use std::collections::BTreeMap;
use std::time::Duration;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const DEFAULT_USER_AGENT: &str =
"Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 Sphinx/9.1.0";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum TlsCacerts {
Bundle(String),
PerHost(BTreeMap<String, String>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct HttpConfig {
pub tls_verify: bool,
pub tls_cacerts: Option<TlsCacerts>,
pub user_agent: Option<String>,
pub timeout: Option<f64>,
}
impl Default for HttpConfig {
fn default() -> Self {
Self {
tls_verify: true,
tls_cacerts: None,
user_agent: None,
timeout: None,
}
}
}
impl HttpConfig {
pub fn user_agent(&self) -> &str {
match self.user_agent.as_deref() {
Some(agent) if !agent.is_empty() => agent,
_ => DEFAULT_USER_AGENT,
}
}
pub fn timeout_duration(&self) -> Option<Duration> {
let seconds = self.timeout?;
if !seconds.is_finite() || seconds < 0.0 || seconds > i64::MAX as f64 {
log::warn!(
"ignoring unusable intersphinx_timeout {seconds}: \
the timeout must be a non-negative number of seconds"
);
return None;
}
Some(Duration::from_secs_f64(seconds))
}
pub fn ca_bundle_for(&self, url: &str) -> Option<&str> {
match self.tls_cacerts.as_ref()? {
TlsCacerts::Bundle(path) => Some(path.as_str()),
TlsCacerts::PerHost(map) => map.get(netloc(url)?).map(String::as_str),
}
}
}
fn netloc(url: &str) -> Option<&str> {
let after_scheme = url.split_once("://")?.1;
let netloc = match after_scheme.find(['/', '?', '#']) {
Some(end) => &after_scheme[..end],
None => after_scheme,
};
Some(match netloc.rsplit_once('@') {
Some((_userinfo, host)) => host,
None => netloc,
})
}
pub trait InventoryFetcher {
fn fetch(&self, url: &str, http: &HttpConfig) -> Result<Vec<u8>>;
}
pub struct UreqFetcher;
const MAX_INVENTORY_BYTES: u64 = 64 * 1024 * 1024;
fn root_certs_from_pem(pem: &[u8]) -> Result<Vec<ureq::tls::Certificate<'static>>> {
let mut certs = Vec::new();
for item in ureq::tls::parse_pem(pem) {
match item.map_err(|e| anyhow::anyhow!("{e}"))? {
ureq::tls::PemItem::Certificate(cert) => certs.push(cert),
_ => continue,
}
}
if certs.is_empty() {
anyhow::bail!("no PEM-encoded certificate found");
}
Ok(certs)
}
impl InventoryFetcher for UreqFetcher {
fn fetch(&self, url: &str, http: &HttpConfig) -> Result<Vec<u8>> {
let mut tls = ureq::tls::TlsConfig::builder().disable_verification(!http.tls_verify);
if http.tls_verify {
if let Some(bundle) = http.ca_bundle_for(url) {
let pem = std::fs::read(bundle)
.with_context(|| format!("cannot read tls_cacerts bundle {bundle}"))?;
let certs = root_certs_from_pem(&pem)
.map_err(|e| anyhow::anyhow!("invalid tls_cacerts bundle {bundle}: {e}"))?;
tls = tls.root_certs(ureq::tls::RootCerts::new_with_certs(&certs));
}
}
let config = ureq::Agent::config_builder()
.user_agent(http.user_agent().to_string())
.timeout_global(http.timeout_duration())
.tls_config(tls.build())
.build();
let agent: ureq::Agent = config.into();
let mut response = agent.get(url).call()?;
let body = response
.body_mut()
.with_config()
.limit(MAX_INVENTORY_BYTES)
.read_to_vec()?;
Ok(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_timeout(timeout: Option<f64>) -> HttpConfig {
HttpConfig {
timeout,
..HttpConfig::default()
}
}
#[test]
fn an_unusable_timeout_degrades_to_none() {
for unusable in [
-1.0,
-0.5,
f64::NAN,
f64::INFINITY,
f64::NEG_INFINITY,
1e30,
f64::MAX,
] {
assert_eq!(
with_timeout(Some(unusable)).timeout_duration(),
None,
"intersphinx_timeout = {unusable}"
);
}
}
#[test]
fn a_usable_timeout_is_kept_and_no_timeout_stays_no_timeout() {
assert_eq!(
with_timeout(Some(2.5)).timeout_duration(),
Some(Duration::from_millis(2500))
);
assert_eq!(
with_timeout(Some(0.0)).timeout_duration(),
Some(Duration::ZERO),
"zero is a legal (if useless) timeout, not an error"
);
assert_eq!(with_timeout(None).timeout_duration(), None);
}
const TWO_CERT_BUNDLE: &[u8] = b"\
-----BEGIN CERTIFICATE-----
AQID
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
BAUG
-----END CERTIFICATE-----
";
#[test]
fn a_ca_bundle_keeps_every_certificate_in_it() {
let certs = root_certs_from_pem(TWO_CERT_BUNDLE).expect("a two-cert bundle parses");
assert_eq!(
certs.len(),
2,
"a bundle's later roots must not be dropped: `Certificate::from_pem` \
returns only the first, which would reject servers chaining to any other"
);
}
#[test]
fn a_bundle_with_no_certificate_in_it_is_an_error() {
assert!(root_certs_from_pem(b"not a pem file at all\n").is_err());
}
#[test]
fn the_default_configuration_verifies_certificates() {
assert!(
HttpConfig::default().tls_verify,
"a default that skips verification would be a trap"
);
}
#[test]
fn the_default_user_agent_is_sphinx_9_1_0s_verbatim() {
let config = HttpConfig::default();
assert_eq!(
config.user_agent(),
"Mozilla/5.0 (X11; Linux x86_64; rv:100.0) Gecko/20100101 Firefox/100.0 Sphinx/9.1.0"
);
let empty = HttpConfig {
user_agent: Some(String::new()),
..HttpConfig::default()
};
assert_eq!(empty.user_agent(), DEFAULT_USER_AGENT);
let custom = HttpConfig {
user_agent: Some("mine/1".to_string()),
..HttpConfig::default()
};
assert_eq!(custom.user_agent(), "mine/1");
}
#[test]
fn tls_cacerts_resolve_per_url_for_the_mapping_form() {
let bundle = HttpConfig {
tls_cacerts: Some(TlsCacerts::Bundle("/etc/ca.pem".to_string())),
..HttpConfig::default()
};
assert_eq!(
bundle.ca_bundle_for("https://anything.example/objects.inv"),
Some("/etc/ca.pem"),
"a plain string is the bundle for every URL"
);
let per_host = HttpConfig {
tls_cacerts: Some(TlsCacerts::PerHost(BTreeMap::from([(
"docs.example.org".to_string(),
"/etc/example.pem".to_string(),
)]))),
..HttpConfig::default()
};
assert_eq!(
per_host.ca_bundle_for("https://user:pw@docs.example.org/v1/objects.inv"),
Some("/etc/example.pem"),
"the key is the netloc with userinfo stripped"
);
assert_eq!(
per_host.ca_bundle_for("https://other.example.org/objects.inv"),
None,
"an unnamed host falls back to the default trust store"
);
assert_eq!(HttpConfig::default().ca_bundle_for("https://x/y"), None);
}
#[test]
fn netloc_keeps_the_port_and_drops_userinfo_path_and_query() {
assert_eq!(
netloc("https://a.example:8443/x?y#z"),
Some("a.example:8443")
);
assert_eq!(
netloc("https://u:p@a.example:8443/x"),
Some("a.example:8443")
);
assert_eq!(netloc("https://a.example"), Some("a.example"));
assert_eq!(netloc("local.inv"), None);
}
}