use crate::error::{DepsError, Result};
use bytes::{Bytes, BytesMut};
use dashmap::DashMap;
use reqwest::{Client, Response, StatusCode, Url, header};
use serde::Serialize;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
const MAX_CACHE_ENTRIES: usize = 1000;
const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024;
const MAX_CACHEABLE_ENTRY_BYTES: usize = MAX_CACHE_BYTES / 8;
const HTTP_TIMEOUT_SECS: u64 = 30;
const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
const CACHE_EVICTION_PERCENTAGE: usize = 10;
#[cfg(any(test, feature = "test-util"))]
fn is_loopback_host(url: &str) -> bool {
let Some(rest) = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
else {
return false;
};
let host_and_port = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = if let Some(bracketed) = host_and_port.strip_prefix('[') {
bracketed.split(']').next().unwrap_or("")
} else {
host_and_port.split(':').next().unwrap_or("")
};
matches!(host, "127.0.0.1" | "localhost" | "::1")
}
#[inline]
fn ensure_https(url: &str) -> Result<()> {
if url.starts_with("https://") {
return Ok(());
}
#[cfg(any(test, feature = "test-util"))]
if is_loopback_host(url) {
return Ok(());
}
Err(DepsError::CacheError(format!("URL must use HTTPS: {url}")))
}
fn is_https_downgrade(previous: &Url, next: &Url) -> bool {
previous.scheme() == "https" && next.scheme() == "http"
}
fn redirect_policy() -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(|attempt| {
let downgraded = attempt
.previous()
.last()
.is_some_and(|previous| is_https_downgrade(previous, attempt.url()));
if downgraded {
attempt.stop()
} else {
reqwest::redirect::Policy::default().redirect(attempt)
}
})
}
fn trusted_origin_redirect_policy(trusted_origin: String) -> reqwest::redirect::Policy {
reqwest::redirect::Policy::custom(move |attempt| {
if attempt.url().as_str().starts_with(&trusted_origin) {
reqwest::redirect::Policy::default().redirect(attempt)
} else {
attempt.stop()
}
})
}
fn build_client(redirect: reqwest::redirect::Policy) -> Client {
Client::builder()
.user_agent(format!("deps-lsp/{}", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
.redirect(redirect)
.build()
.expect("failed to create HTTP client")
}
async fn read_body_capped(url: &str, mut response: Response) -> Result<Bytes> {
let mut body = BytesMut::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|e| DepsError::RegistryError {
package: url.to_string(),
source: e,
})?
{
if body.len() + chunk.len() > MAX_RESPONSE_BYTES {
return Err(DepsError::ResponseTooLarge {
url: url.to_string(),
limit: MAX_RESPONSE_BYTES,
});
}
body.extend_from_slice(&chunk);
}
Ok(body.freeze())
}
#[derive(Debug, Clone)]
pub struct CachedResponse {
pub body: Bytes,
pub etag: Option<String>,
pub last_modified: Option<String>,
pub fetched_at: Instant,
}
pub struct HttpCache {
entries: DashMap<String, CachedResponse>,
total_bytes: AtomicUsize,
client: Client,
trusted_clients: DashMap<String, Client>,
}
impl HttpCache {
pub fn new() -> Self {
Self {
entries: DashMap::new(),
total_bytes: AtomicUsize::new(0),
client: build_client(redirect_policy()),
trusted_clients: DashMap::new(),
}
}
fn client_for_origin(&self, trusted_origin: &str) -> Client {
if let Some(existing) = self.trusted_clients.get(trusted_origin) {
return existing.clone();
}
self.trusted_clients
.entry(trusted_origin.to_string())
.or_insert_with(|| {
build_client(trusted_origin_redirect_policy(trusted_origin.to_string()))
})
.clone()
}
pub async fn get_cached(&self, url: &str) -> Result<Bytes> {
self.get_cached_with_headers(url, &[]).await
}
#[must_use]
pub fn peek_cached(&self, url: &str) -> Option<Bytes> {
self.entries.get(url).map(|r| r.body.clone())
}
pub async fn get_cached_with_headers(
&self,
url: &str,
extra_headers: &[(header::HeaderName, &str)],
) -> Result<Bytes> {
self.get_cached_with_headers_via(url, extra_headers, &self.client)
.await
}
pub async fn get_cached_trusted_origin(
&self,
url: &str,
trusted_origin: &str,
) -> Result<Bytes> {
let client = self.client_for_origin(trusted_origin);
self.get_cached_with_headers_via(url, &[], &client).await
}
async fn get_cached_with_headers_via(
&self,
url: &str,
extra_headers: &[(header::HeaderName, &str)],
client: &Client,
) -> Result<Bytes> {
if self.entries.len() >= MAX_CACHE_ENTRIES
|| self.total_bytes.load(Ordering::Relaxed) >= MAX_CACHE_BYTES
{
self.evict_entries();
}
if let Some(cached) = self.entries.get(url).map(|r| r.clone()) {
match self
.conditional_request_with_headers(url, &cached, extra_headers, client)
.await
{
Ok(Some(new_body)) => return Ok(new_body),
Ok(None) => return Ok(cached.body),
Err(e) => {
tracing::warn!("conditional request failed, using cache: {e}");
return Ok(cached.body);
}
}
}
self.fetch_and_store_with_headers(url, extra_headers, client)
.await
}
async fn conditional_request_with_headers(
&self,
url: &str,
cached: &CachedResponse,
extra_headers: &[(header::HeaderName, &str)],
client: &Client,
) -> Result<Option<Bytes>> {
ensure_https(url)?;
let mut request = client.get(url);
for (name, value) in extra_headers {
request = request.header(name, *value);
}
if let Some(etag) = &cached.etag {
request = request.header(header::IF_NONE_MATCH, etag);
}
if let Some(last_modified) = &cached.last_modified {
request = request.header(header::IF_MODIFIED_SINCE, last_modified);
}
let response = request.send().await.map_err(|e| DepsError::RegistryError {
package: url.to_string(),
source: e,
})?;
if response.status() == StatusCode::NOT_MODIFIED {
return Ok(None);
}
if !response.status().is_success() {
return Err(DepsError::HttpStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get(header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = read_body_capped(url, response).await?;
self.store_entry(
url.to_string(),
CachedResponse {
body: body.clone(),
etag,
last_modified,
fetched_at: Instant::now(),
},
);
Ok(Some(body))
}
async fn fetch_and_store_with_headers(
&self,
url: &str,
extra_headers: &[(header::HeaderName, &str)],
client: &Client,
) -> Result<Bytes> {
ensure_https(url)?;
tracing::debug!(extra_headers = extra_headers.len(), "fetching fresh: {url}");
let mut request = client.get(url);
for (name, value) in extra_headers {
request = request.header(name, *value);
}
let response = request.send().await.map_err(|e| DepsError::RegistryError {
package: url.to_string(),
source: e,
})?;
if !response.status().is_success() {
return Err(DepsError::HttpStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
let etag = response
.headers()
.get(header::ETAG)
.and_then(|v| v.to_str().ok())
.map(String::from);
let last_modified = response
.headers()
.get(header::LAST_MODIFIED)
.and_then(|v| v.to_str().ok())
.map(String::from);
let body = read_body_capped(url, response).await?;
self.store_entry(
url.to_string(),
CachedResponse {
body: body.clone(),
etag,
last_modified,
fetched_at: Instant::now(),
},
);
Ok(body)
}
pub async fn post_json<T: Serialize + ?Sized>(&self, url: &str, body: &T) -> Result<Bytes> {
ensure_https(url)?;
let response = self.client.post(url).json(body).send().await.map_err(|e| {
DepsError::RegistryError {
package: url.to_string(),
source: e,
}
})?;
if !response.status().is_success() {
return Err(DepsError::HttpStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
read_body_capped(url, response).await
}
pub async fn get_transport_only(&self, url: &str) -> Result<Bytes> {
self.get_transport_only_with_headers(url, &[]).await
}
pub async fn get_transport_only_with_headers(
&self,
url: &str,
extra_headers: &[(header::HeaderName, &str)],
) -> Result<Bytes> {
ensure_https(url)?;
let mut request = self.client.get(url);
for (name, value) in extra_headers {
request = request.header(name, *value);
}
let response = request.send().await.map_err(|e| DepsError::RegistryError {
package: url.to_string(),
source: e,
})?;
if !response.status().is_success() {
return Err(DepsError::HttpStatus {
url: url.to_string(),
status: response.status().as_u16(),
});
}
read_body_capped(url, response).await
}
fn store_entry(&self, url: String, response: CachedResponse) {
let new_len = response.body.len();
if new_len > MAX_CACHEABLE_ENTRY_BYTES {
if let Some((_, old)) = self.entries.remove(&url) {
self.total_bytes
.fetch_sub(old.body.len(), Ordering::Relaxed);
}
return;
}
let old_len = self
.entries
.insert(url, response)
.map_or(0, |old| old.body.len());
self.total_bytes.fetch_add(new_len, Ordering::Relaxed);
self.total_bytes.fetch_sub(old_len, Ordering::Relaxed);
}
pub fn clear(&self) {
self.entries.clear();
self.total_bytes.store(0, Ordering::Relaxed);
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn total_bytes(&self) -> usize {
self.total_bytes.load(Ordering::Relaxed)
}
fn evict_entries(&self) {
use std::cmp::Reverse;
use std::collections::BinaryHeap;
let count_target_removals = if self.entries.len() >= MAX_CACHE_ENTRIES {
(MAX_CACHE_ENTRIES / CACHE_EVICTION_PERCENTAGE).max(1)
} else {
0
};
let mut oldest: BinaryHeap<Reverse<(Instant, String)>> = self
.entries
.iter()
.map(|entry| Reverse((entry.value().fetched_at, entry.key().clone())))
.collect();
let mut removed = 0usize;
while removed < count_target_removals
|| self.total_bytes.load(Ordering::Relaxed) > MAX_CACHE_BYTES
{
let Some(Reverse((_, url))) = oldest.pop() else {
break;
};
if let Some((_, old)) = self.entries.remove(&url) {
self.total_bytes
.fetch_sub(old.body.len(), Ordering::Relaxed);
}
removed += 1;
}
tracing::debug!(
"evicted {removed} cache entries ({} bytes remaining)",
self.total_bytes.load(Ordering::Relaxed)
);
}
#[doc(hidden)]
pub fn get_for_bench(&self, url: &str) -> Option<Bytes> {
self.entries.get(url).map(|entry| entry.body.clone())
}
#[doc(hidden)]
pub fn insert_for_bench(&self, url: String, response: CachedResponse) {
self.store_entry(url, response);
}
}
impl Default for HttpCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ensure_https_rejects_non_loopback_http() {
assert!(ensure_https("http://example.com").is_err());
}
#[test]
fn test_ensure_https_rejects_hosts_resembling_loopback() {
assert!(ensure_https("http://localhost.evil.com/").is_err());
assert!(ensure_https("http://127.0.0.1.evil.com/").is_err());
assert!(ensure_https("http://evil.com/?cb=127.0.0.1").is_err());
}
#[test]
fn test_ensure_https_accepts_bracketed_ipv6_loopback() {
assert!(ensure_https("http://[::1]:1234/x").is_ok());
}
#[test]
fn test_is_https_downgrade() {
let https = Url::parse("https://example.com/a").unwrap();
let http = Url::parse("http://example.com/a").unwrap();
assert!(is_https_downgrade(&https, &http));
assert!(!is_https_downgrade(&http, &https));
assert!(!is_https_downgrade(&https, &https));
assert!(!is_https_downgrade(&http, &http));
}
#[tokio::test]
async fn test_get_cached_follows_same_scheme_redirect() {
let mut server = mockito::Server::new_async().await;
let target_url = format!("{}/api/target", server.url());
let _redirect = server
.mock("GET", "/api/source")
.with_status(302)
.with_header("location", &target_url)
.create_async()
.await;
let _target = server
.mock("GET", "/api/target")
.with_status(200)
.with_body("redirected data")
.create_async()
.await;
let cache = HttpCache::new();
let source_url = format!("{}/api/source", server.url());
let result: Bytes = cache.get_cached(&source_url).await.unwrap();
assert_eq!(result.as_ref(), b"redirected data");
}
#[tokio::test]
async fn test_get_cached_trusted_origin_stops_cross_origin_redirect() {
let mut trusted_server = mockito::Server::new_async().await;
let mut other_server = mockito::Server::new_async().await;
let trusted_origin = format!("{}/", trusted_server.url());
let escape_target = format!("{}/api/stolen", other_server.url());
let _redirect = trusted_server
.mock("GET", "/api/source")
.with_status(302)
.with_header("location", &escape_target)
.create_async()
.await;
let escape = other_server
.mock("GET", "/api/stolen")
.with_status(200)
.with_body("must not be returned")
.expect(0)
.create_async()
.await;
let cache = HttpCache::new();
let source_url = format!("{}/api/source", trusted_server.url());
let result: Result<Bytes> = cache
.get_cached_trusted_origin(&source_url, &trusted_origin)
.await;
match result {
Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 302),
other => panic!("expected HttpStatus(302), got {other:?}"),
}
escape.assert_async().await;
}
#[tokio::test]
async fn test_get_cached_trusted_origin_follows_same_origin_redirect() {
let mut server = mockito::Server::new_async().await;
let trusted_origin = format!("{}/", server.url());
let target_url = format!("{}/api/target", server.url());
let _redirect = server
.mock("GET", "/api/source")
.with_status(302)
.with_header("location", &target_url)
.create_async()
.await;
let _target = server
.mock("GET", "/api/target")
.with_status(200)
.with_body("trusted data")
.create_async()
.await;
let cache = HttpCache::new();
let source_url = format!("{}/api/source", server.url());
let result: Bytes = cache
.get_cached_trusted_origin(&source_url, &trusted_origin)
.await
.unwrap();
assert_eq!(result.as_ref(), b"trusted data");
}
#[tokio::test]
async fn test_get_cached_trusted_origin_stops_second_hop_of_multi_hop_chain() {
let mut trusted_server = mockito::Server::new_async().await;
let mut other_server = mockito::Server::new_async().await;
let trusted_origin = format!("{}/", trusted_server.url());
let intermediate_url = format!("{}/api/intermediate", trusted_server.url());
let escape_target = format!("{}/api/stolen", other_server.url());
let _first_hop = trusted_server
.mock("GET", "/api/source")
.with_status(302)
.with_header("location", &intermediate_url)
.create_async()
.await;
let _second_hop = trusted_server
.mock("GET", "/api/intermediate")
.with_status(302)
.with_header("location", &escape_target)
.create_async()
.await;
let escape = other_server
.mock("GET", "/api/stolen")
.with_status(200)
.with_body("must not be returned")
.expect(0)
.create_async()
.await;
let cache = HttpCache::new();
let source_url = format!("{}/api/source", trusted_server.url());
let result: Result<Bytes> = cache
.get_cached_trusted_origin(&source_url, &trusted_origin)
.await;
match result {
Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 302),
other => panic!("expected HttpStatus(302), got {other:?}"),
}
escape.assert_async().await;
}
#[tokio::test]
async fn test_get_cached_trusted_origin_rejects_sibling_path_prefix() {
let mut server = mockito::Server::new_async().await;
let trusted_origin = format!("{}/api/", server.url());
let escape_target = format!("{}/apiX/evil", server.url());
let _redirect = server
.mock("GET", "/api/source")
.with_status(302)
.with_header("location", &escape_target)
.create_async()
.await;
let escape = server
.mock("GET", "/apiX/evil")
.with_status(200)
.with_body("must not be returned")
.expect(0)
.create_async()
.await;
let cache = HttpCache::new();
let source_url = format!("{}/api/source", server.url());
let result: Result<Bytes> = cache
.get_cached_trusted_origin(&source_url, &trusted_origin)
.await;
match result {
Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 302),
other => panic!("expected HttpStatus(302), got {other:?}"),
}
escape.assert_async().await;
}
#[tokio::test]
async fn test_get_cached_default_client_enforces_ten_hop_redirect_limit() {
let mut server = mockito::Server::new_async().await;
let base = server.url();
let mut hop_mocks = Vec::new();
for i in 0..11u32 {
let path = format!("/step/{i}");
let next = format!("{base}/step/{}", i + 1);
hop_mocks.push(
server
.mock("GET", path.as_str())
.with_status(302)
.with_header("location", &next)
.create_async()
.await,
);
}
let final_step = server
.mock("GET", "/step/11")
.with_status(200)
.with_body("unreachable")
.expect(0)
.create_async()
.await;
assert_eq!(hop_mocks.len(), 11);
let cache = HttpCache::new();
let start_url = format!("{base}/step/0");
let result: Result<Bytes> = cache.get_cached(&start_url).await;
assert!(
matches!(result, Err(DepsError::RegistryError { .. })),
"expected a too-many-redirects network error, got {result:?}"
);
final_step.assert_async().await;
}
#[test]
fn test_cache_creation() {
let cache = HttpCache::new();
assert_eq!(cache.len(), 0);
assert!(cache.is_empty());
}
#[test]
fn test_cache_clear() {
let cache = HttpCache::new();
cache.entries.insert(
"test".into(),
CachedResponse {
body: Bytes::from_static(&[1, 2, 3]),
etag: None,
last_modified: None,
fetched_at: Instant::now(),
},
);
assert_eq!(cache.len(), 1);
cache.clear();
assert_eq!(cache.len(), 0);
}
#[test]
fn test_cached_response_clone() {
let response = CachedResponse {
body: Bytes::from_static(&[1, 2, 3]),
etag: Some("test".into()),
last_modified: Some("date".into()),
fetched_at: Instant::now(),
};
let cloned = response.clone();
assert_eq!(response.body, cloned.body);
assert_eq!(response.etag, cloned.etag);
}
#[test]
fn test_cache_len() {
let cache = HttpCache::new();
assert_eq!(cache.len(), 0);
cache.entries.insert(
"url1".into(),
CachedResponse {
body: Bytes::new(),
etag: None,
last_modified: None,
fetched_at: Instant::now(),
},
);
assert_eq!(cache.len(), 1);
}
#[tokio::test]
async fn test_get_cached_fresh_fetch() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/data")
.with_status(200)
.with_header("etag", "\"abc123\"")
.with_body("test data")
.create_async()
.await;
let cache = HttpCache::new();
let url = format!("{}/api/data", server.url());
let result: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result.as_ref(), b"test data");
assert_eq!(cache.len(), 1);
}
#[tokio::test]
async fn test_get_cached_cache_hit() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
let _m1 = server
.mock("GET", "/api/data")
.with_status(200)
.with_header("etag", "\"abc123\"")
.with_body("original data")
.create_async()
.await;
let result1: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result1.as_ref(), b"original data");
assert_eq!(cache.len(), 1);
drop(_m1);
let _m2 = server
.mock("GET", "/api/data")
.match_header("if-none-match", "\"abc123\"")
.with_status(304)
.create_async()
.await;
let result2: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result2.as_ref(), b"original data");
}
#[tokio::test]
async fn test_get_cached_304_not_modified() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
let _m1 = server
.mock("GET", "/api/data")
.with_status(200)
.with_header("etag", "\"abc123\"")
.with_body("original data")
.create_async()
.await;
let result1: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result1.as_ref(), b"original data");
drop(_m1);
let _m2 = server
.mock("GET", "/api/data")
.match_header("if-none-match", "\"abc123\"")
.with_status(304)
.create_async()
.await;
let result2: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result2.as_ref(), b"original data");
}
#[tokio::test]
async fn test_get_cached_etag_validation() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
cache.entries.insert(
url.clone(),
CachedResponse {
body: Bytes::from_static(b"cached"),
etag: Some("\"tag123\"".into()),
last_modified: None,
fetched_at: Instant::now(),
},
);
let _m = server
.mock("GET", "/api/data")
.match_header("if-none-match", "\"tag123\"")
.with_status(304)
.create_async()
.await;
let result: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result.as_ref(), b"cached");
}
#[tokio::test]
async fn test_get_cached_last_modified_validation() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
cache.entries.insert(
url.clone(),
CachedResponse {
body: Bytes::from_static(b"cached"),
etag: None,
last_modified: Some("Wed, 21 Oct 2024 07:28:00 GMT".into()),
fetched_at: Instant::now(),
},
);
let _m = server
.mock("GET", "/api/data")
.match_header("if-modified-since", "Wed, 21 Oct 2024 07:28:00 GMT")
.with_status(304)
.create_async()
.await;
let result: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result.as_ref(), b"cached");
}
#[tokio::test]
async fn test_get_cached_network_error_fallback() {
let cache = HttpCache::new();
let url = "http://invalid.localhost.test/data";
cache.entries.insert(
url.to_string(),
CachedResponse {
body: Bytes::from_static(b"stale data"),
etag: Some("\"old\"".into()),
last_modified: None,
fetched_at: Instant::now(),
},
);
let result: Bytes = cache.get_cached(url).await.unwrap();
assert_eq!(result.as_ref(), b"stale data");
}
#[tokio::test]
async fn test_fetch_and_store_http_error() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/missing")
.with_status(404)
.with_body("Not Found")
.create_async()
.await;
let cache = HttpCache::new();
let url = format!("{}/api/missing", server.url());
let result: Result<Bytes> = cache
.fetch_and_store_with_headers(&url, &[], &cache.client)
.await;
assert!(result.is_err());
match result {
Err(DepsError::HttpStatus { status, .. }) => {
assert_eq!(status, 404);
}
_ => panic!("Expected HttpStatus"),
}
}
#[tokio::test]
async fn test_fetch_and_store_stores_headers() {
let mut server = mockito::Server::new_async().await;
let _m = server
.mock("GET", "/api/data")
.with_status(200)
.with_header("etag", "\"abc123\"")
.with_header("last-modified", "Wed, 21 Oct 2024 07:28:00 GMT")
.with_body("test")
.create_async()
.await;
let cache = HttpCache::new();
let url = format!("{}/api/data", server.url());
let _: Bytes = cache
.fetch_and_store_with_headers(&url, &[], &cache.client)
.await
.unwrap();
let cached = cache.entries.get(&url).unwrap();
assert_eq!(cached.etag, Some("\"abc123\"".into()));
assert_eq!(
cached.last_modified,
Some("Wed, 21 Oct 2024 07:28:00 GMT".into())
);
}
#[tokio::test]
async fn test_get_cached_with_headers_sends_extra_headers() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let _m = server
.mock("GET", "/api/data")
.match_header("authorization", "Bearer token123")
.with_status(200)
.with_header("etag", "\"abc123\"")
.with_body("authed data")
.create_async()
.await;
let cache = HttpCache::new();
let headers = [(header::AUTHORIZATION, "Bearer token123")];
let result: Bytes = cache.get_cached_with_headers(&url, &headers).await.unwrap();
assert_eq!(result.as_ref(), b"authed data");
}
#[tokio::test]
async fn test_fetch_and_store_rejects_oversized_response() {
let mut server = mockito::Server::new_async().await;
let oversized_body = vec![0u8; MAX_RESPONSE_BYTES + 1];
let _m = server
.mock("GET", "/api/huge")
.with_status(200)
.with_body(oversized_body)
.create_async()
.await;
let cache = HttpCache::new();
let url = format!("{}/api/huge", server.url());
let result: Result<Bytes> = cache
.fetch_and_store_with_headers(&url, &[], &cache.client)
.await;
match result {
Err(DepsError::ResponseTooLarge { limit, .. }) => {
assert_eq!(limit, MAX_RESPONSE_BYTES);
}
other => panic!("expected ResponseTooLarge, got {other:?}"),
}
assert!(cache.entries.get(&url).is_none());
}
#[tokio::test]
async fn test_fetch_and_store_accepts_response_at_exact_cap() {
let mut server = mockito::Server::new_async().await;
let exact_cap_body = vec![0u8; MAX_RESPONSE_BYTES];
let _m = server
.mock("GET", "/api/exact")
.with_status(200)
.with_body(exact_cap_body)
.create_async()
.await;
let cache = HttpCache::new();
let url = format!("{}/api/exact", server.url());
let result: Bytes = cache
.fetch_and_store_with_headers(&url, &[], &cache.client)
.await
.unwrap();
assert_eq!(result.len(), MAX_RESPONSE_BYTES);
}
#[tokio::test]
async fn test_get_cached_non_2xx_on_refresh_preserves_stale_cache() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
cache.entries.insert(
url.clone(),
CachedResponse {
body: Bytes::from_static(b"stale but good"),
etag: Some("\"stale-etag\"".into()),
last_modified: None,
fetched_at: Instant::now(),
},
);
let _m = server
.mock("GET", "/api/data")
.match_header("if-none-match", "\"stale-etag\"")
.with_status(503)
.with_body("<html>maintenance</html>")
.create_async()
.await;
let result: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result.as_ref(), b"stale but good");
let cached = cache.entries.get(&url).unwrap();
assert_eq!(cached.etag, Some("\"stale-etag\"".into()));
}
#[tokio::test]
async fn test_post_json_success_returns_body_and_does_not_cache() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/v1/querybatch", server.url());
let _m = server
.mock("POST", "/v1/querybatch")
.match_header("content-type", "application/json")
.with_status(200)
.with_body(r#"{"results":[{}]}"#)
.create_async()
.await;
let cache = HttpCache::new();
let body = serde_json::json!({ "queries": [] });
let result: Bytes = cache.post_json(&url, &body).await.unwrap();
assert_eq!(result.as_ref(), br#"{"results":[{}]}"#);
assert!(
cache.is_empty(),
"post_json must not populate the entry-map cache"
);
}
#[tokio::test]
async fn test_post_json_non_2xx_returns_http_status_error() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/v1/querybatch", server.url());
let _m = server
.mock("POST", "/v1/querybatch")
.with_status(400)
.create_async()
.await;
let cache = HttpCache::new();
let body = serde_json::json!({ "queries": [] });
let result: Result<Bytes> = cache.post_json(&url, &body).await;
match result {
Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 400),
other => panic!("expected HttpStatus, got {other:?}"),
}
}
#[tokio::test]
async fn test_get_transport_only_success_returns_body_and_does_not_cache() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/v1/vulns/RUSTSEC-2020-0071", server.url());
let _m = server
.mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
.with_status(200)
.with_body(r#"{"id":"RUSTSEC-2020-0071"}"#)
.create_async()
.await;
let cache = HttpCache::new();
let result: Bytes = cache.get_transport_only(&url).await.unwrap();
assert_eq!(result.as_ref(), br#"{"id":"RUSTSEC-2020-0071"}"#);
assert!(
cache.is_empty(),
"get_transport_only must not populate the entry-map cache"
);
}
#[tokio::test]
async fn test_get_transport_only_with_headers_sends_extra_headers() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/v1/vulns/RUSTSEC-2020-0071", server.url());
let _m = server
.mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
.match_header("accept", "application/json")
.with_status(200)
.with_body(r#"{"id":"RUSTSEC-2020-0071"}"#)
.create_async()
.await;
let cache = HttpCache::new();
let headers = [(header::ACCEPT, "application/json")];
let result: Bytes = cache
.get_transport_only_with_headers(&url, &headers)
.await
.unwrap();
assert_eq!(result.as_ref(), br#"{"id":"RUSTSEC-2020-0071"}"#);
assert!(
cache.is_empty(),
"get_transport_only_with_headers must not populate the entry-map cache"
);
}
#[tokio::test]
async fn test_get_transport_only_non_2xx_returns_http_status_error() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/v1/vulns/missing", server.url());
let _m = server
.mock("GET", "/v1/vulns/missing")
.with_status(404)
.create_async()
.await;
let cache = HttpCache::new();
let result: Result<Bytes> = cache.get_transport_only(&url).await;
match result {
Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 404),
other => panic!("expected HttpStatus, got {other:?}"),
}
}
fn dummy_response(size: usize) -> CachedResponse {
CachedResponse {
body: Bytes::from(vec![0u8; size]),
etag: None,
last_modified: None,
fetched_at: Instant::now(),
}
}
#[test]
fn test_total_bytes_tracks_inserts_and_replacement() {
let cache = HttpCache::new();
cache.store_entry("url1".into(), dummy_response(100));
assert_eq!(cache.total_bytes(), 100);
cache.store_entry("url1".into(), dummy_response(40));
assert_eq!(cache.total_bytes(), 40);
cache.store_entry("url2".into(), dummy_response(60));
assert_eq!(cache.total_bytes(), 100);
}
#[test]
fn test_clear_resets_total_bytes() {
let cache = HttpCache::new();
cache.store_entry("url1".into(), dummy_response(1000));
assert_eq!(cache.total_bytes(), 1000);
cache.clear();
assert_eq!(cache.total_bytes(), 0);
}
#[test]
fn test_small_payloads_do_not_trigger_eviction() {
let cache = HttpCache::new();
for i in 0..50 {
cache.store_entry(format!("url{i}"), dummy_response(1024));
}
assert_eq!(cache.len(), 50);
assert_eq!(cache.total_bytes(), 50 * 1024);
}
#[test]
fn test_evict_entries_triggers_on_byte_budget_with_few_entries() {
let cache = HttpCache::new();
for i in 0..9 {
cache.store_entry(format!("url{i}"), dummy_response(MAX_CACHEABLE_ENTRY_BYTES));
}
assert_eq!(cache.len(), 9);
assert!(cache.total_bytes() > MAX_CACHE_BYTES);
cache.evict_entries();
assert!(cache.total_bytes() <= MAX_CACHE_BYTES);
assert_eq!(cache.len(), 8);
}
#[test]
fn test_evict_entries_removes_oldest_first_for_bytes() {
let cache = HttpCache::new();
cache.store_entry("oldest".into(), dummy_response(MAX_CACHEABLE_ENTRY_BYTES));
std::thread::sleep(std::time::Duration::from_millis(5));
for i in 0..8 {
cache.store_entry(
format!("newer{i}"),
dummy_response(MAX_CACHEABLE_ENTRY_BYTES),
);
}
assert_eq!(cache.len(), 9);
cache.evict_entries();
assert_eq!(cache.len(), 8);
assert!(cache.entries.get("oldest").is_none());
for i in 0..8 {
assert!(cache.entries.get(&format!("newer{i}")).is_some());
}
}
#[tokio::test]
async fn test_get_cached_with_headers_evicts_on_byte_budget() {
let mut server = mockito::Server::new_async().await;
let url = format!("{}/api/data", server.url());
let cache = HttpCache::new();
for i in 0..9 {
cache.store_entry(
format!("stale{i}"),
dummy_response(MAX_CACHEABLE_ENTRY_BYTES),
);
}
assert!(cache.total_bytes() > MAX_CACHE_BYTES);
let _m = server
.mock("GET", "/api/data")
.with_status(200)
.with_body("fresh")
.create_async()
.await;
let result: Bytes = cache.get_cached(&url).await.unwrap();
assert_eq!(result.as_ref(), b"fresh");
assert!(cache.total_bytes() <= MAX_CACHE_BYTES + result.len());
}
#[test]
fn test_store_entry_skips_caching_oversized_entry() {
let cache = HttpCache::new();
cache.store_entry("big".into(), dummy_response(MAX_CACHEABLE_ENTRY_BYTES + 1));
assert!(cache.entries.get("big").is_none());
assert_eq!(cache.total_bytes(), 0);
cache.store_entry("small".into(), dummy_response(100));
assert_eq!(cache.total_bytes(), 100);
cache.store_entry(
"small".into(),
dummy_response(MAX_CACHEABLE_ENTRY_BYTES + 1),
);
assert!(cache.entries.get("small").is_none());
assert_eq!(cache.total_bytes(), 0);
}
#[test]
fn test_concurrent_store_and_evict_keeps_total_bytes_consistent() {
use std::sync::Arc;
use std::thread;
let cache = Arc::new(HttpCache::new());
let handles: Vec<_> = (0..8)
.map(|t| {
let cache = Arc::clone(&cache);
thread::spawn(move || {
for i in 0..150 {
cache.store_entry(format!("t{t}-{i}"), dummy_response(4096));
if i % 10 == 0 {
cache.evict_entries();
}
}
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
cache.evict_entries();
let actual: usize = cache
.entries
.iter()
.map(|entry| entry.value().body.len())
.sum();
assert_eq!(cache.total_bytes(), actual);
}
}