#[cfg(any(test, feature = "test-support"))]
#[cfg_attr(not(feature = "test-support"), allow(unreachable_pub))]
pub(crate) mod scripted;
use crate::context::Context;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig, TlsProvider};
pub(crate) fn test_base(ctx: &Context) -> Option<String> {
ctx.api_base.clone().filter(|url| is_loopback(url))
}
fn is_loopback(url: &str) -> bool {
let Ok(uri) = url.parse::<ureq::http::Uri>() else {
return false;
};
uri.scheme_str() == Some("http")
&& uri.host().is_some_and(|host| {
host.trim_start_matches('[')
.trim_end_matches(']')
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
}
const TIMEOUT: Duration = Duration::from_secs(5);
#[derive(Clone, Default)]
pub(crate) struct Agents(Arc<OnceLock<Agent>>);
impl std::fmt::Debug for Agents {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Agents")
.field("made", &self.0.get().is_some())
.finish()
}
}
pub(crate) fn agent(ctx: &Context) -> &Agent {
ctx.agent
.0
.get_or_init(|| made(ctx, ureq::unversioned::resolver::DefaultResolver::default()))
}
#[allow(
clippy::disallowed_methods,
reason = "the one agent configuration Pitboard makes, which is given the context's proxy, \
and the one agent, given the resolver a test can replace"
)]
pub(crate) fn made(ctx: &Context, resolver: impl ureq::unversioned::resolver::Resolver) -> Agent {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut config = Agent::config_builder()
.proxy(ctx.proxy.for_ureq())
.tls_config(
TlsConfig::builder()
.provider(TlsProvider::Rustls)
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.timeout_global(Some(TIMEOUT))
.http_status_as_error(false)
.user_agent(concat!("pitboard/", env!("CARGO_PKG_VERSION")));
if let Some(refusal) = ctx.proxy.refusal() {
config = config.middleware(refusal).max_redirects(0);
}
Agent::with_parts(
config.build(),
ureq::unversioned::transport::DefaultConnector::default(),
resolver,
)
}
pub(crate) fn retry_after(headers: &ureq::http::HeaderMap) -> Option<i64> {
headers
.get("retry-after")?
.to_str()
.ok()?
.trim()
.parse::<i64>()
.ok()
.filter(|seconds| *seconds > 0)
}
pub(crate) fn server_time(headers: &ureq::http::HeaderMap) -> Option<i64> {
let raw = headers.get("date")?.to_str().ok()?;
jiff::civil::DateTime::strptime("%a, %d %b %Y %H:%M:%S GMT", raw)
.ok()?
.to_zoned(jiff::tz::TimeZone::UTC)
.ok()
.map(|z| z.timestamp().as_second())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_instant_an_answer_says_it_was_sent_is_read_off_it() {
let mut headers = ureq::http::HeaderMap::new();
headers.insert(
"date",
"Tue, 22 Sep 2026 12:34:56 GMT".parse().expect("valid"),
);
assert_eq!(server_time(&headers), Some(1_790_080_496));
headers.insert("date", "whenever".parse().expect("valid"));
assert_eq!(
server_time(&headers),
None,
"an answer Pitboard cannot read the time off is not a reason to guess one"
);
assert_eq!(server_time(&ureq::http::HeaderMap::new()), None);
}
#[test]
fn only_a_loopback_address_can_redirect_requests() {
for allowed in ["http://127.0.0.1:8080", "http://[::1]:9"] {
assert!(is_loopback(allowed), "{allowed}");
}
for refused in [
"https://evil.example.com",
"http://127.0.0.1.evil.example.com:80",
"http://localhost.evil.example.com:80",
"http://127.0.0.1:@evil.example.com/",
"http://127.0.0.1:8080@evil.example.com/",
"http://localhost:1",
"https://127.0.0.1:443",
"http://10.0.0.1:80",
"",
] {
assert!(
!is_loopback(refused),
"{refused} must not be able to answer identity"
);
}
}
}