···11//! A bounded in-memory LRU cache for blob bytes.
22//!
33//! Blobs are content-addressed by CID (immutable), so a fetched blob is valid
44-//! forever and can be shared across DIDs. The PDS upstream doesn't honour HTTP
44+//! forever and can be shared across DIDs. Entries are keyed by an opaque string
55+//! built from that CID plus the rendition being served (see
66+//! `get_blob_handler::cache_key`), so a resized avatar variant and the original
77+//! bytes coexist without colliding. The PDS upstream doesn't honour HTTP
58//! Range, so to serve cheap byte-ranges (seeking, media duration) the AppView
69//! holds the full bytes locally and slices them. Keeping recently-served blobs
77-//! in RAM means the PDS is hit at most once per CID while it stays hot, and
1010+//! in RAM means the PDS is hit at most once per key while it stays hot, and
811//! every range request after that is a zero-copy `Bytes::slice`.
912//!
1013//! Eviction is by total byte size (least-recently-used first), not entry count.
···3134 total: u64,
3235}
33363434-/// Thread-safe, size-bounded LRU of blob bytes keyed by CID.
3737+/// Thread-safe, size-bounded LRU of blob bytes keyed by CID and rendition.
3538pub struct BlobCache {
3639 inner: Mutex<Inner>,
3740 cap: u64,
···6265 Self::new(cap)
6366 }
64676565- /// Returns the cached blob for `cid`, marking it most-recently-used.
6666- pub fn get(&self, cid: &str) -> Option<CacheEntry> {
6767- self.inner.lock().unwrap().map.get(cid).cloned()
6868+ /// Returns the cached blob for `key`, marking it most-recently-used.
6969+ pub fn get(&self, key: &str) -> Option<CacheEntry> {
7070+ self.inner.lock().unwrap().map.get(key).cloned()
6871 }
69727070- /// Inserts (or replaces) `cid`'s blob, then evicts least-recently-used
7373+ /// Inserts (or replaces) `key`'s blob, then evicts least-recently-used
7174 /// entries until the cache is back within its byte ceiling. A blob larger
7275 /// than the whole ceiling is not cached at all, so it can't evict
7376 /// everything else for a single use.
7474- pub fn insert(&self, cid: &str, entry: CacheEntry) {
7777+ pub fn insert(&self, key: &str, entry: CacheEntry) {
7578 let size = entry.bytes.len() as u64;
7679 if size > self.cap {
7780 return;
···8083 let mut guard = self.inner.lock().unwrap();
8184 let inner = &mut *guard;
82858383- if let Some(old) = inner.map.put(cid.to_string(), entry) {
8686+ if let Some(old) = inner.map.put(key.to_string(), entry) {
8487 inner.total = inner.total.saturating_sub(old.bytes.len() as u64);
8588 }
8689 inner.total += size;
···6677use std::collections::HashMap;
88use std::net::{IpAddr, SocketAddr};
99+use std::num::NonZeroUsize;
1010+use std::sync::{LazyLock, Mutex};
911use std::time::Duration;
10121313+use lru::LruCache;
1114use reqwest::{Client, Response, Url, redirect::Policy};
1215use scraper::{Html, Selector};
1316use serde::Serialize;
14171518/// How long an outbound fetch may take before it's abandoned.
1619const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
2020+/// The budget for fetching a blob, which moves a real payload (an avatar, an
2121+/// attachment) rather than a page of HTML
2222+pub const BLOB_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
1723/// Maximum number of redirects we'll follow (each one is re-validated).
1824const MAX_REDIRECTS: usize = 5;
2525+/// How many pinned clients to keep alive. Each one owns a connection pool, so
2626+/// this is really "how many upstream hosts stay warm at once".
2727+const CLIENT_CACHE_CAPACITY: usize = 256;
1928/// A browser-ish UA — many sites only emit OG tags for "real" user agents.
2029const USER_AGENT: &str = "Mozilla/5.0 (compatible; ColibriBot/1.0; +https://colibri.social)";
2130···165174 Ok(addrs)
166175}
167176168168-fn pinned_client(host: &str, addrs: &[SocketAddr]) -> Result<Client, FetchError> {
169169- Client::builder()
170170- .timeout(FETCH_TIMEOUT)
177177+/// Identifies a pinned client by exactly the things that make it safe to reuse:
178178+/// the host it was built for, the addresses it is pinned to, and its timeout
179179+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
180180+struct ClientKey {
181181+ host: String,
182182+ addrs: Vec<SocketAddr>,
183183+ timeout: Duration,
184184+}
185185+186186+static CLIENT_CACHE: LazyLock<Mutex<LruCache<ClientKey, Client>>> = LazyLock::new(|| {
187187+ Mutex::new(LruCache::new(
188188+ NonZeroUsize::new(CLIENT_CACHE_CAPACITY).expect("capacity is non-zero"),
189189+ ))
190190+});
191191+192192+/// A client pinned to `addrs`, reused across calls. Building a `reqwest::Client`
193193+/// is not cheap, it constructs a fresh connection pool and loads the TLS root
194194+/// store, so doing it per request meant every blob fetch paid a full TLS
195195+/// handshake against a PDS we were already talking to.
196196+fn pinned_client(
197197+ host: &str,
198198+ addrs: &[SocketAddr],
199199+ timeout: Duration,
200200+) -> Result<Client, FetchError> {
201201+ let mut pinned = addrs.to_vec();
202202+ pinned.sort();
203203+204204+ let key = ClientKey {
205205+ host: host.to_string(),
206206+ addrs: pinned,
207207+ timeout,
208208+ };
209209+210210+ if let Some(client) = CLIENT_CACHE.lock().unwrap().get(&key) {
211211+ return Ok(client.clone());
212212+ }
213213+214214+ let client = Client::builder()
215215+ .timeout(timeout)
171216 .redirect(Policy::none())
172217 .user_agent(USER_AGENT)
218218+ .pool_idle_timeout(Duration::from_secs(90))
173219 .resolve_to_addrs(host, addrs)
174220 .build()
175175- .map_err(|e| FetchError::Upstream(e.to_string()))
221221+ .map_err(|e| FetchError::Upstream(e.to_string()))?;
222222+223223+ CLIENT_CACHE.lock().unwrap().put(key, client.clone());
224224+225225+ Ok(client)
176226}
177227178228/// SSRF-safe GET: validates the host (and every redirect target) against the
179229/// private-address blocklist, pins each hop's connection to the exact
180230/// addresses just validated, and follows up to [`MAX_REDIRECTS`] redirects
181231pub async fn guarded_get(raw_url: &str) -> Result<Response, FetchError> {
232232+ guarded_get_with_timeout(raw_url, FETCH_TIMEOUT).await
233233+}
234234+235235+/// [`guarded_get`] with an explicit time budget, for callers moving payloads
236236+/// that a scrape-sized timeout would cut off (see [`BLOB_FETCH_TIMEOUT`]).
237237+pub async fn guarded_get_with_timeout(
238238+ raw_url: &str,
239239+ timeout: Duration,
240240+) -> Result<Response, FetchError> {
182241 let mut current = Url::parse(raw_url).map_err(|e| FetchError::InvalidUrl(e.to_string()))?;
183242184243 for _ in 0..=MAX_REDIRECTS {
···187246 .host_str()
188247 .ok_or_else(|| FetchError::InvalidUrl("missing host".into()))?
189248 .to_string();
190190- let client = pinned_client(&host, &addrs)?;
249249+ let client = pinned_client(&host, &addrs, timeout)?;
191250192251 let resp = client
193252 .get(current.clone())
···233292 let host = url
234293 .host_str()
235294 .ok_or_else(|| FetchError::InvalidUrl("missing host".into()))?;
236236- let client = pinned_client(host, &addrs)?;
295295+ let client = pinned_client(host, &addrs, FETCH_TIMEOUT)?;
237296 Ok((client, url))
238297}
239298···368427mod tests {
369428 use super::*;
370429 use rocket::tokio;
430430+431431+ fn cached_clients_for(host: &str) -> usize {
432432+ CLIENT_CACHE
433433+ .lock()
434434+ .unwrap()
435435+ .iter()
436436+ .filter(|(key, _)| key.host == host)
437437+ .count()
438438+ }
439439+440440+ #[test]
441441+ fn repeated_fetches_to_one_host_share_a_client() {
442442+ let host = "client-reuse.test";
443443+ let addrs = vec!["93.184.216.34:443".parse::<SocketAddr>().unwrap()];
444444+445445+ for _ in 0..5 {
446446+ pinned_client(host, &addrs, FETCH_TIMEOUT).unwrap();
447447+ }
448448+449449+ assert_eq!(cached_clients_for(host), 1);
450450+ }
451451+452452+ /// The pin is the security boundary: a client validated against one set of
453453+ /// addresses must never be handed to a request that resolved elsewhere.
454454+ #[test]
455455+ fn a_different_resolution_gets_a_different_client() {
456456+ let host = "rebind.test";
457457+ let first = vec!["93.184.216.34:443".parse::<SocketAddr>().unwrap()];
458458+ let second = vec!["93.184.216.35:443".parse::<SocketAddr>().unwrap()];
459459+460460+ pinned_client(host, &first, FETCH_TIMEOUT).unwrap();
461461+ pinned_client(host, &second, FETCH_TIMEOUT).unwrap();
462462+463463+ assert_eq!(cached_clients_for(host), 2);
464464+ }
465465+466466+ #[test]
467467+ fn address_ordering_does_not_split_the_cache() {
468468+ let host = "multi-a.test";
469469+ let a = "93.184.216.34:443".parse::<SocketAddr>().unwrap();
470470+ let b = "93.184.216.35:443".parse::<SocketAddr>().unwrap();
471471+472472+ pinned_client(host, &[a, b], FETCH_TIMEOUT).unwrap();
473473+ pinned_client(host, &[b, a], FETCH_TIMEOUT).unwrap();
474474+475475+ assert_eq!(cached_clients_for(host), 1);
476476+ }
477477+478478+ #[test]
479479+ fn timeouts_are_not_shared_between_clients() {
480480+ let host = "timeouts.test";
481481+ let addrs = vec!["93.184.216.34:443".parse::<SocketAddr>().unwrap()];
482482+483483+ pinned_client(host, &addrs, FETCH_TIMEOUT).unwrap();
484484+ pinned_client(host, &addrs, BLOB_FETCH_TIMEOUT).unwrap();
485485+486486+ assert_eq!(cached_clients_for(host), 2);
487487+ }
371488372489 #[test]
373490 fn blocks_loopback_and_private_ips() {
···2727pub mod http;
2828pub mod hum_client;
2929pub mod hum_guard;
3030+pub mod image_variant;
3031pub mod klipy;
3132pub mod list_atproto_records;
3233pub mod map_tap_event;
···108108109109#[cfg(test)]
110110mod tests {
111111+ use std::sync::{Mutex, MutexGuard};
112112+111113 use super::*;
112114 use rocket::tokio;
113115 use sea_orm::{DatabaseBackend, MockDatabase};
114116115117 use crate::models::community_credentials as community_credentials_model;
116118119119+ /// `PDS_LOC` is process-global, but cargo runs tests in parallel threads, so
120120+ /// without this every test that touches it can have the var pulled out from
121121+ /// under it by another one mid-assertion.
122122+ static PDS_LOC_LOCK: Mutex<()> = Mutex::new(());
123123+124124+ /// Holds [`PDS_LOC_LOCK`] and sets `PDS_LOC` for the duration of a test,
125125+ /// clearing it again on drop, including when the test panics, which a
126126+ /// trailing `remove_var` would skip.
127127+ struct PdsLocGuard(#[allow(dead_code)] MutexGuard<'static, ()>);
128128+129129+ impl PdsLocGuard {
130130+ fn set(value: &str) -> Self {
131131+ let guard = PDS_LOC_LOCK.lock().unwrap_or_else(|e| e.into_inner());
132132+ // SAFETY: `PDS_LOC_LOCK` is held, so no other test reads or writes
133133+ // the variable until this guard is dropped.
134134+ unsafe { std::env::set_var("PDS_LOC", value) };
135135+ PdsLocGuard(guard)
136136+ }
137137+ }
138138+139139+ impl Drop for PdsLocGuard {
140140+ fn drop(&mut self) {
141141+ // SAFETY: the lock is still held until this guard finishes dropping.
142142+ unsafe { std::env::remove_var("PDS_LOC") };
143143+ }
144144+ }
145145+117146 #[test]
118147 fn as_str_trims_trailing_slash() {
119148 assert_eq!(
···128157129158 #[test]
130159 fn only_managed_rows_on_the_configured_pds_are_trusted() {
131131- // SAFETY: single-threaded test, and the var is restored below.
132132- unsafe { std::env::set_var("PDS_LOC", "http://localhost:3000") };
160160+ let _pds_loc = PdsLocGuard::set("http://localhost:3000");
133161134162 assert!(is_own_pds("http://localhost:3000", SOURCE_APPVIEW_MANAGED));
135163 // Trailing slash / casing differences are the same endpoint.
···138166 assert!(!is_own_pds("http://localhost:3000", "byo"));
139167 // A managed row for some other host (e.g. after PDS_LOC changed).
140168 assert!(!is_own_pds("http://other.example", SOURCE_APPVIEW_MANAGED));
141141-142142- unsafe { std::env::remove_var("PDS_LOC") };
143169 }
144170145171 fn credentials_row(pds_endpoint: &str, source: &str) -> community_credentials_model::Model {
···164190 /// with no network access, which is the property local development needs.
165191 #[tokio::test]
166192 async fn resolve_prefers_a_managed_row_on_our_own_pds() {
167167- unsafe { std::env::set_var("PDS_LOC", "http://localhost:3000") };
193193+ let _pds_loc = PdsLocGuard::set("http://localhost:3000");
168194169195 let db = db_with_row(credentials_row(
170196 "http://localhost:3000",
···176202 endpoint,
177203 RepoEndpoint::Trusted(String::from("http://localhost:3000"))
178204 );
179179-180180- unsafe { std::env::remove_var("PDS_LOC") };
181205 }
182206183207 /// A BYO endpoint is caller-supplied, so it stays behind the SSRF guard even
184208 /// though we hold credentials for it.
185209 #[tokio::test]
186210 async fn resolve_keeps_byo_rows_untrusted() {
187187- unsafe { std::env::set_var("PDS_LOC", "http://localhost:3000") };
211211+ let _pds_loc = PdsLocGuard::set("http://localhost:3000");
188212189213 let db = db_with_row(credentials_row("http://localhost:3000", "byo"));
190214 let endpoint = resolve(&db, "did:plc:comm").await.unwrap();
···193217 endpoint,
194218 RepoEndpoint::Untrusted(String::from("http://localhost:3000"))
195219 );
196196-197197- unsafe { std::env::remove_var("PDS_LOC") };
198220 }
199221200222 #[tokio::test]