use std::collections::HashSet;
use std::time::Duration;
use futures::StreamExt;
use reqwest::{Client, Url};
use crate::error::{Result, SeerError};
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_REDIRECTS: usize = 5;
const DEFAULT_MAX_BODY: usize = 64 * 1024;
#[derive(Debug, Clone)]
pub(crate) struct FetchedResponse {
pub final_url: String,
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: String,
pub redirects: usize,
}
impl FetchedResponse {
pub fn header(&self, name: &str) -> Option<&str> {
self.headers
.iter()
.find(|(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
pub fn header_all<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a str> {
self.headers
.iter()
.filter(move |(k, _)| k == name)
.map(|(_, v)| v.as_str())
}
}
#[derive(Debug, Clone)]
pub(crate) struct GuardedFetcher {
timeout: Duration,
max_body: usize,
allow_private: bool,
}
impl Default for GuardedFetcher {
fn default() -> Self {
Self::new()
}
}
impl GuardedFetcher {
pub fn new() -> Self {
Self {
timeout: DEFAULT_TIMEOUT,
max_body: DEFAULT_MAX_BODY,
allow_private: false,
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn with_max_body(mut self, max_body: usize) -> Self {
self.max_body = max_body;
self
}
#[cfg(test)]
pub fn allowing_private_hosts(mut self) -> Self {
self.allow_private = true;
self
}
pub async fn get(&self, url: &str) -> Result<FetchedResponse> {
let mut url = Url::parse(url)
.map_err(|e| SeerError::HttpError(format!("invalid URL '{}': {}", url, e)))?;
let mut visited: HashSet<String> = HashSet::new();
for hop in 0..=MAX_REDIRECTS {
if !visited.insert(url.as_str().to_string()) {
return Err(SeerError::HttpError("redirect loop detected".to_string()));
}
let mut builder = Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(self.timeout)
.user_agent(concat!("Seer/", env!("CARGO_PKG_VERSION")));
if !self.allow_private {
let addrs = crate::net::validate_http_url(&url).await?;
let host = url
.host_str()
.ok_or_else(|| SeerError::HttpError("missing URL host".to_string()))?;
builder = builder.resolve_to_addrs(host, &addrs);
}
let client = builder
.build()
.map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
let response = client
.get(url.clone())
.send()
.await
.map_err(|e| SeerError::HttpError(e.to_string()))?;
if response.status().is_redirection() {
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok())
.ok_or_else(|| {
SeerError::HttpError("redirect missing location header".to_string())
})?;
url = url
.join(location)
.or_else(|_| Url::parse(location))
.map_err(|e| SeerError::HttpError(format!("invalid redirect URL: {}", e)))?;
continue;
}
let status = response.status().as_u16();
let final_url = response.url().to_string();
let headers = response
.headers()
.iter()
.map(|(name, value)| {
(
name.as_str().to_ascii_lowercase(),
String::from_utf8_lossy(value.as_bytes()).into_owned(),
)
})
.collect();
let body = self.read_capped_body(response).await?;
return Ok(FetchedResponse {
final_url,
status,
headers,
body,
redirects: hop,
});
}
Err(SeerError::HttpError("too many redirects".to_string()))
}
async fn read_capped_body(&self, response: reqwest::Response) -> Result<String> {
let mut buf: Vec<u8> = Vec::with_capacity(8 * 1024);
let mut stream = response.bytes_stream();
let read = tokio::time::timeout(self.timeout, async {
while buf.len() < self.max_body {
let Some(chunk) = stream.next().await else {
break;
};
let chunk =
chunk.map_err(|e| SeerError::HttpError(format!("body chunk: {}", e)))?;
let remaining = self.max_body - buf.len();
let take = remaining.min(chunk.len());
buf.extend_from_slice(&chunk[..take]);
}
Ok::<(), SeerError>(())
})
.await;
match read {
Ok(Ok(())) => {}
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(SeerError::Timeout(format!(
"HTTP body read timed out after {:?}",
self.timeout
)))
}
}
Ok(String::from_utf8_lossy(&buf).into_owned())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn returns_status_headers_and_body() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("X-Frame-Options", "DENY")
.set_body_string("hello world"),
)
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/", server.uri()))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, "hello world");
assert_eq!(resp.redirects, 0);
assert_eq!(resp.header("x-frame-options"), Some("DENY"));
}
#[tokio::test]
async fn collects_repeated_set_cookie_headers() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.append_header("Set-Cookie", "a=1; Secure")
.append_header("Set-Cookie", "b=2; HttpOnly"),
)
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/", server.uri()))
.await
.unwrap();
let cookies: Vec<&str> = resp.header_all("set-cookie").collect();
assert_eq!(cookies.len(), 2, "both Set-Cookie values must survive");
assert!(cookies.contains(&"a=1; Secure"));
assert!(cookies.contains(&"b=2; HttpOnly"));
}
#[tokio::test]
async fn follows_redirects_and_reports_hop_count() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/start"))
.respond_with(ResponseTemplate::new(302).insert_header("location", "/end"))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/end"))
.respond_with(ResponseTemplate::new(200).set_body_string("arrived"))
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/start", server.uri()))
.await
.unwrap();
assert_eq!(resp.status, 200);
assert_eq!(resp.body, "arrived");
assert_eq!(resp.redirects, 1);
assert!(resp.final_url.ends_with("/end"), "got {}", resp.final_url);
}
#[tokio::test]
async fn redirect_loop_is_detected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/loop"))
.respond_with(ResponseTemplate::new(302).insert_header("location", "/loop"))
.mount(&server)
.await;
let err = GuardedFetcher::new()
.allowing_private_hosts()
.get(&format!("{}/loop", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("redirect loop"), "got: {err}");
}
#[tokio::test]
async fn body_is_capped() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/big"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(10_000)))
.mount(&server)
.await;
let resp = GuardedFetcher::new()
.allowing_private_hosts()
.with_max_body(128)
.get(&format!("{}/big", server.uri()))
.await
.unwrap();
assert_eq!(resp.body.len(), 128, "body must be truncated at the cap");
}
#[tokio::test]
async fn production_client_refuses_loopback() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let err = GuardedFetcher::new()
.get(&format!("{}/", server.uri()))
.await
.unwrap_err();
assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
server.verify().await;
}
#[tokio::test]
async fn production_client_refuses_private_and_metadata_literals() {
let fetcher = GuardedFetcher::new();
for url in [
"http://10.0.0.1/",
"https://169.254.169.254/latest/meta-data",
"http://[::1]/",
] {
let err = fetcher.get(url).await.unwrap_err();
assert!(
matches!(err, SeerError::HttpError(_)),
"{url} must be refused, got {err:?}"
);
}
}
#[tokio::test]
async fn rejects_non_http_scheme_and_nonstandard_port() {
let fetcher = GuardedFetcher::new();
let err = fetcher.get("ftp://example.com/").await.unwrap_err();
assert!(err.to_string().contains("scheme"), "got: {err}");
let err = fetcher.get("http://example.com:8080/").await.unwrap_err();
assert!(err.to_string().contains("port"), "got: {err}");
}
#[tokio::test]
async fn rejects_embedded_credentials() {
let err = GuardedFetcher::new()
.get("https://user:pass@example.com/")
.await
.unwrap_err();
assert!(err.to_string().contains("credentials"), "got: {err}");
}
}