use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Debug, thiserror::Error)]
pub enum NetError {
#[error("network refused: intel is offline, and {url} was not fetched")]
Refused { url: String },
#[error("network unavailable for {url}: {why}")]
Unavailable { url: String, why: String },
}
impl NetError {
pub fn is_refused(&self) -> bool {
matches!(self, NetError::Refused { .. })
}
}
pub trait Fetch: Send + Sync {
fn get(&self, url: &str) -> Result<Vec<u8>, NetError>;
}
pub struct IntelNet {
inner: Option<Box<dyn Fetch>>,
refused: AtomicU64,
}
impl IntelNet {
pub fn offline() -> Self {
Self { inner: None, refused: AtomicU64::new(0) }
}
pub fn online(fetch: Box<dyn Fetch>) -> Self {
Self { inner: Some(fetch), refused: AtomicU64::new(0) }
}
#[cfg(feature = "fetch")]
pub fn http() -> Self {
Self::online(Box::new(UreqFetch::new()))
}
pub fn is_offline(&self) -> bool {
self.inner.is_none()
}
pub fn refused_count(&self) -> u64 {
self.refused.load(Ordering::Relaxed)
}
pub fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
match &self.inner {
None => {
self.refused.fetch_add(1, Ordering::Relaxed);
Err(NetError::Refused { url: url.to_string() })
}
Some(f) => f.get(url),
}
}
}
#[cfg(feature = "fetch")]
pub struct UreqFetch {
agent: ureq::Agent,
}
#[cfg(feature = "fetch")]
impl UreqFetch {
pub const MAX_BODY: u64 = 1024 * 1024 * 1024;
pub fn new() -> Self {
use std::time::Duration;
let agent = ureq::Agent::config_builder()
.timeout_connect(Some(Duration::from_secs(15)))
.timeout_recv_response(Some(Duration::from_secs(60)))
.timeout_recv_body(Some(Duration::from_secs(60 * 60)))
.max_redirects(0)
.http_status_as_error(false)
.proxy(None)
.build()
.new_agent();
Self { agent }
}
}
#[cfg(feature = "fetch")]
impl Default for UreqFetch {
fn default() -> Self {
Self::new()
}
}
#[cfg(feature = "fetch")]
impl Fetch for UreqFetch {
fn get(&self, url: &str) -> Result<Vec<u8>, NetError> {
let unavailable = |why: String| NetError::Unavailable { url: url.to_string(), why };
if !url.starts_with("https://") && !is_loopback_http(url) {
return Err(unavailable("only https:// feeds are fetched (plain http on loopback only)".into()));
}
let mut resp = self.agent.get(url).call().map_err(|e| unavailable(e.to_string()))?;
let status = resp.status().as_u16();
if status != 200 {
return Err(unavailable(format!("HTTP {status}")));
}
resp.body_mut()
.with_config()
.limit(Self::MAX_BODY)
.read_to_vec()
.map_err(|e| unavailable(e.to_string()))
}
}
pub fn is_loopback_http(url: &str) -> bool {
let Some(rest) = url.strip_prefix("http://") else { return false };
let host = rest.split('/').next().unwrap_or("");
let host = host.rsplit_once(':').map(|(h, p)| if p.chars().all(|c| c.is_ascii_digit()) { h } else { host }).unwrap_or(host);
matches!(host, "127.0.0.1" | "[::1]" | "localhost")
}
#[cfg(test)]
mod loopback_tests {
#[test]
fn only_a_loopback_host_may_be_plain_http() {
for ok in ["http://127.0.0.1:8080/index.txt", "http://localhost/kev.json", "http://[::1]:9/x"] {
assert!(super::is_loopback_http(ok), "{ok}");
}
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"] {
assert!(!super::is_loopback_http(no), "{no}");
}
}
}