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>;
fn get_capped(&self, url: &str, max: u64) -> Result<Vec<u8>, NetError> {
let _ = max;
self.get(url)
}
}
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),
}
}
pub fn get_feed(&self, spec: &crate::FeedSpec) -> Result<Vec<u8>, NetError> {
match &self.inner {
None => {
self.refused.fetch_add(1, Ordering::Relaxed);
Err(NetError::Refused {
url: spec.uri.clone(),
})
}
Some(f) => f.get_capped(&spec.uri, spec.max_bytes()),
}
}
}
#[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> {
self.get_capped(url, Self::MAX_BODY)
}
fn get_capped(&self, url: &str, max: u64) -> 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(max)
.read_to_vec()
.map_err(|e| unavailable(format!("{e} (this feed is capped at {max} bytes)")))
}
}
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}");
}
}
}
#[cfg(test)]
mod per_feed_cap_tests {
use super::*;
use crate::feeds::FeedSpec;
use std::sync::{Arc, Mutex};
struct Caps(Arc<Mutex<Vec<u64>>>);
impl Fetch for Caps {
fn get(&self, _url: &str) -> Result<Vec<u8>, NetError> {
self.get_capped(_url, u64::MAX)
}
fn get_capped(&self, _url: &str, max: u64) -> Result<Vec<u8>, NetError> {
self.0.lock().unwrap().push(max);
Ok(Vec::new())
}
}
#[test]
fn each_feed_is_fetched_under_its_own_cap() {
let seen = Arc::new(Mutex::new(Vec::new()));
let net = IntelNet::online(Box::new(Caps(Arc::clone(&seen))));
net.get_feed(&FeedSpec::osv("Chainguard")).unwrap();
net.get_feed(&FeedSpec::nvd("2024")).unwrap();
net.get_feed(&FeedSpec::kev()).unwrap();
let seen = seen.lock().unwrap().clone();
assert!(
seen[0] >= 4 * 1024 * 1024 * 1024,
"the OSV dump cap: {seen:?}"
);
assert_eq!(seen[1], 1024 * 1024 * 1024, "{seen:?}");
assert_eq!(seen[2], 1024 * 1024 * 1024, "{seen:?}");
}
}