deps_core/cache.rs
1use crate::error::{DepsError, Result};
2use crate::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
3use bytes::{Bytes, BytesMut};
4use dashmap::DashMap;
5use reqwest::{Client, Response, StatusCode, Url, header};
6use serde::Serialize;
7use std::borrow::Cow;
8use std::hash::{Hash, Hasher};
9use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10use std::sync::{Arc, RwLock};
11use std::time::Instant;
12
13/// Maximum number of cached entries to prevent unbounded memory growth.
14const MAX_CACHE_ENTRIES: usize = 1000;
15
16/// Maximum total bytes retained across all cached response bodies.
17///
18/// `MAX_CACHE_ENTRIES` alone bounds entry *count*, not size: since a single
19/// response body may be as large as [`MAX_RESPONSE_BYTES`] (32 MiB), a cache
20/// full of near-cap entries could retain tens of gigabytes even though real
21/// registry payloads are typically well under 1 MB. This budget is a
22/// defense-in-depth cap (CWE-400) against that worst case, evicted
23/// alongside the count-based limit in [`HttpCache::evict_entries`]. 64 MiB
24/// comfortably holds thousands of typical registry responses while still
25/// bounding the pathological case.
26///
27/// This is a best-effort bound, not a hard guarantee: it is checked once
28/// per request in [`HttpCache::get_cached_with_headers_via`], so multiple
29/// requests already in flight when the budget is crossed can each finish
30/// inserting before the next check fires. [`MAX_CACHEABLE_ENTRY_BYTES`]
31/// keeps that per-request overshoot small (at most one admission-cap-sized
32/// insert per concurrent in-flight request) rather than bounding it exactly.
33const MAX_CACHE_BYTES: usize = 64 * 1024 * 1024;
34
35/// Maximum size of a single response body that will be retained in the
36/// cache; larger bodies are still returned to the caller, just never
37/// stored.
38///
39/// Without this cap, [`MAX_CACHE_BYTES`] alone lets a handful of
40/// large-but-legitimate responses (up to [`MAX_RESPONSE_BYTES`], 32 MiB
41/// each) evict the *entire* rest of the cache: at a 2x ratio between the
42/// two constants, just two max-size entries would saturate the whole
43/// budget. Set to an eighth of [`MAX_CACHE_BYTES`] (8 MiB) so no single
44/// entry can claim more than 1/8 of the budget — a handful of large
45/// responses degrade to "not cached" instead of "evicts the small-payload
46/// working set".
47const MAX_CACHEABLE_ENTRY_BYTES: usize = MAX_CACHE_BYTES / 8;
48
49/// HTTP request timeout in seconds.
50const HTTP_TIMEOUT_SECS: u64 = 30;
51
52/// Maximum decompressed response body size accepted from a single request.
53///
54/// `reqwest`'s `gzip` feature strips `Content-Length`/`Content-Encoding` after
55/// decoding a response, so a header-based pre-check cannot bound body size
56/// (`response.content_length()` is `None` for every decoded response). This
57/// cap is instead enforced by counting bytes as the body streams in, aborting
58/// as soon as the running total would exceed the limit.
59const MAX_RESPONSE_BYTES: usize = 32 * 1024 * 1024;
60
61/// Ceiling every [`BodyLimit`] is clamped to at construction, so no caller can weaken
62/// the size guard [`read_body_capped`] enforces past this value.
63///
64/// 128 MiB comfortably covers the largest known caller ([`crate`][deps-pypi]'s PyPI
65/// Simple API full index, ~43 MB decompressed today, capped at 96 MiB for organic
66/// growth) while still bounding the pathological case.
67const ABSOLUTE_MAX_RESPONSE_BYTES: usize = 128 * 1024 * 1024;
68
69/// Percentage of cache entries to evict when capacity is reached.
70const CACHE_EVICTION_PERCENTAGE: usize = 10;
71
72/// Upper bound on a single response body, clamped at construction so no caller can
73/// weaken the guard `read_body_capped` enforces past `ABSOLUTE_MAX_RESPONSE_BYTES`.
74///
75/// Every cache method that previously read `MAX_RESPONSE_BYTES` directly now takes
76/// this newtype instead (defaulting to it via [`Self::DEFAULT`]), so a caller that
77/// legitimately needs a larger cap — e.g. a full-index fetch that bypasses the entry
78/// cache entirely, like [`HttpCache::get_transport_only_with_headers_limited`] — can
79/// request one without touching the shared constant every other registry client
80/// relies on.
81///
82/// # Examples
83///
84/// ```
85/// use deps_core::cache::BodyLimit;
86///
87/// let default_limit = BodyLimit::DEFAULT;
88/// let clamped = BodyLimit::new(usize::MAX);
89/// assert_ne!(clamped, default_limit);
90/// ```
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub struct BodyLimit(usize);
93
94impl BodyLimit {
95 /// The default limit (`MAX_RESPONSE_BYTES`), used by every cache method that
96 /// does not take an explicit [`BodyLimit`].
97 pub const DEFAULT: Self = Self(MAX_RESPONSE_BYTES);
98
99 /// Creates a limit of `bytes`, clamped down to `ABSOLUTE_MAX_RESPONSE_BYTES` if
100 /// `bytes` exceeds it.
101 #[must_use]
102 pub const fn new(bytes: usize) -> Self {
103 if bytes > ABSOLUTE_MAX_RESPONSE_BYTES {
104 Self(ABSOLUTE_MAX_RESPONSE_BYTES)
105 } else {
106 Self(bytes)
107 }
108 }
109
110 /// The clamped byte value this limit enforces.
111 #[must_use]
112 pub const fn bytes(self) -> usize {
113 self.0
114 }
115}
116
117/// Whether `url`'s host is loopback (`127.0.0.1`, `localhost`, or `::1`), with any scheme
118/// and an optional port — the shape every `mockito::Server` binds to.
119///
120/// Only compiled into test builds (see [`ensure_https`]): a non-loopback host must never
121/// be allowed to bypass the HTTPS requirement, even under `cfg(test)`/`test-util`. See
122/// [`crate::net_policy::validate_index_url`]'s own private loopback check for the
123/// counterpart used on parsed `url::Url` values — kept separate rather than merged, since
124/// this one takes a raw `&str` on `ensure_https`'s hot path and has looser (any-scheme)
125/// semantics.
126#[cfg(any(test, feature = "test-util"))]
127fn is_loopback_host(url: &str) -> bool {
128 let Some(rest) = url
129 .strip_prefix("http://")
130 .or_else(|| url.strip_prefix("https://"))
131 else {
132 return false;
133 };
134 let host_and_port = rest.split(['/', '?', '#']).next().unwrap_or("");
135 let host = if let Some(bracketed) = host_and_port.strip_prefix('[') {
136 bracketed.split(']').next().unwrap_or("")
137 } else {
138 host_and_port.split(':').next().unwrap_or("")
139 };
140 matches!(host, "127.0.0.1" | "localhost" | "::1")
141}
142
143/// Validates that a URL uses HTTPS protocol.
144///
145/// Returns an error if the URL doesn't start with "https://".
146/// This ensures all network requests are encrypted.
147///
148/// A loopback HTTP URL (`127.0.0.1`/`localhost`/`::1`, the shape every `mockito::Server`
149/// binds to) is allowed in `deps-core`'s own test builds (`cfg(test)`) and in other
150/// workspace crates' test builds via the `test-util` feature — `cfg(test)` alone does not
151/// apply there, since those crates depend on `deps-core` as a normal, non-dev dependency.
152/// Any other HTTP host is still rejected even under those cfgs: `test-util` is a public,
153/// independently-enablable crates.io feature, so this must not become "any host, any
154/// environment" just because the feature is on.
155#[inline]
156fn ensure_https(url: &str) -> Result<()> {
157 if url.starts_with("https://") {
158 return Ok(());
159 }
160 #[cfg(any(test, feature = "test-util"))]
161 if is_loopback_host(url) {
162 return Ok(());
163 }
164 Err(DepsError::CacheError(format!("URL must use HTTPS: {url}")))
165}
166
167/// True when a redirect hop moves from an `https` origin to a plain `http` one.
168///
169/// A redirect to any scheme other than `http`/`https` is already rejected by reqwest
170/// itself once a hop is followed, so the downgrade case is the only one this needs to
171/// catch here.
172fn is_https_downgrade(previous: &Url, next: &Url) -> bool {
173 previous.scheme() == "https" && next.scheme() == "http"
174}
175
176/// Whether a redirect hop's target host is one [`crate::net_policy::HostClass::never_a_registry`]
177/// blocks, exempting `Loopback` in test builds — the identical carve-out [`ensure_https`]
178/// already uses, without which every mockito redirect chain in this workspace's tests would
179/// break.
180fn hop_targets_blocked_host(url: &Url) -> bool {
181 let class = crate::net_policy::classify_host(url);
182 #[cfg(any(test, feature = "test-util"))]
183 let class_blocked = class.never_a_registry() && class != crate::net_policy::HostClass::Loopback;
184 #[cfg(not(any(test, feature = "test-util")))]
185 let class_blocked = class.never_a_registry();
186 class_blocked
187}
188
189/// Which cache-key namespace and [`AddrGuard`] tier a [`Transport`] enforces.
190///
191/// `Baseline` is every non-workspace request (all 11 ecosystems' registry/redirect/API
192/// traffic); `WorkspaceDeclared` is Cargo's workspace-declared-registry traffic, carrying the
193/// same [`WorkspaceRegistryAccess`] **value snapshot** an [`AddrGuard::WorkspaceDeclared`]
194/// carries (see that variant's docs) — [`HttpCache::cache_key`] reads the digit from this
195/// snapshot, never from a live `Arc<RegistryAccessPolicy>` read, so a request's cache key and
196/// its guard always agree on which policy era they were constructed under.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
198enum CacheTier {
199 Baseline,
200 WorkspaceDeclared(WorkspaceRegistryAccess),
201 /// An origin-pinned, connect-address-guarded tier (issue #561/#562) — see
202 /// [`Transport::origin_pinned_guarded`]. `digest` identifies the `(trusted_origin,
203 /// policy_snapshot)` pair this transport was built for (**never** a credential identity —
204 /// see [`HttpCache::get_cached_pinned_with_headers`]'s separate `auth_id` argument, folded
205 /// into the cache key only). `authenticated` distinguishes an authenticated fetch (#561)
206 /// from #562's unauthenticated workspace-declared one for cache-eviction purposes
207 /// ([`CacheTier::is_authenticated`]) without affecting pooling — the shipped
208 /// [`Transport::origin_pinned`] (public path) stays on [`CacheTier::Baseline`], unaffected.
209 Pinned {
210 digest: u64,
211 authenticated: bool,
212 },
213}
214
215impl CacheTier {
216 /// Whether a 401/403 revalidation response on an entry under this tier should evict the
217 /// entry rather than serve the default stale-while-revalidate fallback (FR-015/NFR-004).
218 fn is_authenticated(self) -> bool {
219 matches!(
220 self,
221 Self::Pinned {
222 authenticated: true,
223 ..
224 }
225 )
226 }
227}
228
229/// The tier a [`Transport`]'s redirect policy and DNS resolver both enforce.
230///
231/// `WorkspaceDeclared` holds a [`WorkspaceRegistryAccess`] **value snapshot**, taken once at
232/// [`Transport::workspace`] construction time — not a live `Arc<RegistryAccessPolicy>` read on
233/// every [`Self::tier_allows`] call. [`Transport::workspace`] takes this same snapshot for its
234/// paired [`CacheTier::WorkspaceDeclared`], and [`HttpCache::set_registry_policy`] rebuilds the
235/// whole `Transport` (both snapshots included) on every actual policy transition, so a
236/// request's cache key and its guard always come from one consistent construction-time value,
237/// with no read-skew window between them.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239enum AddrGuard {
240 Baseline,
241 WorkspaceDeclared(WorkspaceRegistryAccess),
242}
243
244impl AddrGuard {
245 /// Whether a host classified as `class` may be reached under this guard's tier.
246 fn tier_allows(self, class: crate::net_policy::HostClass) -> bool {
247 match self {
248 Self::Baseline => true,
249 Self::WorkspaceDeclared(policy) => policy.allows(class),
250 }
251 }
252}
253
254/// Redirect policy for a [`Transport`]'s client, parameterized by the [`AddrGuard`] tier that
255/// client enforces.
256///
257/// [`ensure_https`] only validates the *initial* request URL; a `3xx` response can still
258/// redirect the actual connection anywhere, including down to plain HTTP — or, per spec
259/// `.local/specs/023-cargo-custom-registries/spec.md` NFR-003/plan-1b §1.1, straight to a
260/// cloud metadata endpoint or other host no legitimate registry redirect ever targets. This
261/// policy stops the redirect chain (rather than erroring) the moment a hop would do either,
262/// so the caller sees the last successful `3xx` response and handles it exactly like any
263/// other non-2xx status (`DepsError::HttpStatus`) instead of needing a distinct
264/// "redirect blocked" error variant.
265///
266/// The blocked-host check (`hop_targets_blocked_host`) is unconditional and
267/// policy-independent — it does not consult `guard` at all, since
268/// [`HostClass::never_a_registry`](crate::net_policy::HostClass::never_a_registry)
269/// is deliberately narrower than any workspace-registry policy setting: it blocks only the
270/// classes (loopback, link-local, cloud metadata, unspecified) that are never a legitimate
271/// registry redirect target for *any* ecosystem, benefiting every one of the eleven crates
272/// sharing the baseline client, not only Cargo's workspace-declared indexes.
273///
274/// `guard`'s own [`AddrGuard::tier_allows`] term additionally rejects a hop whose target class
275/// the *tier* does not allow — under [`AddrGuard::Baseline`] this term is constant `false`, so
276/// every non-Cargo ecosystem (and Cargo's own `$CARGO_HOME`-provenance traffic) is
277/// bit-for-bit unaffected; under [`AddrGuard::WorkspaceDeclared`] it closes the redirect-hop
278/// half of issue #455 (an IP-literal hop to an RFC1918/CGNAT address, which `hyper-util`
279/// parses directly and never routes through a resolver).
280///
281/// This only classifies the redirect target's URL string, not its DNS-resolved address — that
282/// residual gap (issue #449, "D1" in PR #447's plan) is closed for the name-hop case by
283/// [`BlockedAddrResolver`], which [`build_guarded_client`] wires into every client this module
284/// builds: a redirect hop reuses the same `Client`, so its target's resolved address is
285/// validated too, for free (FR-007).
286///
287/// Every other redirect — including cross-host ones, which are out of scope for this
288/// policy — falls through to reqwest's default (`Policy::limited(10)`), preserving the
289/// existing hop-count limit and mockito's plain-`http://` loopback chains
290/// used throughout this module's tests.
291fn redirect_policy(guard: AddrGuard) -> reqwest::redirect::Policy {
292 reqwest::redirect::Policy::custom(move |attempt| {
293 let downgraded = attempt
294 .previous()
295 .last()
296 .is_some_and(|previous| is_https_downgrade(previous, attempt.url()));
297 if downgraded
298 || hop_targets_blocked_host(attempt.url())
299 || !guard.tier_allows(crate::net_policy::classify_host(attempt.url()))
300 {
301 attempt.stop()
302 } else {
303 reqwest::redirect::Policy::default().redirect(attempt)
304 }
305 })
306}
307
308/// Redirect policy for a [`HttpCache::transport_for_origin`]-scoped client.
309///
310/// Stops any hop whose URL no longer starts with `trusted_origin` — for a caller (e.g.
311/// NuGet's registration-hive paging) that already validated the *initial* request URL
312/// against a trusted prefix and needs that guarantee to hold through a redirect too. This
313/// alone also covers a downgrade to plain `http://`: every current caller passes an
314/// `https://`-prefixed `trusted_origin`, so an `http://` target already fails the prefix
315/// check — a separate scheme check (as [`redirect_policy`] has, for its no-trusted-prefix
316/// case) would be dead code here.
317fn trusted_origin_redirect_policy(trusted_origin: String) -> reqwest::redirect::Policy {
318 reqwest::redirect::Policy::custom(move |attempt| {
319 if attempt.url().as_str().starts_with(&trusted_origin) {
320 reqwest::redirect::Policy::default().redirect(attempt)
321 } else {
322 attempt.stop()
323 }
324 })
325}
326
327/// Error returned by [`BlockedAddrResolver`] when a DNS resolution cannot be trusted for
328/// connection use — either it produced no address, or at least one resolved address falls into
329/// a blocked [`crate::net_policy::HostClass`].
330///
331/// Kept distinct from [`DepsError`] since this crosses into `reqwest::dns::Resolve`'s own
332/// `BoxError` (`Box<dyn std::error::Error + Send + Sync>`) return type, not this crate's own
333/// error type.
334#[derive(Debug, thiserror::Error)]
335enum ResolveGuardError {
336 /// The resolver returned zero addresses for `host` — fail-closed (NFR-004) rather than
337 /// silently treating "nothing resolved" as "nothing to block".
338 #[error("DNS resolution for {host} returned no addresses")]
339 NoAddresses { host: String },
340 /// `addr`, resolved for `host`, falls into `class`, one of the
341 /// [`crate::net_policy::HostClass::never_a_registry`] classes no legitimate registry index
342 /// (or a redirect from one) could ever target.
343 #[error("resolved address {addr} for host {host} is {class}, blocked by net_policy")]
344 Blocked {
345 host: String,
346 addr: std::net::IpAddr,
347 class: crate::net_policy::HostClass,
348 },
349}
350
351/// Validates every address `tokio::net::lookup_host` returned for `host`, rejecting the whole
352/// resolution if any is blocked — an attacker's public A record alongside a blocked one must not
353/// keep the probe alive (FR-003). `guard`'s tier additionally rejects a resolved address whose
354/// class the *tier* does not allow (issue #455: an RFC1918/CGNAT-range name rebound at
355/// connect time), on top of the policy-independent [`HostClass::never_a_registry`](crate::net_policy::HostClass::never_a_registry)
356/// check every tier enforces.
357fn validate_resolved_addrs(
358 host: &str,
359 addrs: Vec<std::net::SocketAddr>,
360 guard: AddrGuard,
361) -> std::result::Result<Vec<std::net::SocketAddr>, ResolveGuardError> {
362 if addrs.is_empty() {
363 tracing::warn!(host, "DNS resolution returned no addresses");
364 return Err(ResolveGuardError::NoAddresses {
365 host: host.to_string(),
366 });
367 }
368 for addr in &addrs {
369 let class = crate::net_policy::classify_addr(addr.ip());
370 if class.never_a_registry() || !guard.tier_allows(class) {
371 tracing::warn!(host, addr = %addr.ip(), %class, "blocking DNS-resolved address");
372 return Err(ResolveGuardError::Blocked {
373 host: host.to_string(),
374 addr: addr.ip(),
375 class,
376 });
377 }
378 }
379 Ok(addrs)
380}
381
382/// The synthetic-lookup function signature [`TestLookup`] wraps.
383#[cfg(test)]
384type SyntheticLookupFn = dyn Fn(&str) -> Vec<std::net::SocketAddr> + Send + Sync;
385
386/// Test-only override for [`BlockedAddrResolver::resolve`], replacing `tokio::net::lookup_host`
387/// with a synthetic lookup — lets a test exercise the resolver-guard wiring against an address
388/// chosen by the test (e.g. an RFC1918 literal) without depending on real DNS. A newtype rather
389/// than a hand-written `Debug` directly on [`BlockedAddrResolver`], so that struct's own
390/// `#[derive(Debug)]` stays valid under both `cfg(test)` and not.
391#[cfg(test)]
392#[derive(Clone)]
393struct TestLookup(Arc<SyntheticLookupFn>);
394
395#[cfg(test)]
396impl std::fmt::Debug for TestLookup {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 f.write_str("TestLookup(..)")
399 }
400}
401
402/// Connect-time DNS resolver that closes the rebinding TOCTOU gap left by [`ensure_https`]/
403/// [`hop_targets_blocked_host`]'s URL-string-only classification (issue #449): those check the
404/// declared hostname, but `reqwest`'s connector resolves DNS independently, later, and an
405/// attacker who controls the hostname's DNS can rebind it to a blocked address in between.
406///
407/// Wired into every client [`build_guarded_client`] returns, so all 11 ecosystem crates sharing
408/// the baseline client pool inherit it with zero per-crate plumbing (FR-006/NFR-002).
409///
410/// # Scope
411///
412/// This resolver's guard tier decides how much a resolved address is scrutinized: under
413/// [`AddrGuard::Baseline`] this enforces only the policy-independent
414/// [`crate::net_policy::HostClass::never_a_registry`] tier (loopback, link-local,
415/// cloud-metadata, unspecified), the same tier [`hop_targets_blocked_host`] already applies —
416/// closing issue #449's filed exploit (cloud-metadata rebinding) but not full `PublicOnly`
417/// semantics. Under [`AddrGuard::WorkspaceDeclared`], [`validate_resolved_addrs`] additionally
418/// rejects any resolved address outside the snapshotted [`crate::net_policy::WorkspaceRegistryAccess`]
419/// policy's allowed classes — closing issue #455 (a workspace-declared name that legitimately
420/// resolves to `HostClass::Global` at parse time, then rebinds to an RFC1918/CGNAT address at
421/// connect time).
422///
423/// # Fail-closed (NFR-004)
424///
425/// Returns `Err` — never `Ok`, never a fallback resolver — on a `lookup_host` error, zero
426/// addresses, or any resolved address [`validate_resolved_addrs`] rejects for `self.guard`'s
427/// tier.
428///
429/// # Known limitations
430///
431/// - [`ClientBuilder::resolve`](reqwest::ClientBuilder::resolve)/
432/// [`resolve_to_addrs`](reqwest::ClientBuilder::resolve_to_addrs) overrides wrap *outside* the
433/// configured resolver (`reqwest`'s `DnsResolverWithOverrides`) and would bypass this guard
434/// entirely if ever called — this workspace does not call them today.
435/// - A configured system proxy (`HTTPS_PROXY`) resolves the target hostname itself; this
436/// resolver then only ever sees the proxy's own address. Operator configuration, not
437/// attacker-controlled, so NOT claimed as a defended case.
438/// - Never applies to an IP-literal host (`https://169.254.169.254/`): `hyper-util`'s connector
439/// parses those directly and never calls the configured resolver, so
440/// [`classify_host`](crate::net_policy::classify_host) (via [`ensure_https`]/
441/// [`hop_targets_blocked_host`]/[`redirect_policy`]'s tier term) remains the sole guard for
442/// literals — a disjoint domain from this resolver's name-based one, not a gap. This is also
443/// why the cache layer gives [`HttpCache::get_cached_workspace`] zero protection against an
444/// *initial* request URL that is itself an IP literal — see that method's docs.
445/// - Unlike [`ensure_https`]/[`hop_targets_blocked_host`], this resolver has **no** `test-util`
446/// carve-out for `Loopback`: it blocks a `localhost`/`127.0.0.1` *name* unconditionally, in
447/// every build. A downstream `test-util` consumer that mocks by binding an IP literal (as
448/// this workspace's own `mockito` usage does) is unaffected — literals never reach this
449/// resolver at all — but one that mocks via a `localhost` *name* would be newly blocked.
450#[derive(Debug, Clone)]
451struct BlockedAddrResolver {
452 guard: AddrGuard,
453 #[cfg(test)]
454 lookup: Option<TestLookup>,
455}
456
457impl BlockedAddrResolver {
458 fn new(guard: AddrGuard) -> Self {
459 Self {
460 guard,
461 #[cfg(test)]
462 lookup: None,
463 }
464 }
465
466 #[cfg(test)]
467 fn with_lookup(guard: AddrGuard, lookup: TestLookup) -> Self {
468 Self {
469 guard,
470 lookup: Some(lookup),
471 }
472 }
473}
474
475impl reqwest::dns::Resolve for BlockedAddrResolver {
476 fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
477 let host = name.as_str().to_string();
478 let guard = self.guard;
479 #[cfg(test)]
480 let lookup = self.lookup.clone();
481 Box::pin(async move {
482 #[cfg(test)]
483 let addrs: Vec<std::net::SocketAddr> = match &lookup {
484 Some(lookup) => (lookup.0)(&host),
485 None => tokio::net::lookup_host((host.as_str(), 0)).await?.collect(),
486 };
487 #[cfg(not(test))]
488 let addrs: Vec<std::net::SocketAddr> =
489 tokio::net::lookup_host((host.as_str(), 0)).await?.collect();
490
491 let addrs = validate_resolved_addrs(&host, addrs, guard)?;
492 Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
493 })
494 }
495}
496
497/// Builds a client with `HttpCache`'s shared configuration (user agent, timeout), varying the
498/// redirect policy and resolver — kept in one place so a future client-wide setting (proxy,
499/// connection pool sizing, etc.) can't silently miss any [`Transport`] this module builds. This
500/// is also the workspace's only `Client::builder()` call site.
501fn build_client_inner(
502 redirect: reqwest::redirect::Policy,
503 resolver: BlockedAddrResolver,
504) -> Client {
505 Client::builder()
506 .user_agent(format!("deps-lsp/{}", env!("CARGO_PKG_VERSION")))
507 .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS))
508 .redirect(redirect)
509 .dns_resolver(resolver)
510 .build()
511 .expect("failed to create HTTP client")
512}
513
514/// Pairs one [`AddrGuard`] value with both halves it governs — its redirect policy and its
515/// resolver — so a client whose redirect policy and resolver enforce different tiers cannot be
516/// built by this function.
517fn build_guarded_client(guard: AddrGuard) -> Client {
518 build_client_inner(redirect_policy(guard), BlockedAddrResolver::new(guard))
519}
520
521/// Test-only variant of [`build_guarded_client`] that substitutes a synthetic DNS lookup for
522/// `tokio::net::lookup_host` — shares [`build_client_inner`] with the production constructor, so
523/// deleting the `.dns_resolver(...)` wiring from that shared function fails any test built on
524/// this too, not just the production path.
525#[cfg(test)]
526fn build_guarded_client_with_lookup(guard: AddrGuard, lookup: TestLookup) -> Client {
527 build_client_inner(
528 redirect_policy(guard),
529 BlockedAddrResolver::with_lookup(guard, lookup),
530 )
531}
532
533/// A `Client` welded to the [`CacheTier`] its guard enforces.
534///
535/// [`Self::baseline`], [`Self::workspace`] and [`Self::origin_pinned`] are the only sanctioned
536/// way to build one: each derives its redirect policy, its resolver and its tier from a single
537/// [`AddrGuard`] value, so a mismatched pairing (e.g. a baseline-guarded client keyed under the
538/// workspace cache namespace) never arises through normal construction — though `cache.rs` is
539/// one module, so a hand-written `Transport { .. }` literal elsewhere in this file could still
540/// mismatch them; the three constructors are what make that a deliberate act, not an accident
541/// reachable by passing the wrong argument to an existing function.
542#[derive(Clone)]
543struct Transport {
544 client: Client,
545 tier: CacheTier,
546}
547
548impl Transport {
549 /// The shared, unauthenticated transport used by every non-workspace request.
550 fn baseline() -> Self {
551 Self {
552 client: build_guarded_client(AddrGuard::Baseline),
553 tier: CacheTier::Baseline,
554 }
555 }
556
557 /// The transport for Cargo's workspace-declared-registry requests, snapshotting `policy`'s
558 /// current value once and sharing that single snapshot between the guard and the cache-key
559 /// tier — see [`AddrGuard::WorkspaceDeclared`]'s docs for why this is a value snapshot, not
560 /// a live `Arc` read, and why the guard and the tier must never snapshot independently.
561 fn workspace(policy: &Arc<RegistryAccessPolicy>) -> Self {
562 let snapshot = policy.get();
563 Self {
564 client: build_guarded_client(AddrGuard::WorkspaceDeclared(snapshot)),
565 tier: CacheTier::WorkspaceDeclared(snapshot),
566 }
567 }
568
569 /// The transport for one [`HttpCache::transport_for_origin`]-pinned origin: a plain
570 /// [`AddrGuard::Baseline`] resolver, paired with [`trusted_origin_redirect_policy`] instead
571 /// of [`redirect_policy`] — that policy pins by URL prefix, which already subsumes the
572 /// blocked-host hop check, so this is the one documented caller of [`build_client_inner`]
573 /// directly rather than [`build_guarded_client`].
574 fn origin_pinned(trusted_origin: &str) -> Self {
575 Self {
576 client: build_client_inner(
577 trusted_origin_redirect_policy(trusted_origin.to_string()),
578 BlockedAddrResolver::new(AddrGuard::Baseline),
579 ),
580 tier: CacheTier::Baseline,
581 }
582 }
583
584 /// The transport for one origin-pinned **workspace-declared** host (issue #561/#562),
585 /// optionally carrying a credential. Pairs [`trusted_origin_redirect_policy`] (send-scope
586 /// confinement — no redirect hop may leave `trusted_origin`) with
587 /// [`AddrGuard::WorkspaceDeclared`] (the connect-address policy guard, #455-class
588 /// protection) and the namespaced [`CacheTier::Pinned`] tier. One constructor serves both
589 /// #562's unauthenticated workspace-declared fetches (`authenticated: false`) and #561's
590 /// authenticated ones (`authenticated: true`) — `authenticated` only affects
591 /// [`HttpCache`]'s revalidation-eviction rule (FR-015), never `AddrGuard`/redirect
592 /// confinement. The shipped [`Self::origin_pinned`] (public `api.nuget.org` path) is
593 /// unaffected — this is a distinct constructor, not a modification of that one.
594 fn origin_pinned_guarded(
595 trusted_origin: &str,
596 policy: &Arc<RegistryAccessPolicy>,
597 authenticated: bool,
598 ) -> Self {
599 let snapshot = policy.get();
600 Self {
601 client: build_client_inner(
602 trusted_origin_redirect_policy(trusted_origin.to_string()),
603 BlockedAddrResolver::new(AddrGuard::WorkspaceDeclared(snapshot)),
604 ),
605 tier: CacheTier::Pinned {
606 digest: pinned_digest(trusted_origin, snapshot),
607 authenticated,
608 },
609 }
610 }
611}
612
613/// Identifies a `(trusted_origin, policy_snapshot)` pair for [`CacheTier::Pinned`] — **never**
614/// a credential identity (see that variant's docs). Not cryptographically salted: unlike a
615/// caller's own credential-header digest (e.g. `deps_nuget`'s salted `auth_id`), this value is
616/// not attacker-observable secret material, only a pool/cache-key discriminant.
617fn pinned_digest(trusted_origin: &str, snapshot: WorkspaceRegistryAccess) -> u64 {
618 let mut hasher = std::collections::hash_map::DefaultHasher::new();
619 trusted_origin.hash(&mut hasher);
620 snapshot.hash(&mut hasher);
621 hasher.finish()
622}
623
624/// Reads a response body incrementally, aborting once it exceeds `limit`.
625///
626/// Chunked reading (via [`Response::chunk`]) is required because the
627/// decompressed body size is not known upfront: `gzip` decoding strips
628/// `Content-Length`, so the only reliable guard against an oversized or
629/// maliciously amplified (decompression-bomb) response is counting bytes
630/// as they arrive and bailing before the whole body is buffered.
631async fn read_body_capped(url: &str, mut response: Response, limit: BodyLimit) -> Result<Bytes> {
632 let mut body = BytesMut::new();
633 let limit = limit.bytes();
634
635 while let Some(chunk) = response
636 .chunk()
637 .await
638 .map_err(|e| DepsError::RegistryError {
639 package: url.to_string(),
640 source: e,
641 })?
642 {
643 if body.len() + chunk.len() > limit {
644 return Err(DepsError::ResponseTooLarge {
645 url: url.to_string(),
646 limit,
647 });
648 }
649 body.extend_from_slice(&chunk);
650 }
651
652 Ok(body.freeze())
653}
654
655/// Cached HTTP response with validation headers.
656///
657/// Stores response body and cache validation headers (ETag, Last-Modified)
658/// for efficient conditional requests. The body uses `Bytes` which is an
659/// Arc-like type optimized for network data, enabling zero-cost cloning
660/// across multiple consumers without copying.
661///
662/// # Examples
663///
664/// ```
665/// use deps_core::cache::CachedResponse;
666/// use bytes::Bytes;
667/// use std::time::Instant;
668///
669/// let response = CachedResponse {
670/// body: Bytes::from("response data"),
671/// etag: Some("\"abc123\"".into()),
672/// last_modified: None,
673/// fetched_at: Instant::now(),
674/// };
675///
676/// // Clone is cheap - only increments reference count
677/// let cloned = response.clone();
678/// ```
679#[derive(Debug, Clone)]
680pub struct CachedResponse {
681 pub body: Bytes,
682 pub etag: Option<String>,
683 pub last_modified: Option<String>,
684 pub fetched_at: Instant,
685}
686
687/// HTTP cache with ETag and Last-Modified validation.
688///
689/// Implements RFC 7232 conditional requests to minimize network traffic.
690/// All responses are cached with their validation headers, and subsequent
691/// requests use `If-None-Match` (ETag) or `If-Modified-Since` headers
692/// to check for updates.
693///
694/// The cache uses `Bytes` for response bodies, enabling efficient sharing
695/// of cached data across multiple consumers without copying. `Bytes` is
696/// an Arc-like type optimized for network I/O.
697///
698/// # Examples
699///
700/// ```no_run
701/// use deps_core::cache::HttpCache;
702///
703/// # async fn example() -> deps_core::error::Result<()> {
704/// let cache = HttpCache::new();
705///
706/// // First request - fetches from network
707/// let data1 = cache.get_cached("https://index.crates.io/se/rd/serde").await?;
708///
709/// // Second request - uses conditional GET (304 Not Modified if unchanged)
710/// let data2 = cache.get_cached("https://index.crates.io/se/rd/serde").await?;
711/// # Ok(())
712/// # }
713/// ```
714///
715/// # Cache key
716///
717/// Entries are keyed by URL alone (see `Self::cache_key`, private) — `extra_headers` (see
718/// [`HttpCache::get_cached_with_headers`]) play no part in the cache key.
719/// This is safe only as long as "same URL" implies "same representation":
720/// a content-negotiating header (e.g. a per-request `Accept`) that can vary
721/// the response body for an otherwise-identical URL requires giving each
722/// distinct representation its own URL (e.g. a query parameter or distinct
723/// path), not just a distinct header value, or callers requesting different
724/// representations of the same URL will silently share one cache entry.
725///
726/// Likewise, the key doesn't encode *which* client (and so which redirect policy)
727/// produced an entry — [`HttpCache::get_cached`] and [`HttpCache::get_cached_trusted_origin`]
728/// share one entry map. No caller today requests the same URL through both, but one that did
729/// could observe the other's cached (and differently redirect-validated) body.
730///
731/// [`Self::get_cached_workspace`] is the one exception: it is namespaced under a distinct,
732/// policy-scoped key prefix (see `Self::cache_key`, private) so a body fetched under a looser
733/// [`crate::net_policy::WorkspaceRegistryAccess`] can never be served back once the policy
734/// tightens.
735pub struct HttpCache {
736 entries: DashMap<String, CachedResponse>,
737 /// Running total of `body.len()` across all `entries`, kept in sync by
738 /// [`HttpCache::store_entry`], [`HttpCache::evict_entries`], and
739 /// [`HttpCache::clear`] via relative `fetch_add`/`fetch_sub` only —
740 /// never an absolute `store` after the initial `0`, since that would
741 /// silently discard any concurrent relative update racing with it. Used
742 /// to trigger byte-bounded eviction without summing every entry on each
743 /// check; advisory (see [`MAX_CACHE_BYTES`]), not an exact live count
744 /// under concurrent access.
745 total_bytes: AtomicUsize,
746 /// The shared, unauthenticated transport used by every non-workspace request.
747 baseline: Transport,
748 /// Per-`(trusted_origin, tier)` transport pool backing [`Self::get_cached_trusted_origin`]
749 /// and [`Self::get_cached_pinned`] alike (issue #561/#562, FR-017), keyed by the exact
750 /// `trusted_origin` prefix string passed to that call, paired with the [`CacheTier`] it was
751 /// built for. reqwest's redirect policy is fixed per-`Client`, so a distinct client is
752 /// unavoidable per distinct origin; pooled here so repeated calls against the same
753 /// `(origin, tier)` reuse one transport (and its connection pool) instead of rebuilding on
754 /// every call. Deliberately **uncapped** — see [`Self::set_registry_policy`]'s docs for why
755 /// a capacity cap was considered and dropped for this pool.
756 trusted_clients: DashMap<(String, CacheTier), Transport>,
757 /// Live-updatable Cargo workspace-registry policy the `workspace` transport field below
758 /// and cache-key namespace are derived from. Kept alongside that field (not just read once
759 /// at construction) so [`Self::cache_key`] and [`Self::set_registry_policy`] both read the
760 /// same discriminant.
761 policy: Arc<RegistryAccessPolicy>,
762 /// The transport for [`Self::get_cached_workspace`], rebuilt in place by
763 /// [`Self::set_registry_policy`] on every actual policy transition.
764 workspace: RwLock<Transport>,
765 /// Test-only counter of how many times [`Self::set_registry_policy`] has actually rebuilt
766 /// the `workspace` transport field above (as opposed to no-op'ing on an unchanged value) —
767 /// asserts C4's rebuild-only-on-change behavior directly.
768 #[cfg(test)]
769 workspace_rebuilds: AtomicUsize,
770 /// Live-updatable "no outbound requests" flag (issue #483). Enforced by
771 /// [`Self::ensure_online`] at all 4 send sites. See [`Self::set_offline`]'s docs for
772 /// the override this has on `cache_enabled` below.
773 offline: AtomicBool,
774 /// Live-updatable entry-map toggle (issue #482): `false` bypasses the entry map
775 /// entirely (see `get_cached_with_headers_via`). Overridden to effectively `true`
776 /// whenever `offline` is set — see [`Self::set_offline`]'s docs.
777 cache_enabled: AtomicBool,
778}
779
780impl HttpCache {
781 /// Creates a new HTTP cache with default configuration and the default
782 /// [`crate::net_policy::WorkspaceRegistryAccess`] policy (`PublicOnly`).
783 ///
784 /// The cache uses a configurable timeout for all requests and identifies
785 /// itself with an auto-versioned user agent.
786 pub fn new() -> Self {
787 Self::with_policy(Arc::new(RegistryAccessPolicy::default()))
788 }
789
790 /// Creates a new HTTP cache whose [`Self::get_cached_workspace`] requests are governed by
791 /// `policy`'s live value.
792 ///
793 /// A later [`Self::set_registry_policy`] call rebuilds the workspace transport (and its
794 /// cache-key namespace) in place, so every caller holding this `HttpCache` sees the new
795 /// policy take effect immediately, with no need to reconstruct the cache.
796 ///
797 /// # Examples
798 ///
799 /// ```
800 /// use deps_core::HttpCache;
801 /// use deps_core::net_policy::RegistryAccessPolicy;
802 /// use std::sync::Arc;
803 ///
804 /// let policy = Arc::new(RegistryAccessPolicy::default());
805 /// let cache = HttpCache::with_policy(Arc::clone(&policy));
806 /// assert!(cache.is_empty());
807 /// ```
808 pub fn with_policy(policy: Arc<RegistryAccessPolicy>) -> Self {
809 let workspace = Transport::workspace(&policy);
810 Self {
811 entries: DashMap::new(),
812 total_bytes: AtomicUsize::new(0),
813 baseline: Transport::baseline(),
814 trusted_clients: DashMap::new(),
815 policy,
816 workspace: RwLock::new(workspace),
817 #[cfg(test)]
818 workspace_rebuilds: AtomicUsize::new(0),
819 offline: AtomicBool::new(false),
820 cache_enabled: AtomicBool::new(true),
821 }
822 }
823
824 /// Sets whether outbound network requests are permitted (issue #483).
825 ///
826 /// Enforced by `Self::ensure_online` (private) at every one of this module's 4 send sites —
827 /// effective for every call after this returns. While `value` is `true`, this also
828 /// overrides `cache_enabled` (see [`Self::set_cache_enabled`]) to behave as `true` on
829 /// both the read and write path in `get_cached_with_headers_via`: without this, a
830 /// warm entry fetched before going offline could never have been stored in the first
831 /// place if caching was disabled, leaving the offline warm-cache path with nothing to
832 /// serve — the exact combination `cache.enabled: false` + `network.offline: true` is
833 /// meant to survive.
834 pub fn set_offline(&self, value: bool) {
835 self.offline.store(value, Ordering::Relaxed);
836 }
837
838 /// Returns whether outbound network requests are currently blocked.
839 #[must_use]
840 pub fn is_offline(&self) -> bool {
841 self.offline.load(Ordering::Relaxed)
842 }
843
844 /// Sets whether the entry-map cache is used (issue #482). See [`Self::set_offline`]'s
845 /// docs for the override `offline` has on this flag while set.
846 pub fn set_cache_enabled(&self, value: bool) {
847 self.cache_enabled.store(value, Ordering::Relaxed);
848 }
849
850 /// Returns `Err(DepsError::Offline)` when `network.offline` is set, without making any
851 /// request — the last check before a socket opens at each of this module's 4 send
852 /// sites, placed beside the existing [`ensure_https`] call at each.
853 fn ensure_online(&self, url: &str) -> Result<()> {
854 if self.is_offline() {
855 return Err(DepsError::Offline {
856 url: url.to_string(),
857 });
858 }
859 Ok(())
860 }
861
862 /// Returns the transport scoped to `trusted_origin`, building and pooling one on first use.
863 ///
864 /// The `get` fast path (a shared read lock) serves the common case — a `trusted_origin`
865 /// already pooled — without ever taking `trusted_clients`' write-capable `entry` lock;
866 /// `entry().or_insert_with()` only runs on a miss, so two callers racing on the same new
867 /// origin still only ever build and store one [`Transport`] for it, not one each.
868 fn transport_for_origin(&self, trusted_origin: &str) -> Transport {
869 let key = (trusted_origin.to_string(), CacheTier::Baseline);
870 if let Some(existing) = self.trusted_clients.get(&key) {
871 return existing.clone();
872 }
873
874 self.trusted_clients
875 .entry(key)
876 .or_insert_with(|| Transport::origin_pinned(trusted_origin))
877 .clone()
878 }
879
880 /// Like [`Self::transport_for_origin`], but for an origin-pinned, connect-address-guarded
881 /// [`CacheTier::Pinned`] transport (issue #561/#562) — building and pooling one on first
882 /// use, keyed by `(trusted_origin, CacheTier::Pinned { .. })` so an authenticated and
883 /// unauthenticated transport for the same origin are pooled separately.
884 fn transport_for_pinned(&self, trusted_origin: &str, authenticated: bool) -> Transport {
885 let digest = pinned_digest(trusted_origin, self.policy.get());
886 let key = (
887 trusted_origin.to_string(),
888 CacheTier::Pinned {
889 digest,
890 authenticated,
891 },
892 );
893 if let Some(existing) = self.trusted_clients.get(&key) {
894 return existing.clone();
895 }
896
897 self.trusted_clients
898 .entry(key)
899 .or_insert_with(|| {
900 Transport::origin_pinned_guarded(trusted_origin, &self.policy, authenticated)
901 })
902 .clone()
903 }
904
905 /// The prefix marking a workspace-tier cache key, chosen as a control character that can
906 /// never appear at the start of a URL string this module writes: production code paths
907 /// only ever write keys derived from this function, which are either the bare URL (starting
908 /// `https://`, or — test cfgs only — `http://` on loopback) or this prefix followed by a
909 /// policy digit. No in-process caller other than [`Self::insert_for_bench`] (a
910 /// `#[doc(hidden)]` test/bench helper that accepts a caller-chosen key) can write an
911 /// arbitrary key, so a `Baseline`-tier and `WorkspaceDeclared`-tier entry can never collide
912 /// in production use.
913 const WS_KEY_PREFIX: char = '\u{1}';
914
915 /// The prefix marking a [`CacheTier::Pinned`]-tier cache key (issue #561/#562) — distinct
916 /// from [`Self::WS_KEY_PREFIX`] so the two namespaces can never collide, chosen as another
917 /// control character no URL string this module writes can start with.
918 const PINNED_KEY_PREFIX: char = '\u{2}';
919
920 /// Computes the cache-map key for `url` under `tier` — `Cow::Borrowed(url)` for
921 /// [`CacheTier::Baseline`] (allocation-free, and identical to every entry this cache wrote
922 /// before this policy-tier split existed), or a policy-digit-prefixed owned key for
923 /// [`CacheTier::WorkspaceDeclared`] so a policy tightening can never serve a body fetched
924 /// under a looser policy (C5): the digit comes from `tier`'s own snapshot — the exact same
925 /// value the paired [`Transport`]'s [`AddrGuard`] enforced for this request, taken together
926 /// at [`Transport::workspace`] construction time — never a separate live `self.policy` read,
927 /// which would open a read-skew window between the guard that let a fetch through and the
928 /// key that fetch's body gets stored under. `self.policy` remains this cache's live handle
929 /// for [`Self::set_registry_policy`]'s change detection and the write-through source that
930 /// method snapshots from when rebuilding the workspace transport — never consulted here.
931 ///
932 /// Callers must compute this once per request and thread the result through, never
933 /// recompute mid-request — a policy flip between two recomputations would read and write
934 /// under different keys for what should be one atomic operation.
935 ///
936 /// `auth_id` (FR-014) is folded in only for [`CacheTier::Pinned`] — a separate credential
937 /// identity from `digest` (see that variant's docs), so a rotated or distinct credential
938 /// against the same origin never reads back a body fetched under a different one. `None`
939 /// serializes as 16 zero hex digits, matching an unauthenticated `#562` fetch.
940 fn cache_key<'a>(&self, url: &'a str, tier: CacheTier, auth_id: Option<u64>) -> Cow<'a, str> {
941 match tier {
942 CacheTier::Baseline => Cow::Borrowed(url),
943 CacheTier::WorkspaceDeclared(snapshot) => {
944 Cow::Owned(format!("{}{}{url}", Self::WS_KEY_PREFIX, snapshot.to_u8()))
945 }
946 CacheTier::Pinned { digest, .. } => Cow::Owned(format!(
947 "{}{digest:016x}{:016x}{url}",
948 Self::PINNED_KEY_PREFIX,
949 auth_id.unwrap_or(0)
950 )),
951 }
952 }
953
954 /// Retrieves data from URL with intelligent caching.
955 ///
956 /// On first request, fetches data from the network and caches it.
957 /// On subsequent requests, performs a conditional GET request using
958 /// cached ETag or Last-Modified headers. If the server responds with
959 /// 304 Not Modified, returns the cached data. Otherwise, fetches and
960 /// caches the new data.
961 ///
962 /// If the conditional request fails due to network errors, falls back
963 /// to the cached data (stale-while-revalidate pattern).
964 ///
965 /// # Returns
966 ///
967 /// Returns `Bytes` containing the response body. Multiple calls for the
968 /// same URL return cheap clones (reference counting) without copying data.
969 ///
970 /// # Errors
971 ///
972 /// Returns `DepsError::RegistryError` if the initial fetch fails and no
973 /// cached data exists, `DepsError::HttpStatus` if the server returns a
974 /// non-2xx status on that initial fetch, or `DepsError::ResponseTooLarge`
975 /// if the response body exceeds the configured size cap.
976 ///
977 /// # Examples
978 ///
979 /// ```no_run
980 /// # use deps_core::cache::HttpCache;
981 /// # async fn example() -> deps_core::error::Result<()> {
982 /// let cache = HttpCache::new();
983 /// let data = cache.get_cached("https://example.com/api/data").await?;
984 /// println!("Fetched {} bytes", data.len());
985 /// # Ok(())
986 /// # }
987 /// ```
988 pub async fn get_cached(&self, url: &str) -> Result<Bytes> {
989 self.get_cached_with_headers(url, &[]).await
990 }
991
992 /// Returns the cached body for `url` without making any network request.
993 ///
994 /// Unlike `get_cached`'s own stale-while-revalidate fallback (the `Err` arm of
995 /// `conditional_request_with_headers`'s match in `get_cached_with_headers_via`), this
996 /// is reachable even when a caller wraps `get_cached` in a short outer timeout: a
997 /// hung conditional request that never resolves within that timeout gets its whole
998 /// future cancelled, so `get_cached`'s internal fallback logic never runs and the
999 /// caller sees a timeout instead of stale data. A caller in that position can call
1000 /// this instead — a synchronous map lookup, no I/O — to serve the last known-good
1001 /// body itself. Returns `None` if `url` has never been successfully cached.
1002 ///
1003 /// The returned body carries no age bound: this bypasses `get_cached`'s own
1004 /// freshness/revalidation logic entirely, so a caller that surfaces this body to
1005 /// the user (e.g. inserting it into a manifest edit) should treat it as
1006 /// arbitrarily stale, not just-expired.
1007 ///
1008 /// Reads the baseline (unprefixed) cache-key namespace only (see `Self::cache_key`, private) — a
1009 /// body fetched via [`Self::get_cached_workspace`] is never visible through this method.
1010 #[must_use]
1011 pub fn peek_cached(&self, url: &str) -> Option<Bytes> {
1012 self.entries.get(url).map(|r| r.body.clone())
1013 }
1014
1015 /// Fetches a URL with additional request headers, using the cache.
1016 ///
1017 /// Works the same as `get_cached` but injects extra headers (e.g., Authorization)
1018 /// into every request. Useful for APIs that require authentication tokens.
1019 ///
1020 /// # Errors
1021 ///
1022 /// Returns `DepsError::RegistryError` if the initial fetch fails and no
1023 /// cached data exists, `DepsError::HttpStatus` if the server returns a
1024 /// non-2xx status on that initial fetch, or `DepsError::ResponseTooLarge`
1025 /// if the response body exceeds the configured size cap.
1026 pub async fn get_cached_with_headers(
1027 &self,
1028 url: &str,
1029 extra_headers: &[(header::HeaderName, &str)],
1030 ) -> Result<Bytes> {
1031 self.get_cached_with_headers_via(url, extra_headers, &self.baseline, None)
1032 .await
1033 }
1034
1035 /// Like [`Self::get_cached`], but additionally stops any redirect hop whose target no
1036 /// longer starts with `trusted_origin` (e.g. `https://api.nuget.org/v3/registration5-gz/`).
1037 ///
1038 /// For a caller that already validated the *initial* request URL against a trusted
1039 /// prefix (NuGet's registration-hive paging validates `page.id` this way) and needs
1040 /// that guarantee to hold through any redirect too, not just the first hop —
1041 /// [`Self::get_cached`]'s own policy deliberately does not enforce this, since
1042 /// cross-host redirects are legitimate for the other registry clients sharing this
1043 /// cache; the stricter check is opt-in per call rather than global.
1044 ///
1045 /// The block only surfaces as an error on a cold cache: like [`Self::get_cached`]'s own
1046 /// stale-while-revalidate fallback, a warm entry for `url` still returns the last
1047 /// known-good body (itself already fetched and origin-validated on a prior call)
1048 /// instead of propagating a blocked-redirect `HttpStatus` from a revalidation attempt.
1049 ///
1050 /// # Errors
1051 ///
1052 /// Same as [`Self::get_cached`].
1053 pub async fn get_cached_trusted_origin(
1054 &self,
1055 url: &str,
1056 trusted_origin: &str,
1057 ) -> Result<Bytes> {
1058 let transport = self.transport_for_origin(trusted_origin);
1059 self.get_cached_with_headers_via(url, &[], &transport, None)
1060 .await
1061 }
1062
1063 /// Like [`Self::get_cached_trusted_origin`], but additionally injects `extra_headers`
1064 /// (e.g. an `Authorization` bearer token) into every request — the authenticated
1065 /// counterpart to [`Self::get_cached_with_headers`], composed with the same
1066 /// origin-pinned redirect policy [`Self::get_cached_trusted_origin`] uses.
1067 ///
1068 /// This exists specifically so a header carrying a credential can never survive a
1069 /// cross-origin redirect hop: [`Self::get_cached_with_headers`] attaches
1070 /// `extra_headers` to the *initial* request only and follows reqwest's default
1071 /// (same-scheme, any-host) redirect policy for every hop after that, which is
1072 /// exactly the shape a hostile or misconfigured redirect on the resolved index
1073 /// itself could exploit to exfiltrate a bearer token to an attacker-controlled
1074 /// host. Composing `Self::transport_for_origin`'s (private) pinned-origin transport with header
1075 /// injection closes that by construction — no empirical redirect test is needed
1076 /// to prove the header cannot leak, since the client stops following before a
1077 /// cross-origin hop would ever be sent.
1078 ///
1079 /// # Errors
1080 ///
1081 /// Same as [`Self::get_cached_trusted_origin`].
1082 ///
1083 /// # Examples
1084 ///
1085 /// ```no_run
1086 /// use deps_core::cache::HttpCache;
1087 /// use reqwest::header;
1088 ///
1089 /// # async fn example() -> deps_core::error::Result<()> {
1090 /// let cache = HttpCache::new();
1091 /// let data = cache
1092 /// .get_cached_trusted_origin_with_headers(
1093 /// "https://index.mycorp.dev/se/rd/serde",
1094 /// "https://index.mycorp.dev/",
1095 /// &[(header::AUTHORIZATION, "Bearer secret-token")],
1096 /// )
1097 /// .await?;
1098 /// println!("Fetched {} bytes", data.len());
1099 /// # Ok(())
1100 /// # }
1101 /// ```
1102 pub async fn get_cached_trusted_origin_with_headers(
1103 &self,
1104 url: &str,
1105 trusted_origin: &str,
1106 extra_headers: &[(header::HeaderName, &str)],
1107 ) -> Result<Bytes> {
1108 let transport = self.transport_for_origin(trusted_origin);
1109 self.get_cached_with_headers_via(url, extra_headers, &transport, None)
1110 .await
1111 }
1112
1113 /// Like [`Self::get_cached_trusted_origin_with_headers`], but for an origin-pinned,
1114 /// connect-address-guarded `CacheTier::Pinned` transport (issue #561/#562) instead of the
1115 /// baseline-guarded [`Self::get_cached_trusted_origin`] one — the only sanctioned way to
1116 /// send a credential to a workspace-declared host. Delegates to
1117 /// [`Self::get_cached_pinned_with_headers`] with no extra headers.
1118 ///
1119 /// # Errors
1120 ///
1121 /// Same as [`Self::get_cached`].
1122 pub async fn get_cached_pinned(
1123 &self,
1124 url: &str,
1125 trusted_origin: &str,
1126 authenticated: bool,
1127 auth_id: Option<u64>,
1128 ) -> Result<Bytes> {
1129 self.get_cached_pinned_with_headers(url, trusted_origin, authenticated, auth_id, &[])
1130 .await
1131 }
1132
1133 /// Like [`Self::get_cached_pinned`], but additionally injects `extra_headers` (e.g. an
1134 /// `Authorization` header carrying a credential) into every request — composed with the
1135 /// same origin-pinned, connect-address-guarded transport [`Self::get_cached_pinned`] uses,
1136 /// so a credential header can never survive a cross-origin redirect hop, exactly like
1137 /// [`Self::get_cached_trusted_origin_with_headers`]'s identical closure argument for the
1138 /// baseline-guarded tier.
1139 ///
1140 /// `auth_id` (FR-014) — a caller-computed, salted digest of the credential actually being
1141 /// attached (`None` for an unauthenticated #562 fetch) — is folded into the cache key only,
1142 /// never into `CacheTier`/the transport-pool key, so a rotated or distinct credential
1143 /// against the same origin never reads back a body fetched under a different one.
1144 ///
1145 /// # Errors
1146 ///
1147 /// Same as [`Self::get_cached`].
1148 pub async fn get_cached_pinned_with_headers(
1149 &self,
1150 url: &str,
1151 trusted_origin: &str,
1152 authenticated: bool,
1153 auth_id: Option<u64>,
1154 extra_headers: &[(header::HeaderName, &str)],
1155 ) -> Result<Bytes> {
1156 let transport = self.transport_for_pinned(trusted_origin, authenticated);
1157 self.get_cached_with_headers_via(url, extra_headers, &transport, auth_id)
1158 .await
1159 }
1160
1161 /// Like [`Self::get_cached`], but for Cargo's workspace-declared-registry requests: routes
1162 /// through the workspace transport field, whose guard enforces the live
1163 /// [`crate::net_policy::WorkspaceRegistryAccess`] policy on both the resolved connect-time
1164 /// address (issue #455) and any redirect hop, and keys the entry under a
1165 /// policy-scoped namespace (see `Self::cache_key`, private) distinct from every other method on
1166 /// this cache.
1167 ///
1168 /// This gives the *resolved address* the same policy scrutiny `deps_cargo::config::RegistryIndex::new`
1169 /// already gives the declared URL string at parse time — it does **not**
1170 /// re-check the initial request URL itself: a caller passing an IP-literal `url` whose
1171 /// class the policy would reject connects anyway, since `hyper-util`'s connector parses an
1172 /// IP literal directly and never calls the configured resolver (see
1173 /// `BlockedAddrResolver`'s docs, private). `RegistryIndex::new` is the sole, by-design gate for
1174 /// that residual — every caller of this method already went through it.
1175 ///
1176 /// If an entry is already cached, a revalidation failure — including a guard rejection
1177 /// from a since-rebound or since-tightened-policy address — falls back to serving the
1178 /// cached body, logging only a `tracing::warn!` (pre-existing behavior, unrelated to
1179 /// this method). This is not a bypass — no new connection to the blocked address is
1180 /// made — but it means such a block is invisible to the caller whenever an entry already
1181 /// exists for that URL.
1182 ///
1183 /// # Errors
1184 ///
1185 /// Same as [`Self::get_cached`].
1186 ///
1187 /// # Examples
1188 ///
1189 /// ```no_run
1190 /// use deps_core::HttpCache;
1191 /// use deps_core::net_policy::RegistryAccessPolicy;
1192 /// use std::sync::Arc;
1193 ///
1194 /// # async fn example() -> deps_core::error::Result<()> {
1195 /// let policy = Arc::new(RegistryAccessPolicy::default());
1196 /// let cache = HttpCache::with_policy(policy);
1197 /// let data = cache
1198 /// .get_cached_workspace("https://index.mycorp.dev/se/rd/serde")
1199 /// .await?;
1200 /// println!("Fetched {} bytes", data.len());
1201 /// # Ok(())
1202 /// # }
1203 /// ```
1204 pub async fn get_cached_workspace(&self, url: &str) -> Result<Bytes> {
1205 self.get_cached_workspace_with_headers(url, &[]).await
1206 }
1207
1208 /// Like [`Self::get_cached_workspace`], but additionally forwards `extra_headers` to the
1209 /// underlying request — the headered form needed by a registry client whose
1210 /// workspace-declared fetch requires a non-default header (e.g. `deps-npm`'s abbreviated-
1211 /// packument `Accept` header for an alternate npm registry).
1212 ///
1213 /// # Security
1214 ///
1215 /// `extra_headers` are attached to the **initial** request only. The workspace transport
1216 /// pins by [`crate::net_policy::HostClass`], not origin — unlike
1217 /// [`Self::get_cached_trusted_origin_with_headers`], which exists precisely to close this
1218 /// gap for a caller that needs it — so a cross-origin redirect hop to any other
1219 /// policy-permitted host is followed with `extra_headers` re-sent by reqwest's default
1220 /// redirect policy. **This method must never carry a credential.** Harmless for its
1221 /// current sole caller (a fixed `Accept` header), but directly load-bearing for any
1222 /// future auth-wiring work: reach for [`Self::get_cached_trusted_origin_with_headers`]
1223 /// instead if a header ever needs to stay pinned to one origin.
1224 pub async fn get_cached_workspace_with_headers(
1225 &self,
1226 url: &str,
1227 extra_headers: &[(header::HeaderName, &str)],
1228 ) -> Result<Bytes> {
1229 let transport = self
1230 .workspace
1231 .read()
1232 .expect("workspace transport lock poisoned")
1233 .clone();
1234 self.get_cached_with_headers_via(url, extra_headers, &transport, None)
1235 .await
1236 }
1237
1238 /// Updates the policy governing [`Self::get_cached_workspace`], rebuilding the workspace
1239 /// transport field (and so its cache-key namespace and guard together) when
1240 /// `value` actually differs from the current setting — a no-op call does not rebuild, so a
1241 /// caller that re-applies an unchanged configuration does not pay for a fresh `Client` and
1242 /// its connection pool.
1243 ///
1244 /// Effective for every [`Self::get_cached_workspace`] call after this returns. Note this
1245 /// only gates *future* fetches: an `All -> PublicOnly`/`Off` tightening does not purge
1246 /// already-registered `deps-cargo` alternate-registry clients resolved under the looser
1247 /// policy (pre-existing, documented on [`crate::net_policy::RegistryAccessPolicy::set`]).
1248 ///
1249 /// Unlike that pre-existing gap, every `CacheTier::Pinned` cache entry (issue #561/#562)
1250 /// **is** purged on every actual policy transition, along with every pinned-tier pooled
1251 /// `Transport` — substantially narrowing the `All -> PublicOnly -> All` round-trip hole for
1252 /// credential-carrying entries specifically (NFR-004): re-namespacing alone (as the
1253 /// pre-existing workspace-tier digit prefix does) would leave an old-era authenticated
1254 /// body reachable once the policy round-trips back to a value whose digest happens to
1255 /// collide again. Not an absolute close: a fetch already in flight when the transition
1256 /// happens can still land its response and re-insert an old-era key after the purge —
1257 /// harmless (readable only under the era it was legitimately fetched in), just not
1258 /// prevented by this purge alone.
1259 pub fn set_registry_policy(&self, value: WorkspaceRegistryAccess) {
1260 if self.policy.get() == value {
1261 return;
1262 }
1263 self.policy.set(value);
1264 let rebuilt = Transport::workspace(&self.policy);
1265 *self
1266 .workspace
1267 .write()
1268 .expect("workspace transport lock poisoned") = rebuilt;
1269 #[cfg(test)]
1270 self.workspace_rebuilds.fetch_add(1, Ordering::Relaxed);
1271
1272 let mut freed_bytes = 0usize;
1273 self.entries.retain(|k, v| {
1274 let keep = !k.starts_with(Self::PINNED_KEY_PREFIX);
1275 if !keep {
1276 freed_bytes += v.body.len();
1277 }
1278 keep
1279 });
1280 self.total_bytes.fetch_sub(freed_bytes, Ordering::Relaxed);
1281 self.trusted_clients
1282 .retain(|(_, tier), _| !matches!(tier, CacheTier::Pinned { .. }));
1283 }
1284
1285 /// `auth_id` (FR-014) is meaningful only under [`CacheTier::Pinned`] — every other tier
1286 /// ignores it (see [`Self::cache_key`]'s docs).
1287 async fn get_cached_with_headers_via(
1288 &self,
1289 url: &str,
1290 extra_headers: &[(header::HeaderName, &str)],
1291 transport: &Transport,
1292 auth_id: Option<u64>,
1293 ) -> Result<Bytes> {
1294 if self.entries.len() >= MAX_CACHE_ENTRIES
1295 || self.total_bytes.load(Ordering::Relaxed) >= MAX_CACHE_BYTES
1296 {
1297 self.evict_entries();
1298 }
1299
1300 let offline = self.is_offline();
1301 // `offline` forces `cache_enabled` true on both the read and write path below (S1
1302 // fix): `cache.enabled: false` otherwise means "never store", which would leave the
1303 // offline early-return below with nothing to serve for a URL that was only ever
1304 // fetched while caching was disabled. See `Self::set_offline`'s docs.
1305 let cache_enabled = self.cache_enabled.load(Ordering::Relaxed) || offline;
1306
1307 // Computed once and threaded through every downstream call — never recomputed, or a
1308 // policy flip mid-request would read and write under different keys (see
1309 // `Self::cache_key`'s docs).
1310 let cache_key = self.cache_key(url, transport.tier, auth_id);
1311
1312 if !cache_enabled {
1313 return self
1314 .transport_only_via(url, extra_headers, BodyLimit::DEFAULT, &transport.client)
1315 .await;
1316 }
1317
1318 // Clone and drop the DashMap Ref immediately to release the shard lock.
1319 // Holding a Ref across .await causes deadlocks when concurrent tasks
1320 // need write access to the same shard (e.g., conditional_request_with_headers → insert).
1321 if let Some(cached) = self.entries.get(cache_key.as_ref()).map(|r| r.clone()) {
1322 if offline {
1323 // Skip the conditional-request attempt entirely: `ensure_online` inside
1324 // `conditional_request_with_headers` would block it anyway and fall back to
1325 // this same cached body via the `Err` arm below, but only after a spurious
1326 // `tracing::warn!` and a wasted request-builder allocation on every offline
1327 // hover. Behavior is identical either way — this is purely to avoid that.
1328 return Ok(cached.body);
1329 }
1330 match self
1331 .conditional_request_with_headers(
1332 url,
1333 &cached,
1334 extra_headers,
1335 &transport.client,
1336 &cache_key,
1337 )
1338 .await
1339 {
1340 Ok(Some(new_body)) => return Ok(new_body),
1341 Ok(None) => return Ok(cached.body),
1342 Err(e) => {
1343 // FR-015/NFR-004: a 401/403 revalidation against an *authenticated*
1344 // pinned-tier entry must evict rather than serve the possibly-revoked
1345 // credential's last-known-good body — every other tier keeps today's
1346 // stale-while-revalidate fallback unchanged.
1347 if transport.tier.is_authenticated()
1348 && matches!(
1349 &e,
1350 DepsError::HttpStatus {
1351 status: 401 | 403,
1352 ..
1353 }
1354 )
1355 {
1356 if let Some((_, old)) = self.entries.remove(cache_key.as_ref()) {
1357 self.total_bytes
1358 .fetch_sub(old.body.len(), Ordering::Relaxed);
1359 }
1360 tracing::warn!(
1361 %e,
1362 "evicting authenticated cache entry after revalidation failure"
1363 );
1364 return Err(e);
1365 }
1366 tracing::warn!("conditional request failed, using cache: {e}");
1367 return Ok(cached.body);
1368 }
1369 }
1370 }
1371
1372 self.fetch_and_store_with_headers(url, extra_headers, &transport.client, &cache_key)
1373 .await
1374 }
1375
1376 /// Performs conditional HTTP request using cached validation headers.
1377 ///
1378 /// Sends `If-None-Match` (ETag) and/or `If-Modified-Since` headers
1379 /// to check if the cached content is still valid.
1380 ///
1381 /// # Returns
1382 ///
1383 /// - `Ok(Some(Bytes))` - Server returned 200 OK with new content
1384 /// - `Ok(None)` - Server returned 304 Not Modified (cache is valid)
1385 /// - `Err(_)` - Network or HTTP error occurred
1386 async fn conditional_request_with_headers(
1387 &self,
1388 url: &str,
1389 cached: &CachedResponse,
1390 extra_headers: &[(header::HeaderName, &str)],
1391 client: &Client,
1392 cache_key: &str,
1393 ) -> Result<Option<Bytes>> {
1394 self.ensure_online(url)?;
1395 ensure_https(url)?;
1396 let mut request = client.get(url);
1397
1398 for (name, value) in extra_headers {
1399 request = request.header(name, *value);
1400 }
1401 if let Some(etag) = &cached.etag {
1402 request = request.header(header::IF_NONE_MATCH, etag);
1403 }
1404 if let Some(last_modified) = &cached.last_modified {
1405 request = request.header(header::IF_MODIFIED_SINCE, last_modified);
1406 }
1407
1408 let response = request.send().await.map_err(|e| DepsError::RegistryError {
1409 package: url.to_string(),
1410 source: e,
1411 })?;
1412
1413 if response.status() == StatusCode::NOT_MODIFIED {
1414 return Ok(None);
1415 }
1416
1417 if !response.status().is_success() {
1418 return Err(DepsError::HttpStatus {
1419 url: url.to_string(),
1420 status: response.status().as_u16(),
1421 });
1422 }
1423
1424 let etag = response
1425 .headers()
1426 .get(header::ETAG)
1427 .and_then(|v| v.to_str().ok())
1428 .map(String::from);
1429 let last_modified = response
1430 .headers()
1431 .get(header::LAST_MODIFIED)
1432 .and_then(|v| v.to_str().ok())
1433 .map(String::from);
1434 let body = read_body_capped(url, response, BodyLimit::DEFAULT).await?;
1435
1436 self.store_entry(
1437 cache_key.to_string(),
1438 CachedResponse {
1439 body: body.clone(),
1440 etag,
1441 last_modified,
1442 fetched_at: Instant::now(),
1443 },
1444 );
1445
1446 Ok(Some(body))
1447 }
1448
1449 /// Fetches a fresh response from the network and stores it in the cache.
1450 ///
1451 /// This method bypasses the cache and always makes a network request.
1452 /// The response is stored with its ETag and Last-Modified headers for
1453 /// future conditional requests.
1454 ///
1455 /// # Errors
1456 ///
1457 /// Returns `DepsError::HttpStatus` if the server returns a non-2xx status code,
1458 /// `DepsError::RegistryError` if the network request fails, or
1459 /// `DepsError::ResponseTooLarge` if the response body exceeds the
1460 /// configured size cap.
1461 async fn fetch_and_store_with_headers(
1462 &self,
1463 url: &str,
1464 extra_headers: &[(header::HeaderName, &str)],
1465 client: &Client,
1466 cache_key: &str,
1467 ) -> Result<Bytes> {
1468 self.ensure_online(url)?;
1469 ensure_https(url)?;
1470 tracing::debug!(extra_headers = extra_headers.len(), "fetching fresh: {url}");
1471
1472 let mut request = client.get(url);
1473 for (name, value) in extra_headers {
1474 request = request.header(name, *value);
1475 }
1476
1477 let response = request.send().await.map_err(|e| DepsError::RegistryError {
1478 package: url.to_string(),
1479 source: e,
1480 })?;
1481
1482 if !response.status().is_success() {
1483 return Err(DepsError::HttpStatus {
1484 url: url.to_string(),
1485 status: response.status().as_u16(),
1486 });
1487 }
1488
1489 let etag = response
1490 .headers()
1491 .get(header::ETAG)
1492 .and_then(|v| v.to_str().ok())
1493 .map(String::from);
1494 let last_modified = response
1495 .headers()
1496 .get(header::LAST_MODIFIED)
1497 .and_then(|v| v.to_str().ok())
1498 .map(String::from);
1499 let body = read_body_capped(url, response, BodyLimit::DEFAULT).await?;
1500
1501 self.store_entry(
1502 cache_key.to_string(),
1503 CachedResponse {
1504 body: body.clone(),
1505 etag,
1506 last_modified,
1507 fetched_at: Instant::now(),
1508 },
1509 );
1510
1511 Ok(body)
1512 }
1513
1514 /// POSTs `body` as JSON and returns the response body.
1515 ///
1516 /// Deliberately does not cache: the OSV batch endpoint is a POST with a
1517 /// request-body-dependent response and sends no `ETag`/`Last-Modified`
1518 /// validators, so entry-map caching would be meaningless here — every
1519 /// call reuses the client, HTTPS enforcement, size cap, and timeout
1520 /// (via `read_body_capped`) without touching the entry map or
1521 /// [`Self::total_bytes`].
1522 ///
1523 /// # Errors
1524 ///
1525 /// Returns `DepsError::HttpStatus` if the server returns a non-2xx
1526 /// status, `DepsError::RegistryError` if the request fails, or
1527 /// `DepsError::ResponseTooLarge` if the response body exceeds the
1528 /// configured size cap.
1529 pub async fn post_json<T: Serialize + ?Sized>(&self, url: &str, body: &T) -> Result<Bytes> {
1530 self.ensure_online(url)?;
1531 ensure_https(url)?;
1532
1533 let response = self
1534 .baseline
1535 .client
1536 .post(url)
1537 .json(body)
1538 .send()
1539 .await
1540 .map_err(|e| DepsError::RegistryError {
1541 package: url.to_string(),
1542 source: e,
1543 })?;
1544
1545 if !response.status().is_success() {
1546 return Err(DepsError::HttpStatus {
1547 url: url.to_string(),
1548 status: response.status().as_u16(),
1549 });
1550 }
1551
1552 read_body_capped(url, response, BodyLimit::DEFAULT).await
1553 }
1554
1555 /// GETs `url` and returns the response body, bypassing the entry-map
1556 /// cache entirely — reuses the client, HTTPS enforcement, size cap, and
1557 /// timeout, exactly like [`Self::post_json`], but for a plain GET.
1558 ///
1559 /// For a caller whose own values are already cached elsewhere (e.g.
1560 /// `OsvClient`'s record cache, validated by a `modified` timestamp
1561 /// rather than `ETag`/`Last-Modified`): reusing [`Self::get_cached`]
1562 /// there would double-cache every fetched record in *this* cache's byte
1563 /// budget too, competing with registry responses for it even though
1564 /// nothing here ever reads that cached copy back.
1565 ///
1566 /// # Errors
1567 ///
1568 /// Returns `DepsError::HttpStatus` if the server returns a non-2xx
1569 /// status, `DepsError::RegistryError` if the request fails, or
1570 /// `DepsError::ResponseTooLarge` if the response body exceeds the
1571 /// configured size cap.
1572 pub async fn get_transport_only(&self, url: &str) -> Result<Bytes> {
1573 self.get_transport_only_with_headers(url, &[]).await
1574 }
1575
1576 /// Same as [`Self::get_transport_only`], but injects extra request headers (e.g. a
1577 /// content-negotiating `Accept`) — mirrors how [`Self::get_cached_with_headers`] relates
1578 /// to [`Self::get_cached`].
1579 ///
1580 /// # Errors
1581 ///
1582 /// Returns `DepsError::HttpStatus` if the server returns a non-2xx
1583 /// status, `DepsError::RegistryError` if the request fails, or
1584 /// `DepsError::ResponseTooLarge` if the response body exceeds the
1585 /// configured size cap.
1586 pub async fn get_transport_only_with_headers(
1587 &self,
1588 url: &str,
1589 extra_headers: &[(header::HeaderName, &str)],
1590 ) -> Result<Bytes> {
1591 self.get_transport_only_with_headers_limited(url, extra_headers, BodyLimit::DEFAULT)
1592 .await
1593 }
1594
1595 /// Same as [`Self::get_transport_only_with_headers`], but takes an explicit
1596 /// [`BodyLimit`] instead of the [`BodyLimit::DEFAULT`] (`MAX_RESPONSE_BYTES`) cap.
1597 ///
1598 /// For a caller whose response is legitimately larger than every other registry
1599 /// payload — e.g. `deps-pypi`'s full Simple API project index — without weakening
1600 /// the cap every other caller of this cache relies on. `BodyLimit` clamps at
1601 /// construction, so this can never be widened past `ABSOLUTE_MAX_RESPONSE_BYTES`
1602 /// regardless of what the caller passes in.
1603 ///
1604 /// # Errors
1605 ///
1606 /// Same as [`Self::get_transport_only_with_headers`].
1607 pub async fn get_transport_only_with_headers_limited(
1608 &self,
1609 url: &str,
1610 extra_headers: &[(header::HeaderName, &str)],
1611 limit: BodyLimit,
1612 ) -> Result<Bytes> {
1613 self.transport_only_via(url, extra_headers, limit, &self.baseline.client)
1614 .await
1615 }
1616
1617 /// Same as [`Self::get_transport_only_with_headers_limited`], but additionally
1618 /// stops any redirect hop whose target no longer starts with `trusted_origin`
1619 /// (see [`Self::get_cached_trusted_origin`], which applies the identical policy
1620 /// to the entry-cached path). For a caller carrying a materially larger
1621 /// [`BodyLimit`] than [`BodyLimit::DEFAULT`] — the bigger the budget, the more
1622 /// worth pinning the origin an arbitrary cross-host redirect could point it at.
1623 ///
1624 /// # Errors
1625 ///
1626 /// Same as [`Self::get_transport_only_with_headers_limited`].
1627 pub async fn get_transport_only_with_headers_limited_trusted_origin(
1628 &self,
1629 url: &str,
1630 extra_headers: &[(header::HeaderName, &str)],
1631 limit: BodyLimit,
1632 trusted_origin: &str,
1633 ) -> Result<Bytes> {
1634 let transport = self.transport_for_origin(trusted_origin);
1635 self.transport_only_via(url, extra_headers, limit, &transport.client)
1636 .await
1637 }
1638
1639 /// Note: "transport" here means "bypasses the entry-map cache" (see this method's
1640 /// callers' docs) — a different axis from the [`Transport`] type, which pairs a `Client`
1641 /// with its [`CacheTier`]. The name predates that type and is kept as-is to avoid
1642 /// churning every `get_transport_only*` call site for a naming collision that causes no
1643 /// actual ambiguity at the call sites themselves.
1644 async fn transport_only_via(
1645 &self,
1646 url: &str,
1647 extra_headers: &[(header::HeaderName, &str)],
1648 limit: BodyLimit,
1649 client: &Client,
1650 ) -> Result<Bytes> {
1651 self.ensure_online(url)?;
1652 ensure_https(url)?;
1653
1654 let mut request = client.get(url);
1655 for (name, value) in extra_headers {
1656 request = request.header(name, *value);
1657 }
1658
1659 let response = request.send().await.map_err(|e| DepsError::RegistryError {
1660 package: url.to_string(),
1661 source: e,
1662 })?;
1663
1664 if !response.status().is_success() {
1665 return Err(DepsError::HttpStatus {
1666 url: url.to_string(),
1667 status: response.status().as_u16(),
1668 });
1669 }
1670
1671 read_body_capped(url, response, limit).await
1672 }
1673
1674 /// Inserts (or replaces) a cache entry, keeping [`Self::total_bytes`] in sync.
1675 ///
1676 /// `DashMap::insert` returns the replaced value, if any, so the byte
1677 /// delta is computed from a single insert rather than a separate
1678 /// lookup-then-insert (which would race with concurrent writers).
1679 ///
1680 /// A body larger than [`MAX_CACHEABLE_ENTRY_BYTES`] is not inserted at
1681 /// all (the caller already has it from the network response; only
1682 /// caching is skipped), and any stale entry previously cached for this
1683 /// URL is dropped rather than left to serve increasingly outdated data.
1684 fn store_entry(&self, url: String, response: CachedResponse) {
1685 let new_len = response.body.len();
1686
1687 if new_len > MAX_CACHEABLE_ENTRY_BYTES {
1688 if let Some((_, old)) = self.entries.remove(&url) {
1689 self.total_bytes
1690 .fetch_sub(old.body.len(), Ordering::Relaxed);
1691 }
1692 return;
1693 }
1694
1695 let old_len = self
1696 .entries
1697 .insert(url, response)
1698 .map_or(0, |old| old.body.len());
1699 self.total_bytes.fetch_add(new_len, Ordering::Relaxed);
1700 self.total_bytes.fetch_sub(old_len, Ordering::Relaxed);
1701 }
1702
1703 /// Clears all cached entries.
1704 ///
1705 /// This removes all cached responses, forcing the next request for
1706 /// any URL to fetch fresh data from the network.
1707 pub fn clear(&self) {
1708 self.entries.clear();
1709 self.total_bytes.store(0, Ordering::Relaxed);
1710 }
1711
1712 /// Returns the number of cached entries.
1713 pub fn len(&self) -> usize {
1714 self.entries.len()
1715 }
1716
1717 /// Returns `true` if the cache contains no entries.
1718 pub fn is_empty(&self) -> bool {
1719 self.entries.is_empty()
1720 }
1721
1722 /// Returns the total bytes retained across all cached response bodies.
1723 pub fn total_bytes(&self) -> usize {
1724 self.total_bytes.load(Ordering::Relaxed)
1725 }
1726
1727 /// Evicts the oldest cache entries when either capacity limit is reached.
1728 ///
1729 /// When the entry count is at or over `MAX_CACHE_ENTRIES`, evicts at
1730 /// least `CACHE_EVICTION_PERCENTAGE`% of entries (by count). Note this
1731 /// is a *fix*, not a preserved behavior: the original count-only
1732 /// eviction built its bounded min-heap with an inverted comparison
1733 /// (`peek()` returns the oldest entry, but the old code treated it as
1734 /// the newest-of-the-oldest-so-far and only replaced it when a *newer*
1735 /// candidate came along that was still older than it — backwards), so
1736 /// it evicted roughly the first `target_removals` entries in DashMap
1737 /// hash-iteration order, not the oldest ones. This version evicts
1738 /// genuinely oldest-first.
1739 ///
1740 /// Independently, if the tracked byte total is over [`MAX_CACHE_BYTES`]
1741 /// — which can happen with far fewer than `MAX_CACHE_ENTRIES` entries if
1742 /// a few responses are large — eviction keeps removing the next-oldest
1743 /// entries until the byte budget is satisfied too. A cache that is over
1744 /// the byte budget but well under the entry-count threshold only evicts
1745 /// as many entries as the byte budget requires, not a fixed count-based
1746 /// batch.
1747 ///
1748 /// Builds a min-heap over all entry keys by `fetched_at` (O(N)), then
1749 /// pops the oldest one at a time (O(R log N) for R removals) — unlike a
1750 /// heap bounded to a fixed top-K, the removal count isn't known upfront
1751 /// since it depends on the byte budget as well as the count target.
1752 ///
1753 /// Every byte-count adjustment here is a relative `fetch_sub` applied to
1754 /// exactly the entry [`DashMap::remove`] actually returned — never a
1755 /// snapshot-then-absolute-`store` of a locally computed total. The
1756 /// latter would silently discard any [`Self::store_entry`] delta that
1757 /// lands between this method's start and its end (lost-update race), and
1758 /// under adversarial timing could even underflow `total_bytes` to
1759 /// `usize::MAX`, permanently wedging every future request into
1760 /// evicting the entire cache. Reading `total_bytes` fresh on every loop
1761 /// iteration (rather than maintaining a local mirror) keeps this
1762 /// correct under concurrent `evict_entries`/`store_entry` calls: two
1763 /// callers can race to remove the same key — the second `remove` simply
1764 /// returns `None` and is a no-op, not a double-subtraction.
1765 fn evict_entries(&self) {
1766 use std::cmp::Reverse;
1767 use std::collections::BinaryHeap;
1768
1769 let count_target_removals = if self.entries.len() >= MAX_CACHE_ENTRIES {
1770 (MAX_CACHE_ENTRIES / CACHE_EVICTION_PERCENTAGE).max(1)
1771 } else {
1772 0
1773 };
1774
1775 let mut oldest: BinaryHeap<Reverse<(Instant, String)>> = self
1776 .entries
1777 .iter()
1778 .map(|entry| Reverse((entry.value().fetched_at, entry.key().clone())))
1779 .collect();
1780
1781 let mut removed = 0usize;
1782
1783 while removed < count_target_removals
1784 || self.total_bytes.load(Ordering::Relaxed) > MAX_CACHE_BYTES
1785 {
1786 let Some(Reverse((_, url))) = oldest.pop() else {
1787 break;
1788 };
1789 if let Some((_, old)) = self.entries.remove(&url) {
1790 self.total_bytes
1791 .fetch_sub(old.body.len(), Ordering::Relaxed);
1792 }
1793 removed += 1;
1794 }
1795
1796 tracing::debug!(
1797 "evicted {removed} cache entries ({} bytes remaining)",
1798 self.total_bytes.load(Ordering::Relaxed)
1799 );
1800 }
1801
1802 /// Benchmark-only helper: Direct cache lookup without network requests.
1803 #[doc(hidden)]
1804 pub fn get_for_bench(&self, url: &str) -> Option<Bytes> {
1805 self.entries.get(url).map(|entry| entry.body.clone())
1806 }
1807
1808 /// Benchmark-only helper: Direct cache insertion.
1809 #[doc(hidden)]
1810 pub fn insert_for_bench(&self, url: String, response: CachedResponse) {
1811 self.store_entry(url, response);
1812 }
1813}
1814
1815impl Default for HttpCache {
1816 fn default() -> Self {
1817 Self::new()
1818 }
1819}
1820
1821#[cfg(test)]
1822mod tests {
1823 use super::*;
1824
1825 use std::assert_matches;
1826
1827 // Guards the non-loopback path of `ensure_https`: every other test in this
1828 // module reaches it only through loopback `mockito` URLs, so without this
1829 // test the "reject any other HTTP host" branch would never run.
1830 #[test]
1831 fn test_ensure_https_rejects_non_loopback_http() {
1832 assert!(ensure_https("http://example.com").is_err());
1833 }
1834
1835 // `http://example.com` alone would still pass under a regressed, substring-based
1836 // `is_loopback_host` (e.g. `url.contains("localhost")`) — these hosts embed a
1837 // loopback token without actually being loopback, and must still be rejected.
1838 #[test]
1839 fn test_ensure_https_rejects_hosts_resembling_loopback() {
1840 assert!(ensure_https("http://localhost.evil.com/").is_err());
1841 assert!(ensure_https("http://127.0.0.1.evil.com/").is_err());
1842 assert!(ensure_https("http://evil.com/?cb=127.0.0.1").is_err());
1843 }
1844
1845 // The sole exerciser of `is_loopback_host`'s bracketed-IPv6 branch
1846 // (`strip_prefix('[')`/`split(']')`) — every other test/mockito URL in this
1847 // module uses `127.0.0.1`.
1848 #[test]
1849 fn test_ensure_https_accepts_bracketed_ipv6_loopback() {
1850 assert!(ensure_https("http://[::1]:1234/x").is_ok());
1851 }
1852
1853 // reqwest's `Attempt` has no public constructor, so the redirect policy closure
1854 // itself can't be unit-tested directly from outside the reqwest crate; this
1855 // exercises the pure detection logic it delegates to instead. End-to-end coverage
1856 // of an actual https->http redirect is not feasible with mockito, which is
1857 // http-only (see test_get_cached_follows_same_scheme_redirect for the
1858 // policy-is-wired-in regression check that mockito *can* exercise).
1859 #[test]
1860 fn test_is_https_downgrade() {
1861 let https = Url::parse("https://example.com/a").unwrap();
1862 let http = Url::parse("http://example.com/a").unwrap();
1863
1864 assert!(is_https_downgrade(&https, &http));
1865 assert!(!is_https_downgrade(&http, &https));
1866 assert!(!is_https_downgrade(&https, &https));
1867 assert!(!is_https_downgrade(&http, &http));
1868 }
1869
1870 #[test]
1871 fn test_hop_targets_blocked_host_blocks_cloud_metadata() {
1872 let url = Url::parse("https://169.254.169.254/latest/meta-data/").unwrap();
1873 assert!(hop_targets_blocked_host(&url));
1874 }
1875
1876 #[test]
1877 fn test_hop_targets_blocked_host_allows_global() {
1878 let url = Url::parse("https://index.crates.io/").unwrap();
1879 assert!(!hop_targets_blocked_host(&url));
1880 }
1881
1882 // The loopback carve-out (identical to `ensure_https`'s) must still exempt
1883 // loopback hops under `cfg(test)`, or every mockito redirect chain in this
1884 // module's own tests would start failing.
1885 #[test]
1886 fn test_hop_targets_blocked_host_exempts_loopback_under_test_cfg() {
1887 let url = Url::parse("http://127.0.0.1:1234/api/target").unwrap();
1888 assert!(!hop_targets_blocked_host(&url));
1889 }
1890
1891 // Issue #449: the connect-time resolver guard's pure classification core, unit-tested
1892 // directly rather than through `tokio::net::lookup_host` — no live DNS/network needed.
1893 #[test]
1894 fn test_validate_resolved_addrs_blocks_cloud_metadata() {
1895 let addrs = vec!["169.254.169.254:0".parse().unwrap()];
1896 assert_matches!(
1897 validate_resolved_addrs("evil.example", addrs, AddrGuard::Baseline),
1898 Err(ResolveGuardError::Blocked { .. })
1899 );
1900 }
1901
1902 // FR-003: an attacker's public A record alongside a blocked one must not keep the probe
1903 // alive — the whole resolution is rejected, not filtered down to the public address.
1904 #[test]
1905 fn test_validate_resolved_addrs_blocks_when_any_address_is_blocked() {
1906 let addrs = vec![
1907 "1.1.1.1:0".parse().unwrap(),
1908 "169.254.169.254:0".parse().unwrap(),
1909 ];
1910 assert_matches!(
1911 validate_resolved_addrs("evil.example", addrs, AddrGuard::Baseline),
1912 Err(ResolveGuardError::Blocked { .. })
1913 );
1914 }
1915
1916 #[test]
1917 fn test_validate_resolved_addrs_allows_global() {
1918 let addrs = vec!["1.1.1.1:0".parse().unwrap()];
1919 assert_eq!(
1920 validate_resolved_addrs("index.crates.io", addrs.clone(), AddrGuard::Baseline).unwrap(),
1921 addrs
1922 );
1923 }
1924
1925 // NFR-004: fail-closed on an empty resolution rather than silently treating "nothing
1926 // resolved" as "nothing to block".
1927 #[test]
1928 fn test_validate_resolved_addrs_fails_closed_on_empty() {
1929 assert_matches!(
1930 validate_resolved_addrs("evil.example", vec![], AddrGuard::Baseline),
1931 Err(ResolveGuardError::NoAddresses { .. })
1932 );
1933 }
1934
1935 #[test]
1936 fn test_validate_resolved_addrs_unwraps_mapped_v4() {
1937 let addrs = vec!["[::ffff:169.254.169.254]:0".parse().unwrap()];
1938 assert_matches!(
1939 validate_resolved_addrs("evil.example", addrs, AddrGuard::Baseline),
1940 Err(ResolveGuardError::Blocked { .. })
1941 );
1942 }
1943
1944 // Issue #455, test-plan item 1: under `Baseline`, an RFC1918/CGNAT/unique-local address is
1945 // allowed (today's pre-#455 behavior) — only `never_a_registry` classes are blocked.
1946 #[test]
1947 fn test_validate_resolved_addrs_baseline_allows_private_ranges() {
1948 for addr_str in ["10.0.0.1:0", "100.64.0.1:0", "[fc00::1]:0"] {
1949 let addrs = vec![addr_str.parse().unwrap()];
1950 assert!(
1951 validate_resolved_addrs("corp.example", addrs, AddrGuard::Baseline).is_ok(),
1952 "{addr_str} must be allowed under Baseline"
1953 );
1954 }
1955 }
1956
1957 // Issue #455, test-plan item 1: under `WorkspaceDeclared(PublicOnly)`, every RFC1918/CGNAT/
1958 // unique-local address is blocked, while a `Global` address is still allowed.
1959 #[test]
1960 fn test_validate_resolved_addrs_workspace_public_only_blocks_private_ranges() {
1961 let guard = AddrGuard::WorkspaceDeclared(WorkspaceRegistryAccess::PublicOnly);
1962 for addr_str in ["10.0.0.1:0", "100.64.0.1:0", "[fc00::1]:0"] {
1963 let addrs = vec![addr_str.parse().unwrap()];
1964 assert!(
1965 matches!(
1966 validate_resolved_addrs("evil.example", addrs, guard),
1967 Err(ResolveGuardError::Blocked { .. })
1968 ),
1969 "{addr_str} must be blocked under WorkspaceDeclared(PublicOnly)"
1970 );
1971 }
1972
1973 let global = vec!["1.1.1.1:0".parse().unwrap()];
1974 assert!(validate_resolved_addrs("index.crates.io", global, guard).is_ok());
1975 }
1976
1977 // Test-plan item 2: `WorkspaceDeclared(All)` admits a private-range address.
1978 #[test]
1979 fn test_validate_resolved_addrs_workspace_all_allows_private_ranges() {
1980 let guard = AddrGuard::WorkspaceDeclared(WorkspaceRegistryAccess::All);
1981 let addrs = vec!["10.0.0.1:0".parse().unwrap()];
1982 assert!(validate_resolved_addrs("corp.example", addrs, guard).is_ok());
1983 }
1984
1985 // Test-plan item 2: `WorkspaceDeclared(Off)` rejects even a `Global` address.
1986 #[test]
1987 fn test_validate_resolved_addrs_workspace_off_rejects_global() {
1988 let guard = AddrGuard::WorkspaceDeclared(WorkspaceRegistryAccess::Off);
1989 let addrs = vec!["1.1.1.1:0".parse().unwrap()];
1990 assert_matches!(
1991 validate_resolved_addrs("index.crates.io", addrs, guard),
1992 Err(ResolveGuardError::Blocked { .. })
1993 );
1994 }
1995
1996 // Test-plan item 3: `build_guarded_client_with_lookup` shares `build_client_inner` with the
1997 // production `build_guarded_client`, so deleting the `.dns_resolver(...)` wiring from that
1998 // shared function fails this test too, not just the production-path wiring test above. The
1999 // synthetic lookup returns an RFC1918 address, resolved (not connected — the resolver
2000 // guard rejects before any TCP attempt) purely through the built `Client`.
2001 #[tokio::test]
2002 async fn test_build_guarded_client_with_lookup_blocks_private_range_under_workspace_tier() {
2003 let lookup = TestLookup(Arc::new(|_host: &str| vec!["10.0.0.1:0".parse().unwrap()]));
2004 let guard = AddrGuard::WorkspaceDeclared(WorkspaceRegistryAccess::PublicOnly);
2005 let client = build_guarded_client_with_lookup(guard, lookup);
2006 let result = client.get("https://corp.example/").send().await;
2007 let err = result.expect_err(
2008 "a private-range synthetic lookup must be rejected under WorkspaceDeclared(PublicOnly)",
2009 );
2010 assert!(
2011 format!("{err:?}").contains("Blocked"),
2012 "expected rejection at the resolver-guard step, got: {err:?}"
2013 );
2014 }
2015
2016 // Test-plan item 3, `Baseline` contrast: the same synthetic private-range lookup is not
2017 // blocked at the resolver-guard step under `Baseline` — asserted directly against the
2018 // resolver (not a full `Client`) to avoid depending on any real network behavior of
2019 // actually connecting to the synthetic address.
2020 #[tokio::test]
2021 async fn test_blocked_addr_resolver_allows_private_range_under_baseline() {
2022 use reqwest::dns::Resolve;
2023
2024 let lookup = TestLookup(Arc::new(|_host: &str| vec!["10.0.0.1:0".parse().unwrap()]));
2025 let resolver = BlockedAddrResolver::with_lookup(AddrGuard::Baseline, lookup);
2026 let result = resolver.resolve("corp.example".parse().unwrap()).await;
2027 assert!(
2028 result.is_ok(),
2029 "Baseline must allow a private-range address through"
2030 );
2031 }
2032
2033 // Direct unit coverage of `BlockedAddrResolver::resolve` on a *name* (not an IP literal —
2034 // that path never reaches any resolver in production, see the struct's `# Known
2035 // limitations` doc). `localhost` resolves via the OS's own hosts file, no network needed.
2036 // This alone does not prove the resolver is wired into `build_guarded_client` — see the
2037 // sibling test below (critic S1) for that.
2038 #[tokio::test]
2039 async fn test_blocked_addr_resolver_rejects_loopback_name_directly() {
2040 use reqwest::dns::Resolve;
2041
2042 let addrs = BlockedAddrResolver::new(AddrGuard::Baseline)
2043 .resolve("localhost".parse().unwrap())
2044 .await;
2045 assert!(addrs.is_err());
2046 }
2047
2048 // Issue #449 critic S1: the previous version of this test called
2049 // `BlockedAddrResolver::resolve` directly and never went through `build_guarded_client` at
2050 // all — deleting `.dns_resolver(...)` from `build_client_inner` left it green. This
2051 // version proves actual wiring behaviorally: a real mockito listener answers on
2052 // `server.socket_address()`'s port, reached here through the `localhost` *name* (so the
2053 // request actually reaches the configured resolver, unlike an IP literal, which
2054 // hyper-util's connector parses directly and never consults the resolver — see
2055 // `BlockedAddrResolver`'s `# Known limitations` doc). Without the guard wired in, this
2056 // request would succeed against the real listener; with it wired in, it must fail before
2057 // ever reaching the listener.
2058 #[tokio::test]
2059 async fn test_build_client_wires_in_blocked_addr_resolver() {
2060 let mut server = mockito::Server::new_async().await;
2061 let _mock = server
2062 .mock("GET", "/")
2063 .with_status(200)
2064 .create_async()
2065 .await;
2066 let port = server.socket_address().port();
2067
2068 let client = build_guarded_client(AddrGuard::Baseline);
2069 let result = client.get(format!("http://localhost:{port}/")).send().await;
2070
2071 let err = result.expect_err(
2072 "expected the wired-in resolver guard to reject a loopback-resolving name even \
2073 though a real listener answers at this port",
2074 );
2075 // Not just any failure: the `Debug` impl (unlike `Display`) surfaces the boxed
2076 // `source` chain, so this confirms `ResolveGuardError::Blocked` itself produced the
2077 // error rather than an unrelated failure (timeout, TLS, connection refused)
2078 // coincidentally also erroring. `derive(Debug)` on an enum prints only the variant
2079 // name, not `ResolveGuardError::`, hence checking for `Blocked`/`Loopback` together
2080 // rather than the enum's own name.
2081 let debug = format!("{err:?}");
2082 assert!(
2083 debug.contains("Blocked") && debug.contains("Loopback"),
2084 "expected the failure to originate from ResolveGuardError::Blocked with class \
2085 Loopback, got: {debug}"
2086 );
2087 }
2088
2089 // S5 (plan-1b §1.1/§4): a 302 to the cloud-metadata IP must be stopped by the
2090 // *unconditional* redirect policy, not just the trusted-origin one — this is the
2091 // empirical proof that #443's default unauthenticated client also closes the
2092 // redirect-hop bypass, not only `get_cached_trusted_origin`.
2093 #[tokio::test]
2094 async fn test_get_cached_stops_redirect_to_cloud_metadata() {
2095 let mut server = mockito::Server::new_async().await;
2096
2097 let _redirect = server
2098 .mock("GET", "/api/source")
2099 .with_status(302)
2100 .with_header("location", "https://169.254.169.254/latest/meta-data/")
2101 .create_async()
2102 .await;
2103
2104 let cache = HttpCache::new();
2105 let source_url = format!("{}/api/source", server.url());
2106 let result: Result<Bytes> = cache.get_cached(&source_url).await;
2107
2108 assert!(
2109 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2110 "expected the redirect to be stopped and surfaced as HttpStatus(302)"
2111 );
2112 }
2113
2114 #[tokio::test]
2115 async fn test_get_cached_follows_same_scheme_redirect() {
2116 let mut server = mockito::Server::new_async().await;
2117 let target_url = format!("{}/api/target", server.url());
2118
2119 let _redirect = server
2120 .mock("GET", "/api/source")
2121 .with_status(302)
2122 .with_header("location", &target_url)
2123 .create_async()
2124 .await;
2125 let _target = server
2126 .mock("GET", "/api/target")
2127 .with_status(200)
2128 .with_body("redirected data")
2129 .create_async()
2130 .await;
2131
2132 let cache = HttpCache::new();
2133 let source_url = format!("{}/api/source", server.url());
2134 let result: Bytes = cache.get_cached(&source_url).await.unwrap();
2135
2136 assert_eq!(result.as_ref(), b"redirected data");
2137 }
2138
2139 // Issue #455, test-plan item 4(a): loopback -> loopback. A `Baseline` transport follows the
2140 // hop (the `hop_targets_blocked_host` test-cfg carve-out for `Loopback`); a
2141 // `WorkspaceDeclared(PublicOnly)` transport stops it, since `PublicOnly.allows(Loopback) ==
2142 // false` — this pins M1' and is the contrast proving the tier split is real. This is one of
2143 // the tests exercising the documented zero-initial-URL-literal-protection residual: both
2144 // mockito URLs are IP literals, so the *initial* connection to server A is never checked by
2145 // policy — only the redirect hop is.
2146 #[tokio::test]
2147 async fn test_workspace_transport_stops_loopback_redirect_baseline_follows() {
2148 let mut server_a = mockito::Server::new_async().await;
2149 let mut server_b = mockito::Server::new_async().await;
2150 let target_url = format!("{}/api/target", server_b.url());
2151
2152 let _redirect = server_a
2153 .mock("GET", "/api/source")
2154 .with_status(302)
2155 .with_header("location", &target_url)
2156 .create_async()
2157 .await;
2158 let _target = server_b
2159 .mock("GET", "/api/target")
2160 .with_status(200)
2161 .with_body("redirected data")
2162 .create_async()
2163 .await;
2164
2165 let source_url = format!("{}/api/source", server_a.url());
2166
2167 let cache = HttpCache::new();
2168 let result: Bytes = cache
2169 .get_cached_with_headers_via(&source_url, &[], &Transport::baseline(), None)
2170 .await
2171 .unwrap();
2172 assert_eq!(result.as_ref(), b"redirected data");
2173
2174 let policy = Arc::new(RegistryAccessPolicy::new(
2175 WorkspaceRegistryAccess::PublicOnly,
2176 ));
2177 let workspace_cache = HttpCache::with_policy(Arc::clone(&policy));
2178 let result: Result<Bytes> = workspace_cache
2179 .get_cached_with_headers_via(&source_url, &[], &Transport::workspace(&policy), None)
2180 .await;
2181 assert!(
2182 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2183 "expected the workspace transport to stop the loopback hop, got {result:?}"
2184 );
2185 }
2186
2187 // Issue #455, test-plan item 4(b): a workspace-blocked literal. Server A 302s to an
2188 // RFC1918-literal target; the workspace transport under `PublicOnly` stops before
2189 // connecting (the redirect-policy tier term rejects the hop from its URL string alone, no
2190 // resolver involved for a literal), so the caller sees `HttpStatus{302}` with no
2191 // `HTTP_TIMEOUT_SECS` stall. Also exercises the zero-initial-URL-literal-protection
2192 // residual (the initial hop to server A, an IP literal, is unchecked by policy).
2193 #[tokio::test]
2194 async fn test_workspace_transport_stops_redirect_to_private_literal() {
2195 let mut server = mockito::Server::new_async().await;
2196
2197 let _redirect = server
2198 .mock("GET", "/api/source")
2199 .with_status(302)
2200 .with_header("location", "https://10.0.0.1/x")
2201 .create_async()
2202 .await;
2203
2204 let policy = Arc::new(RegistryAccessPolicy::new(
2205 WorkspaceRegistryAccess::PublicOnly,
2206 ));
2207 let cache = HttpCache::with_policy(Arc::clone(&policy));
2208 let source_url = format!("{}/api/source", server.url());
2209 let result: Result<Bytes> = cache
2210 .get_cached_with_headers_via(&source_url, &[], &Transport::workspace(&policy), None)
2211 .await;
2212
2213 assert!(
2214 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2215 "expected the redirect to 10.0.0.1 to be stopped, got {result:?}"
2216 );
2217 }
2218
2219 // Unlike the https->http downgrade case, cross-origin redirect blocking IS reachable
2220 // through mockito: two separate `mockito::Server` instances bind to distinct ports,
2221 // and a distinct port is a distinct origin (scheme+host+port), so a 302 from one to
2222 // the other is a genuine cross-origin redirect the trusted-origin policy must stop.
2223 #[tokio::test]
2224 async fn test_get_cached_trusted_origin_stops_cross_origin_redirect() {
2225 let mut trusted_server = mockito::Server::new_async().await;
2226 let mut other_server = mockito::Server::new_async().await;
2227
2228 let trusted_origin = format!("{}/", trusted_server.url());
2229 let escape_target = format!("{}/api/stolen", other_server.url());
2230
2231 let _redirect = trusted_server
2232 .mock("GET", "/api/source")
2233 .with_status(302)
2234 .with_header("location", &escape_target)
2235 .create_async()
2236 .await;
2237 let escape = other_server
2238 .mock("GET", "/api/stolen")
2239 .with_status(200)
2240 .with_body("must not be returned")
2241 .expect(0)
2242 .create_async()
2243 .await;
2244
2245 let cache = HttpCache::new();
2246 let source_url = format!("{}/api/source", trusted_server.url());
2247 let result: Result<Bytes> = cache
2248 .get_cached_trusted_origin(&source_url, &trusted_origin)
2249 .await;
2250
2251 // The stopped redirect surfaces as the 302 response itself, handled like any
2252 // other non-2xx status - not as a distinct "redirect blocked" error variant.
2253 // Assert via `matches!` rather than debug-formatting `result` in a panic message:
2254 // on the `Ok` arm that value is the raw response body, which would otherwise be
2255 // written to the test log by the panic machinery.
2256 assert!(
2257 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2258 "expected HttpStatus(302)"
2259 );
2260
2261 // Proves the security property itself (the escape origin was never contacted),
2262 // not just the symptom (the result is a 302) - a client that followed the
2263 // redirect and then discarded the body would still pass the assertion above.
2264 escape.assert_async().await;
2265 }
2266
2267 #[tokio::test]
2268 async fn test_get_cached_trusted_origin_follows_same_origin_redirect() {
2269 let mut server = mockito::Server::new_async().await;
2270 let trusted_origin = format!("{}/", server.url());
2271 let target_url = format!("{}/api/target", server.url());
2272
2273 let _redirect = server
2274 .mock("GET", "/api/source")
2275 .with_status(302)
2276 .with_header("location", &target_url)
2277 .create_async()
2278 .await;
2279 let _target = server
2280 .mock("GET", "/api/target")
2281 .with_status(200)
2282 .with_body("trusted data")
2283 .create_async()
2284 .await;
2285
2286 let cache = HttpCache::new();
2287 let source_url = format!("{}/api/source", server.url());
2288 let result: Bytes = cache
2289 .get_cached_trusted_origin(&source_url, &trusted_origin)
2290 .await
2291 .unwrap();
2292
2293 assert_eq!(result.as_ref(), b"trusted data");
2294 }
2295
2296 // Proves every hop is re-checked, not just the first: a same-origin hop is followed,
2297 // then a second, cross-origin hop from that (already-followed) intermediate is stopped.
2298 #[tokio::test]
2299 async fn test_get_cached_trusted_origin_stops_second_hop_of_multi_hop_chain() {
2300 let mut trusted_server = mockito::Server::new_async().await;
2301 let mut other_server = mockito::Server::new_async().await;
2302
2303 let trusted_origin = format!("{}/", trusted_server.url());
2304 let intermediate_url = format!("{}/api/intermediate", trusted_server.url());
2305 let escape_target = format!("{}/api/stolen", other_server.url());
2306
2307 let _first_hop = trusted_server
2308 .mock("GET", "/api/source")
2309 .with_status(302)
2310 .with_header("location", &intermediate_url)
2311 .create_async()
2312 .await;
2313 let _second_hop = trusted_server
2314 .mock("GET", "/api/intermediate")
2315 .with_status(302)
2316 .with_header("location", &escape_target)
2317 .create_async()
2318 .await;
2319 let escape = other_server
2320 .mock("GET", "/api/stolen")
2321 .with_status(200)
2322 .with_body("must not be returned")
2323 .expect(0)
2324 .create_async()
2325 .await;
2326
2327 let cache = HttpCache::new();
2328 let source_url = format!("{}/api/source", trusted_server.url());
2329 let result: Result<Bytes> = cache
2330 .get_cached_trusted_origin(&source_url, &trusted_origin)
2331 .await;
2332
2333 // Assert via `matches!` rather than debug-formatting `result` in a panic message:
2334 // on the `Ok` arm that value is the raw response body, which would otherwise be
2335 // written to the test log by the panic machinery.
2336 assert!(
2337 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2338 "expected HttpStatus(302)"
2339 );
2340 escape.assert_async().await;
2341 }
2342
2343 // Sibling path-prefix rejection: `.../api/` must not accept `.../apiX/...`. The other
2344 // trusted-origin tests use a bare-host prefix, which never exercises this boundary.
2345 #[tokio::test]
2346 async fn test_get_cached_trusted_origin_rejects_sibling_path_prefix() {
2347 let mut server = mockito::Server::new_async().await;
2348 let trusted_origin = format!("{}/api/", server.url());
2349 let escape_target = format!("{}/apiX/evil", server.url());
2350
2351 let _redirect = server
2352 .mock("GET", "/api/source")
2353 .with_status(302)
2354 .with_header("location", &escape_target)
2355 .create_async()
2356 .await;
2357 let escape = server
2358 .mock("GET", "/apiX/evil")
2359 .with_status(200)
2360 .with_body("must not be returned")
2361 .expect(0)
2362 .create_async()
2363 .await;
2364
2365 let cache = HttpCache::new();
2366 let source_url = format!("{}/api/source", server.url());
2367 let result: Result<Bytes> = cache
2368 .get_cached_trusted_origin(&source_url, &trusted_origin)
2369 .await;
2370
2371 // Assert via `matches!` rather than debug-formatting `result` in a panic message:
2372 // on the `Ok` arm that value is the raw response body, which would otherwise be
2373 // written to the test log by the panic machinery.
2374 assert!(
2375 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2376 "expected HttpStatus(302)"
2377 );
2378 escape.assert_async().await;
2379 }
2380
2381 #[tokio::test]
2382 async fn test_get_cached_trusted_origin_with_headers_sends_extra_header() {
2383 let mut server = mockito::Server::new_async().await;
2384 let trusted_origin = format!("{}/", server.url());
2385
2386 let _m = server
2387 .mock("GET", "/api/data")
2388 .match_header("authorization", "Bearer secret-token")
2389 .with_status(200)
2390 .with_body("authenticated data")
2391 .create_async()
2392 .await;
2393
2394 let cache = HttpCache::new();
2395 let url = format!("{}/api/data", server.url());
2396 let result: Bytes = cache
2397 .get_cached_trusted_origin_with_headers(
2398 &url,
2399 &trusted_origin,
2400 &[(header::AUTHORIZATION, "Bearer secret-token")],
2401 )
2402 .await
2403 .unwrap();
2404
2405 assert_eq!(result.as_ref(), b"authenticated data");
2406 }
2407
2408 // The security property this method exists for: a credential header must never survive
2409 // a cross-origin redirect hop, proven the same way the unauthenticated trusted-origin
2410 // test proves it (the escape origin is never contacted at all) rather than by asserting
2411 // the header was merely absent on a request that did land.
2412 #[tokio::test]
2413 async fn test_get_cached_trusted_origin_with_headers_stops_cross_origin_redirect() {
2414 let mut trusted_server = mockito::Server::new_async().await;
2415 let mut other_server = mockito::Server::new_async().await;
2416
2417 let trusted_origin = format!("{}/", trusted_server.url());
2418 let escape_target = format!("{}/api/stolen", other_server.url());
2419
2420 let _redirect = trusted_server
2421 .mock("GET", "/api/source")
2422 .with_status(302)
2423 .with_header("location", &escape_target)
2424 .create_async()
2425 .await;
2426 let escape = other_server
2427 .mock("GET", "/api/stolen")
2428 .with_status(200)
2429 .with_body("must not be returned")
2430 .expect(0)
2431 .create_async()
2432 .await;
2433
2434 let cache = HttpCache::new();
2435 let source_url = format!("{}/api/source", trusted_server.url());
2436 let result: Result<Bytes> = cache
2437 .get_cached_trusted_origin_with_headers(
2438 &source_url,
2439 &trusted_origin,
2440 &[(header::AUTHORIZATION, "Bearer secret-token")],
2441 )
2442 .await;
2443
2444 assert!(
2445 matches!(result, Err(DepsError::HttpStatus { status: 302, .. })),
2446 "expected HttpStatus(302)"
2447 );
2448 escape.assert_async().await;
2449 }
2450
2451 // Proves `redirect_policy`'s `Policy::default().redirect(attempt)` delegation is
2452 // actually wired in and live: without it (e.g. a no-op policy that always follows),
2453 // this chain would keep following past 10 hops instead of erroring. A single-hop
2454 // redirect test alone can't distinguish "delegation is live" from "no policy at all".
2455 #[tokio::test]
2456 async fn test_get_cached_default_client_enforces_ten_hop_redirect_limit() {
2457 let mut server = mockito::Server::new_async().await;
2458 let base = server.url();
2459
2460 // reqwest's default policy errors once `previous.len() > 10`, i.e. on the 11th
2461 // redirect hop - so 11 redirecting steps (step/0 through step/10) are needed to
2462 // trigger it; step/11 must never actually be requested.
2463 let mut hop_mocks = Vec::new();
2464 for i in 0..11u32 {
2465 let path = format!("/step/{i}");
2466 let next = format!("{base}/step/{}", i + 1);
2467 hop_mocks.push(
2468 server
2469 .mock("GET", path.as_str())
2470 .with_status(302)
2471 .with_header("location", &next)
2472 .create_async()
2473 .await,
2474 );
2475 }
2476 let final_step = server
2477 .mock("GET", "/step/11")
2478 .with_status(200)
2479 .with_body("unreachable")
2480 .expect(0)
2481 .create_async()
2482 .await;
2483
2484 // Kept alive (not just built) until here: each `Mock` deregisters on drop, so
2485 // dropping this early would silently turn every hop 404 instead of 302.
2486 assert_eq!(hop_mocks.len(), 11);
2487
2488 let cache = HttpCache::new();
2489 let start_url = format!("{base}/step/0");
2490 let result: Result<Bytes> = cache.get_cached(&start_url).await;
2491
2492 assert!(
2493 matches!(result, Err(DepsError::RegistryError { .. })),
2494 "expected a too-many-redirects network error, got {result:?}"
2495 );
2496 final_step.assert_async().await;
2497 }
2498
2499 #[test]
2500 fn test_cache_creation() {
2501 let cache = HttpCache::new();
2502 assert_eq!(cache.len(), 0);
2503 assert!(cache.is_empty());
2504 }
2505
2506 #[test]
2507 fn test_cache_clear() {
2508 let cache = HttpCache::new();
2509 cache.entries.insert(
2510 "test".into(),
2511 CachedResponse {
2512 body: Bytes::from_static(&[1, 2, 3]),
2513 etag: None,
2514 last_modified: None,
2515 fetched_at: Instant::now(),
2516 },
2517 );
2518 assert_eq!(cache.len(), 1);
2519 cache.clear();
2520 assert_eq!(cache.len(), 0);
2521 }
2522
2523 #[test]
2524 fn test_cached_response_clone() {
2525 let response = CachedResponse {
2526 body: Bytes::from_static(&[1, 2, 3]),
2527 etag: Some("test".into()),
2528 last_modified: Some("date".into()),
2529 fetched_at: Instant::now(),
2530 };
2531 let cloned = response.clone();
2532 // Bytes clone is cheap (reference counting)
2533 assert_eq!(response.body, cloned.body);
2534 assert_eq!(response.etag, cloned.etag);
2535 }
2536
2537 #[test]
2538 fn test_cache_len() {
2539 let cache = HttpCache::new();
2540 assert_eq!(cache.len(), 0);
2541
2542 cache.entries.insert(
2543 "url1".into(),
2544 CachedResponse {
2545 body: Bytes::new(),
2546 etag: None,
2547 last_modified: None,
2548 fetched_at: Instant::now(),
2549 },
2550 );
2551
2552 assert_eq!(cache.len(), 1);
2553 }
2554
2555 #[tokio::test]
2556 async fn test_get_cached_fresh_fetch() {
2557 let mut server = mockito::Server::new_async().await;
2558
2559 let _m = server
2560 .mock("GET", "/api/data")
2561 .with_status(200)
2562 .with_header("etag", "\"abc123\"")
2563 .with_body("test data")
2564 .create_async()
2565 .await;
2566
2567 let cache = HttpCache::new();
2568 let url = format!("{}/api/data", server.url());
2569 let result: Bytes = cache.get_cached(&url).await.unwrap();
2570
2571 assert_eq!(result.as_ref(), b"test data");
2572 assert_eq!(cache.len(), 1);
2573 }
2574
2575 #[tokio::test]
2576 async fn test_get_cached_cache_hit() {
2577 let mut server = mockito::Server::new_async().await;
2578 let url = format!("{}/api/data", server.url());
2579
2580 let cache = HttpCache::new();
2581
2582 let _m1 = server
2583 .mock("GET", "/api/data")
2584 .with_status(200)
2585 .with_header("etag", "\"abc123\"")
2586 .with_body("original data")
2587 .create_async()
2588 .await;
2589
2590 let result1: Bytes = cache.get_cached(&url).await.unwrap();
2591 assert_eq!(result1.as_ref(), b"original data");
2592 assert_eq!(cache.len(), 1);
2593
2594 drop(_m1);
2595
2596 let _m2 = server
2597 .mock("GET", "/api/data")
2598 .match_header("if-none-match", "\"abc123\"")
2599 .with_status(304)
2600 .create_async()
2601 .await;
2602
2603 let result2: Bytes = cache.get_cached(&url).await.unwrap();
2604 assert_eq!(result2.as_ref(), b"original data");
2605 }
2606
2607 #[tokio::test]
2608 async fn test_get_cached_304_not_modified() {
2609 let mut server = mockito::Server::new_async().await;
2610 let url = format!("{}/api/data", server.url());
2611
2612 let cache = HttpCache::new();
2613
2614 let _m1 = server
2615 .mock("GET", "/api/data")
2616 .with_status(200)
2617 .with_header("etag", "\"abc123\"")
2618 .with_body("original data")
2619 .create_async()
2620 .await;
2621
2622 let result1: Bytes = cache.get_cached(&url).await.unwrap();
2623 assert_eq!(result1.as_ref(), b"original data");
2624
2625 drop(_m1);
2626
2627 let _m2 = server
2628 .mock("GET", "/api/data")
2629 .match_header("if-none-match", "\"abc123\"")
2630 .with_status(304)
2631 .create_async()
2632 .await;
2633
2634 let result2: Bytes = cache.get_cached(&url).await.unwrap();
2635 assert_eq!(result2.as_ref(), b"original data");
2636 }
2637
2638 #[tokio::test]
2639 async fn test_get_cached_etag_validation() {
2640 let mut server = mockito::Server::new_async().await;
2641 let url = format!("{}/api/data", server.url());
2642
2643 let cache = HttpCache::new();
2644
2645 cache.entries.insert(
2646 url.clone(),
2647 CachedResponse {
2648 body: Bytes::from_static(b"cached"),
2649 etag: Some("\"tag123\"".into()),
2650 last_modified: None,
2651 fetched_at: Instant::now(),
2652 },
2653 );
2654
2655 let _m = server
2656 .mock("GET", "/api/data")
2657 .match_header("if-none-match", "\"tag123\"")
2658 .with_status(304)
2659 .create_async()
2660 .await;
2661
2662 let result: Bytes = cache.get_cached(&url).await.unwrap();
2663 assert_eq!(result.as_ref(), b"cached");
2664 }
2665
2666 #[tokio::test]
2667 async fn test_get_cached_last_modified_validation() {
2668 let mut server = mockito::Server::new_async().await;
2669 let url = format!("{}/api/data", server.url());
2670
2671 let cache = HttpCache::new();
2672
2673 cache.entries.insert(
2674 url.clone(),
2675 CachedResponse {
2676 body: Bytes::from_static(b"cached"),
2677 etag: None,
2678 last_modified: Some("Wed, 21 Oct 2024 07:28:00 GMT".into()),
2679 fetched_at: Instant::now(),
2680 },
2681 );
2682
2683 let _m = server
2684 .mock("GET", "/api/data")
2685 .match_header("if-modified-since", "Wed, 21 Oct 2024 07:28:00 GMT")
2686 .with_status(304)
2687 .create_async()
2688 .await;
2689
2690 let result: Bytes = cache.get_cached(&url).await.unwrap();
2691 assert_eq!(result.as_ref(), b"cached");
2692 }
2693
2694 #[tokio::test]
2695 async fn test_get_cached_network_error_fallback() {
2696 let cache = HttpCache::new();
2697 // https:// (not http://) so this exercises DNS-resolution failure, not the
2698 // HTTPS-only policy enforced by `ensure_https`.
2699 let url = "https://invalid.localhost.test/data";
2700
2701 cache.entries.insert(
2702 url.to_string(),
2703 CachedResponse {
2704 body: Bytes::from_static(b"stale data"),
2705 etag: Some("\"old\"".into()),
2706 last_modified: None,
2707 fetched_at: Instant::now(),
2708 },
2709 );
2710
2711 let result: Bytes = cache.get_cached(url).await.unwrap();
2712 assert_eq!(result.as_ref(), b"stale data");
2713 }
2714
2715 #[tokio::test]
2716 async fn test_fetch_and_store_http_error() {
2717 let mut server = mockito::Server::new_async().await;
2718
2719 let _m = server
2720 .mock("GET", "/api/missing")
2721 .with_status(404)
2722 .with_body("Not Found")
2723 .create_async()
2724 .await;
2725
2726 let cache = HttpCache::new();
2727 let url = format!("{}/api/missing", server.url());
2728 let result: Result<Bytes> = cache
2729 .fetch_and_store_with_headers(&url, &[], &cache.baseline.client, &url)
2730 .await;
2731
2732 assert!(result.is_err());
2733 match result {
2734 Err(DepsError::HttpStatus { status, .. }) => {
2735 assert_eq!(status, 404);
2736 }
2737 _ => panic!("Expected HttpStatus"),
2738 }
2739 }
2740
2741 #[tokio::test]
2742 async fn test_fetch_and_store_stores_headers() {
2743 let mut server = mockito::Server::new_async().await;
2744
2745 let _m = server
2746 .mock("GET", "/api/data")
2747 .with_status(200)
2748 .with_header("etag", "\"abc123\"")
2749 .with_header("last-modified", "Wed, 21 Oct 2024 07:28:00 GMT")
2750 .with_body("test")
2751 .create_async()
2752 .await;
2753
2754 let cache = HttpCache::new();
2755 let url = format!("{}/api/data", server.url());
2756 let _: Bytes = cache
2757 .fetch_and_store_with_headers(&url, &[], &cache.baseline.client, &url)
2758 .await
2759 .unwrap();
2760
2761 let cached = cache.entries.get(&url).unwrap();
2762 assert_eq!(cached.etag, Some("\"abc123\"".into()));
2763 assert_eq!(
2764 cached.last_modified,
2765 Some("Wed, 21 Oct 2024 07:28:00 GMT".into())
2766 );
2767 }
2768
2769 #[tokio::test]
2770 async fn test_get_cached_with_headers_sends_extra_headers() {
2771 let mut server = mockito::Server::new_async().await;
2772 let url = format!("{}/api/data", server.url());
2773
2774 let _m = server
2775 .mock("GET", "/api/data")
2776 .match_header("authorization", "Bearer token123")
2777 .with_status(200)
2778 .with_header("etag", "\"abc123\"")
2779 .with_body("authed data")
2780 .create_async()
2781 .await;
2782
2783 let cache = HttpCache::new();
2784 let headers = [(header::AUTHORIZATION, "Bearer token123")];
2785 let result: Bytes = cache.get_cached_with_headers(&url, &headers).await.unwrap();
2786
2787 assert_eq!(result.as_ref(), b"authed data");
2788 }
2789
2790 /// The headered form of `get_cached_workspace` used by `deps-npm`'s alternate-registry
2791 /// client (A1): forwards `extra_headers` while still going through the workspace-tier
2792 /// transport (mirrors `test_get_cached_and_get_cached_workspace_do_not_share_an_entry`'s
2793 /// use of the unheadered `get_cached_workspace` against a loopback mockito server under
2794 /// the default policy — an IP-literal host like mockito's has no DNS resolution step for
2795 /// the connect-time `AddrGuard` to intercept, so no policy elevation is needed here
2796 /// either; that guard's actual job is catching a *hostname* that resolves differently at
2797 /// connect time than its parse-time classification, see `validate_resolved_addrs`).
2798 #[tokio::test]
2799 async fn test_get_cached_workspace_with_headers_sends_extra_headers() {
2800 let mut server = mockito::Server::new_async().await;
2801 let url = format!("{}/api/data", server.url());
2802
2803 let _m = server
2804 .mock("GET", "/api/data")
2805 .match_header("accept", "application/vnd.npm.install-v1+json")
2806 .with_status(200)
2807 .with_body("abbreviated packument")
2808 .create_async()
2809 .await;
2810
2811 let cache = HttpCache::new();
2812 let headers = [(header::ACCEPT, "application/vnd.npm.install-v1+json")];
2813 let result: Bytes = cache
2814 .get_cached_workspace_with_headers(&url, &headers)
2815 .await
2816 .unwrap();
2817
2818 assert_eq!(result.as_ref(), b"abbreviated packument");
2819 }
2820
2821 #[tokio::test]
2822 async fn test_fetch_and_store_rejects_oversized_response() {
2823 let mut server = mockito::Server::new_async().await;
2824 let oversized_body = vec![0u8; MAX_RESPONSE_BYTES + 1];
2825
2826 let _m = server
2827 .mock("GET", "/api/huge")
2828 .with_status(200)
2829 .with_body(oversized_body)
2830 .create_async()
2831 .await;
2832
2833 let cache = HttpCache::new();
2834 let url = format!("{}/api/huge", server.url());
2835 let result: Result<Bytes> = cache
2836 .fetch_and_store_with_headers(&url, &[], &cache.baseline.client, &url)
2837 .await;
2838
2839 match result {
2840 Err(DepsError::ResponseTooLarge { limit, .. }) => {
2841 assert_eq!(limit, MAX_RESPONSE_BYTES);
2842 }
2843 other => panic!("expected ResponseTooLarge, got {other:?}"),
2844 }
2845
2846 // The oversized response must not have been cached.
2847 assert!(cache.entries.get(&url).is_none());
2848 }
2849
2850 #[tokio::test]
2851 async fn test_fetch_and_store_accepts_response_at_exact_cap() {
2852 // A response at MAX_RESPONSE_BYTES (32 MiB) is well over
2853 // MAX_CACHEABLE_ENTRY_BYTES (8 MiB), so the network-layer cap and
2854 // the cache admission cap are independent: the fetch succeeds and
2855 // returns the full body, but the response is not retained in the
2856 // cache (see test_store_entry_skips_caching_oversized_entry).
2857 let mut server = mockito::Server::new_async().await;
2858 let exact_cap_body = vec![0u8; MAX_RESPONSE_BYTES];
2859
2860 let _m = server
2861 .mock("GET", "/api/exact")
2862 .with_status(200)
2863 .with_body(exact_cap_body)
2864 .create_async()
2865 .await;
2866
2867 let cache = HttpCache::new();
2868 let url = format!("{}/api/exact", server.url());
2869 let result: Bytes = cache
2870 .fetch_and_store_with_headers(&url, &[], &cache.baseline.client, &url)
2871 .await
2872 .unwrap();
2873
2874 assert_eq!(result.len(), MAX_RESPONSE_BYTES);
2875 }
2876
2877 #[tokio::test]
2878 async fn test_get_cached_non_2xx_on_refresh_preserves_stale_cache() {
2879 let mut server = mockito::Server::new_async().await;
2880 let url = format!("{}/api/data", server.url());
2881
2882 let cache = HttpCache::new();
2883 cache.entries.insert(
2884 url.clone(),
2885 CachedResponse {
2886 body: Bytes::from_static(b"stale but good"),
2887 etag: Some("\"stale-etag\"".into()),
2888 last_modified: None,
2889 fetched_at: Instant::now(),
2890 },
2891 );
2892
2893 // Registry is down for maintenance: the conditional request gets a
2894 // non-2xx, non-304 response instead of either "unchanged" or "here's
2895 // the new body".
2896 let _m = server
2897 .mock("GET", "/api/data")
2898 .match_header("if-none-match", "\"stale-etag\"")
2899 .with_status(503)
2900 .with_body("<html>maintenance</html>")
2901 .create_async()
2902 .await;
2903
2904 let result: Bytes = cache.get_cached(&url).await.unwrap();
2905
2906 // The stale-while-revalidate fallback returns the last-known-good
2907 // body, and the cache entry is left untouched rather than being
2908 // overwritten with the error page.
2909 assert_eq!(result.as_ref(), b"stale but good");
2910 let cached = cache.entries.get(&url).unwrap();
2911 assert_eq!(cached.etag, Some("\"stale-etag\"".into()));
2912 }
2913
2914 #[tokio::test]
2915 async fn test_post_json_success_returns_body_and_does_not_cache() {
2916 let mut server = mockito::Server::new_async().await;
2917 let url = format!("{}/v1/querybatch", server.url());
2918
2919 let _m = server
2920 .mock("POST", "/v1/querybatch")
2921 .match_header("content-type", "application/json")
2922 .with_status(200)
2923 .with_body(r#"{"results":[{}]}"#)
2924 .create_async()
2925 .await;
2926
2927 let cache = HttpCache::new();
2928 let body = serde_json::json!({ "queries": [] });
2929 let result: Bytes = cache.post_json(&url, &body).await.unwrap();
2930
2931 assert_eq!(result.as_ref(), br#"{"results":[{}]}"#);
2932 assert!(
2933 cache.is_empty(),
2934 "post_json must not populate the entry-map cache"
2935 );
2936 }
2937
2938 #[tokio::test]
2939 async fn test_post_json_non_2xx_returns_http_status_error() {
2940 let mut server = mockito::Server::new_async().await;
2941 let url = format!("{}/v1/querybatch", server.url());
2942
2943 let _m = server
2944 .mock("POST", "/v1/querybatch")
2945 .with_status(400)
2946 .create_async()
2947 .await;
2948
2949 let cache = HttpCache::new();
2950 let body = serde_json::json!({ "queries": [] });
2951 let result: Result<Bytes> = cache.post_json(&url, &body).await;
2952
2953 match result {
2954 Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 400),
2955 other => panic!("expected HttpStatus, got {other:?}"),
2956 }
2957 }
2958
2959 #[tokio::test]
2960 async fn test_get_transport_only_success_returns_body_and_does_not_cache() {
2961 let mut server = mockito::Server::new_async().await;
2962 let url = format!("{}/v1/vulns/RUSTSEC-2020-0071", server.url());
2963
2964 let _m = server
2965 .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
2966 .with_status(200)
2967 .with_body(r#"{"id":"RUSTSEC-2020-0071"}"#)
2968 .create_async()
2969 .await;
2970
2971 let cache = HttpCache::new();
2972 let result: Bytes = cache.get_transport_only(&url).await.unwrap();
2973
2974 assert_eq!(result.as_ref(), br#"{"id":"RUSTSEC-2020-0071"}"#);
2975 assert!(
2976 cache.is_empty(),
2977 "get_transport_only must not populate the entry-map cache"
2978 );
2979 }
2980
2981 #[tokio::test]
2982 async fn test_get_transport_only_with_headers_sends_extra_headers() {
2983 let mut server = mockito::Server::new_async().await;
2984 let url = format!("{}/v1/vulns/RUSTSEC-2020-0071", server.url());
2985
2986 let _m = server
2987 .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
2988 .match_header("accept", "application/json")
2989 .with_status(200)
2990 .with_body(r#"{"id":"RUSTSEC-2020-0071"}"#)
2991 .create_async()
2992 .await;
2993
2994 let cache = HttpCache::new();
2995 let headers = [(header::ACCEPT, "application/json")];
2996 let result: Bytes = cache
2997 .get_transport_only_with_headers(&url, &headers)
2998 .await
2999 .unwrap();
3000
3001 assert_eq!(result.as_ref(), br#"{"id":"RUSTSEC-2020-0071"}"#);
3002 assert!(
3003 cache.is_empty(),
3004 "get_transport_only_with_headers must not populate the entry-map cache"
3005 );
3006 }
3007
3008 #[tokio::test]
3009 async fn test_get_transport_only_non_2xx_returns_http_status_error() {
3010 let mut server = mockito::Server::new_async().await;
3011 let url = format!("{}/v1/vulns/missing", server.url());
3012
3013 let _m = server
3014 .mock("GET", "/v1/vulns/missing")
3015 .with_status(404)
3016 .create_async()
3017 .await;
3018
3019 let cache = HttpCache::new();
3020 let result: Result<Bytes> = cache.get_transport_only(&url).await;
3021
3022 match result {
3023 Err(DepsError::HttpStatus { status, .. }) => assert_eq!(status, 404),
3024 other => panic!("expected HttpStatus, got {other:?}"),
3025 }
3026 }
3027
3028 fn dummy_response(size: usize) -> CachedResponse {
3029 CachedResponse {
3030 body: Bytes::from(vec![0u8; size]),
3031 etag: None,
3032 last_modified: None,
3033 fetched_at: Instant::now(),
3034 }
3035 }
3036
3037 #[test]
3038 fn test_total_bytes_tracks_inserts_and_replacement() {
3039 let cache = HttpCache::new();
3040 cache.store_entry("url1".into(), dummy_response(100));
3041 assert_eq!(cache.total_bytes(), 100);
3042
3043 // Replacing the same key must account for the delta, not just add.
3044 cache.store_entry("url1".into(), dummy_response(40));
3045 assert_eq!(cache.total_bytes(), 40);
3046
3047 cache.store_entry("url2".into(), dummy_response(60));
3048 assert_eq!(cache.total_bytes(), 100);
3049 }
3050
3051 #[test]
3052 fn test_clear_resets_total_bytes() {
3053 let cache = HttpCache::new();
3054 cache.store_entry("url1".into(), dummy_response(1000));
3055 assert_eq!(cache.total_bytes(), 1000);
3056
3057 cache.clear();
3058 assert_eq!(cache.total_bytes(), 0);
3059 }
3060
3061 #[test]
3062 fn test_small_payloads_do_not_trigger_eviction() {
3063 let cache = HttpCache::new();
3064 for i in 0..50 {
3065 cache.store_entry(format!("url{i}"), dummy_response(1024));
3066 }
3067
3068 assert_eq!(cache.len(), 50);
3069 assert_eq!(cache.total_bytes(), 50 * 1024);
3070 }
3071
3072 #[test]
3073 fn test_evict_entries_triggers_on_byte_budget_with_few_entries() {
3074 let cache = HttpCache::new();
3075
3076 // 9 entries, each at the per-entry admission cap: far below
3077 // MAX_CACHE_ENTRIES by count, but their combined size (72 MiB)
3078 // overshoots MAX_CACHE_BYTES (64 MiB), exercising the byte-only
3079 // eviction path.
3080 for i in 0..9 {
3081 cache.store_entry(format!("url{i}"), dummy_response(MAX_CACHEABLE_ENTRY_BYTES));
3082 }
3083 assert_eq!(cache.len(), 9);
3084 assert!(cache.total_bytes() > MAX_CACHE_BYTES);
3085
3086 cache.evict_entries();
3087
3088 // Only as many oldest entries as needed to clear the byte budget
3089 // are removed - not a fixed count-based batch. Removing the single
3090 // oldest (8 MiB) entry brings the total to exactly the 64 MiB
3091 // budget, so eviction stops there.
3092 assert!(cache.total_bytes() <= MAX_CACHE_BYTES);
3093 assert_eq!(cache.len(), 8);
3094 }
3095
3096 #[test]
3097 fn test_evict_entries_removes_oldest_first_for_bytes() {
3098 let cache = HttpCache::new();
3099
3100 // 9 entries at the per-entry admission cap (8 MiB each = 72 MiB
3101 // total, 8 MiB over the 64 MiB budget), so evicting just the single
3102 // oldest entry restores the cache to within budget - proving
3103 // eviction picks the genuinely oldest entry, not hash-iteration
3104 // order (the pre-existing count-eviction bug this PR also fixes).
3105 cache.store_entry("oldest".into(), dummy_response(MAX_CACHEABLE_ENTRY_BYTES));
3106 std::thread::sleep(std::time::Duration::from_millis(5));
3107 for i in 0..8 {
3108 cache.store_entry(
3109 format!("newer{i}"),
3110 dummy_response(MAX_CACHEABLE_ENTRY_BYTES),
3111 );
3112 }
3113 assert_eq!(cache.len(), 9);
3114
3115 cache.evict_entries();
3116
3117 assert_eq!(cache.len(), 8);
3118 assert!(cache.entries.get("oldest").is_none());
3119 for i in 0..8 {
3120 assert!(cache.entries.get(&format!("newer{i}")).is_some());
3121 }
3122 }
3123
3124 #[tokio::test]
3125 async fn test_get_cached_with_headers_evicts_on_byte_budget() {
3126 let mut server = mockito::Server::new_async().await;
3127 let url = format!("{}/api/data", server.url());
3128
3129 let cache = HttpCache::new();
3130
3131 // Pre-fill the cache past the byte budget with old entries, all
3132 // under MAX_CACHE_ENTRIES by count and within the per-entry
3133 // admission cap.
3134 for i in 0..9 {
3135 cache.store_entry(
3136 format!("stale{i}"),
3137 dummy_response(MAX_CACHEABLE_ENTRY_BYTES),
3138 );
3139 }
3140 assert!(cache.total_bytes() > MAX_CACHE_BYTES);
3141
3142 let _m = server
3143 .mock("GET", "/api/data")
3144 .with_status(200)
3145 .with_body("fresh")
3146 .create_async()
3147 .await;
3148
3149 let result: Bytes = cache.get_cached(&url).await.unwrap();
3150 assert_eq!(result.as_ref(), b"fresh");
3151
3152 // The pre-request byte-budget check evicted stale entries before
3153 // fetching, so the cache never grows unbounded past the budget.
3154 assert!(cache.total_bytes() <= MAX_CACHE_BYTES + result.len());
3155 }
3156
3157 #[test]
3158 fn test_store_entry_skips_caching_oversized_entry() {
3159 let cache = HttpCache::new();
3160
3161 // A body over the per-entry admission cap is not retained, even
3162 // though the caller still gets it back (store_entry's caller
3163 // already holds `body` independently - see fetch_and_store_with_headers).
3164 cache.store_entry("big".into(), dummy_response(MAX_CACHEABLE_ENTRY_BYTES + 1));
3165 assert!(cache.entries.get("big").is_none());
3166 assert_eq!(cache.total_bytes(), 0);
3167
3168 // Replacing an existing small entry with an oversized one drops the
3169 // stale small entry too, rather than leaving it to keep serving
3170 // increasingly outdated data forever.
3171 cache.store_entry("small".into(), dummy_response(100));
3172 assert_eq!(cache.total_bytes(), 100);
3173
3174 cache.store_entry(
3175 "small".into(),
3176 dummy_response(MAX_CACHEABLE_ENTRY_BYTES + 1),
3177 );
3178 assert!(cache.entries.get("small").is_none());
3179 assert_eq!(cache.total_bytes(), 0);
3180 }
3181
3182 #[test]
3183 fn test_concurrent_store_and_evict_keeps_total_bytes_consistent() {
3184 use std::sync::Arc;
3185 use std::thread;
3186
3187 let cache = Arc::new(HttpCache::new());
3188 let handles: Vec<_> = (0..8)
3189 .map(|t| {
3190 let cache = Arc::clone(&cache);
3191 thread::spawn(move || {
3192 for i in 0..150 {
3193 cache.store_entry(format!("t{t}-{i}"), dummy_response(4096));
3194 if i % 10 == 0 {
3195 cache.evict_entries();
3196 }
3197 }
3198 })
3199 })
3200 .collect();
3201
3202 for handle in handles {
3203 handle.join().unwrap();
3204 }
3205 cache.evict_entries();
3206
3207 // The regression this guards against: evict_entries used to
3208 // snapshot total_bytes once and overwrite it with an absolute
3209 // store at the end, silently discarding any store_entry delta
3210 // that landed concurrently. That drift is undetectable from a
3211 // single-threaded test - only genuine concurrent access exercises
3212 // the race, so this asserts the tracked counter still matches the
3213 // actual summed size of what remains in the map.
3214 let actual: usize = cache
3215 .entries
3216 .iter()
3217 .map(|entry| entry.value().body.len())
3218 .sum();
3219 assert_eq!(cache.total_bytes(), actual);
3220 }
3221
3222 // Issue #455, test-plan item 5: `get_cached(url)` then `get_cached_workspace(url)` do not
3223 // share an entry — each is keyed under a distinct namespace (see `HttpCache::cache_key`),
3224 // so the mockito mock is hit twice and the cache ends up with two entries for one URL.
3225 #[tokio::test]
3226 async fn test_get_cached_and_get_cached_workspace_do_not_share_an_entry() {
3227 let mut server = mockito::Server::new_async().await;
3228 let url = format!("{}/api/data", server.url());
3229
3230 let mock = server
3231 .mock("GET", "/api/data")
3232 .with_status(200)
3233 .with_body("shared url, distinct tiers")
3234 .expect(2)
3235 .create_async()
3236 .await;
3237
3238 let cache = HttpCache::new();
3239 let baseline_result: Bytes = cache.get_cached(&url).await.unwrap();
3240 let workspace_result: Bytes = cache.get_cached_workspace(&url).await.unwrap();
3241
3242 assert_eq!(baseline_result.as_ref(), b"shared url, distinct tiers");
3243 assert_eq!(workspace_result.as_ref(), b"shared url, distinct tiers");
3244 assert_eq!(cache.len(), 2);
3245 mock.assert_async().await;
3246 }
3247
3248 // Issue #455, test-plan item 6 (C5): fetch under `All`, tighten to `PublicOnly`, re-fetch
3249 // the same URL — the `All`-era body must not be served, since the policy-scoped key
3250 // namespace changes with the policy.
3251 #[tokio::test]
3252 async fn test_set_registry_policy_change_does_not_serve_stale_era_body() {
3253 let mut server = mockito::Server::new_async().await;
3254 let url = format!("{}/api/data", server.url());
3255
3256 let _first = server
3257 .mock("GET", "/api/data")
3258 .with_status(200)
3259 .with_body("all-era body")
3260 .create_async()
3261 .await;
3262
3263 let policy = Arc::new(RegistryAccessPolicy::new(WorkspaceRegistryAccess::All));
3264 let cache = HttpCache::with_policy(Arc::clone(&policy));
3265 let first: Bytes = cache.get_cached_workspace(&url).await.unwrap();
3266 assert_eq!(first.as_ref(), b"all-era body");
3267
3268 cache.set_registry_policy(WorkspaceRegistryAccess::PublicOnly);
3269
3270 let _second = server
3271 .mock("GET", "/api/data")
3272 .with_status(200)
3273 .with_body("public-only-era body")
3274 .create_async()
3275 .await;
3276
3277 let second: Bytes = cache.get_cached_workspace(&url).await.unwrap();
3278 assert_eq!(
3279 second.as_ref(),
3280 b"public-only-era body",
3281 "the All-era cached body must not be served after tightening to PublicOnly"
3282 );
3283 // mockito's second mock answers request 2 regardless of which cache entry (if any) was
3284 // hit, so the body assertion alone would still pass with a policy-blind cache key —
3285 // this is the assertion that actually proves the two eras got distinct map entries.
3286 assert_eq!(cache.len(), 2);
3287 }
3288
3289 // Issue #455, test-plan item 7 (C4): `set_registry_policy` rebuilds the workspace transport
3290 // only on an actual change, not on a no-op re-application of the same value.
3291 #[test]
3292 fn test_set_registry_policy_rebuilds_only_on_change() {
3293 let policy = Arc::new(RegistryAccessPolicy::new(
3294 WorkspaceRegistryAccess::PublicOnly,
3295 ));
3296 let cache = HttpCache::with_policy(policy);
3297 assert_eq!(cache.workspace_rebuilds.load(Ordering::Relaxed), 0);
3298
3299 cache.set_registry_policy(WorkspaceRegistryAccess::PublicOnly);
3300 assert_eq!(
3301 cache.workspace_rebuilds.load(Ordering::Relaxed),
3302 0,
3303 "re-applying the unchanged policy must not rebuild the workspace transport"
3304 );
3305
3306 cache.set_registry_policy(WorkspaceRegistryAccess::All);
3307 assert_eq!(cache.workspace_rebuilds.load(Ordering::Relaxed), 1);
3308
3309 cache.set_registry_policy(WorkspaceRegistryAccess::All);
3310 assert_eq!(
3311 cache.workspace_rebuilds.load(Ordering::Relaxed),
3312 1,
3313 "re-applying the unchanged (new) policy must not rebuild again"
3314 );
3315
3316 cache.set_registry_policy(WorkspaceRegistryAccess::Off);
3317 assert_eq!(cache.workspace_rebuilds.load(Ordering::Relaxed), 2);
3318 }
3319
3320 // Issue #483: offline + cold, three send sites. `.expect(0)` proves nothing reached
3321 // *this mock* — adequate here only because `ensure_https`'s loopback carve-out is what
3322 // let a mockito server stand in for a real registry at all in this module's tests, not
3323 // a general proof that zero sockets ever opened.
3324
3325 #[tokio::test]
3326 async fn test_offline_cold_get_cached_errors_without_network() {
3327 let mut server = mockito::Server::new_async().await;
3328 let url = format!("{}/api/data", server.url());
3329 let mock = server
3330 .mock("GET", "/api/data")
3331 .with_status(200)
3332 .with_body("must not be fetched")
3333 .expect(0)
3334 .create_async()
3335 .await;
3336
3337 let cache = HttpCache::new();
3338 cache.set_offline(true);
3339
3340 let result: Result<Bytes> = cache.get_cached(&url).await;
3341 match result {
3342 Err(DepsError::Offline { url: blocked }) => assert_eq!(blocked, url),
3343 other => panic!("expected Offline, got {other:?}"),
3344 }
3345 mock.assert_async().await;
3346 }
3347
3348 #[tokio::test]
3349 async fn test_offline_cold_post_json_errors_without_network() {
3350 let mut server = mockito::Server::new_async().await;
3351 let url = format!("{}/v1/querybatch", server.url());
3352 let mock = server
3353 .mock("POST", "/v1/querybatch")
3354 .with_status(200)
3355 .expect(0)
3356 .create_async()
3357 .await;
3358
3359 let cache = HttpCache::new();
3360 cache.set_offline(true);
3361
3362 let body = serde_json::json!({ "queries": [] });
3363 let result: Result<Bytes> = cache.post_json(&url, &body).await;
3364 assert_matches!(result, Err(DepsError::Offline { .. }));
3365 mock.assert_async().await;
3366 }
3367
3368 #[tokio::test]
3369 async fn test_offline_cold_get_transport_only_errors_without_network() {
3370 let mut server = mockito::Server::new_async().await;
3371 let url = format!("{}/v1/vulns/RUSTSEC-2020-0071", server.url());
3372 let mock = server
3373 .mock("GET", "/v1/vulns/RUSTSEC-2020-0071")
3374 .with_status(200)
3375 .expect(0)
3376 .create_async()
3377 .await;
3378
3379 let cache = HttpCache::new();
3380 cache.set_offline(true);
3381
3382 let result: Result<Bytes> = cache.get_transport_only(&url).await;
3383 assert_matches!(result, Err(DepsError::Offline { .. }));
3384 mock.assert_async().await;
3385 }
3386
3387 #[tokio::test]
3388 async fn test_offline_warm_serves_cached_body_without_network() {
3389 let mut server = mockito::Server::new_async().await;
3390 let url = format!("{}/api/data", server.url());
3391 let mock = server
3392 .mock("GET", "/api/data")
3393 .with_status(200)
3394 .with_body("must not be fetched")
3395 .expect(0)
3396 .create_async()
3397 .await;
3398
3399 let cache = HttpCache::new();
3400 cache.entries.insert(
3401 url.clone(),
3402 CachedResponse {
3403 body: Bytes::from_static(b"warm cached body"),
3404 etag: Some("\"tag123\"".into()),
3405 last_modified: None,
3406 fetched_at: Instant::now(),
3407 },
3408 );
3409 cache.set_offline(true);
3410
3411 let result: Bytes = cache.get_cached(&url).await.unwrap();
3412 assert_eq!(result.as_ref(), b"warm cached body");
3413 mock.assert_async().await;
3414 }
3415
3416 #[tokio::test]
3417 async fn test_cache_disabled_two_calls_each_hit_the_server() {
3418 let mut server = mockito::Server::new_async().await;
3419 let url = format!("{}/api/data", server.url());
3420 let mock = server
3421 .mock("GET", "/api/data")
3422 .with_status(200)
3423 .with_body("fresh every time")
3424 .expect(2)
3425 .create_async()
3426 .await;
3427
3428 let cache = HttpCache::new();
3429 cache.set_cache_enabled(false);
3430
3431 let first: Bytes = cache.get_cached(&url).await.unwrap();
3432 let second: Bytes = cache.get_cached(&url).await.unwrap();
3433 assert_eq!(first.as_ref(), b"fresh every time");
3434 assert_eq!(second.as_ref(), b"fresh every time");
3435 assert!(
3436 cache.is_empty(),
3437 "cache.enabled: false must never populate the entry map"
3438 );
3439 mock.assert_async().await;
3440 }
3441
3442 // S1 fix (critic-corrected): the naive design's `!cache_enabled` bypass ran *before*
3443 // any offline check, so `cache.enabled: false` + `network.offline: true` cold always
3444 // took the network-only bypass path — which `ensure_online` then blocked — even though
3445 // this combination is meant to still surface a clean, immediate signal rather than
3446 // hang or silently return empty data forever.
3447 #[tokio::test]
3448 async fn test_offline_and_cache_disabled_cold_start_errors_cleanly() {
3449 let mut server = mockito::Server::new_async().await;
3450 let url = format!("{}/api/data", server.url());
3451 let mock = server
3452 .mock("GET", "/api/data")
3453 .with_status(200)
3454 .expect(0)
3455 .create_async()
3456 .await;
3457
3458 let cache = HttpCache::new();
3459 cache.set_cache_enabled(false);
3460 cache.set_offline(true);
3461
3462 let result: Result<Bytes> = cache.get_cached(&url).await;
3463 assert_matches!(result, Err(DepsError::Offline { .. }));
3464 mock.assert_async().await;
3465 }
3466
3467 // The scenario S1 actually exists to fix: an entry stored while caching was enabled
3468 // must still be servable once `cache.enabled` is later turned off *and* the cache goes
3469 // offline in the same breath — proving `offline` truly overrides `cache_enabled` on the
3470 // read path, not just when the two flags never change together.
3471 #[tokio::test]
3472 async fn test_offline_overrides_disabled_cache_to_serve_warm_entry() {
3473 let mut server = mockito::Server::new_async().await;
3474 let url = format!("{}/api/data", server.url());
3475 let mock = server
3476 .mock("GET", "/api/data")
3477 .with_status(200)
3478 .with_body("fetched while online")
3479 .expect(1)
3480 .create_async()
3481 .await;
3482
3483 let cache = HttpCache::new();
3484 let first: Bytes = cache.get_cached(&url).await.unwrap();
3485 assert_eq!(first.as_ref(), b"fetched while online");
3486
3487 cache.set_cache_enabled(false);
3488 cache.set_offline(true);
3489
3490 let second: Bytes = cache.get_cached(&url).await.unwrap();
3491 assert_eq!(
3492 second.as_ref(),
3493 b"fetched while online",
3494 "offline must override cache_enabled:false and still serve the warm entry"
3495 );
3496 mock.assert_async().await;
3497 }
3498
3499 // The primary UX case (critic M6a): a full online -> offline transition on an
3500 // otherwise-default cache (cache.enabled stays true throughout) must keep serving what
3501 // was already fetched.
3502 #[tokio::test]
3503 async fn test_online_to_offline_transition_serves_previously_fetched_entry() {
3504 let mut server = mockito::Server::new_async().await;
3505 let url = format!("{}/api/data", server.url());
3506 let mock = server
3507 .mock("GET", "/api/data")
3508 .with_status(200)
3509 .with_body("fetched while online")
3510 .expect(1)
3511 .create_async()
3512 .await;
3513
3514 let cache = HttpCache::new();
3515 let online: Bytes = cache.get_cached(&url).await.unwrap();
3516 assert_eq!(online.as_ref(), b"fetched while online");
3517
3518 cache.set_offline(true);
3519
3520 let offline: Bytes = cache.get_cached(&url).await.unwrap();
3521 assert_eq!(offline.as_ref(), b"fetched while online");
3522 mock.assert_async().await;
3523 }
3524
3525 // Offline -> online restores live fetches (the flag's other half of critic M6a),
3526 // exercised here through `get_cached`'s conditional-revalidation path directly (the
3527 // live `did_change_configuration` toggle is covered by `deps-lsp`'s own test).
3528 #[tokio::test]
3529 async fn test_offline_to_online_transition_resumes_fetching() {
3530 let mut server = mockito::Server::new_async().await;
3531 let url = format!("{}/api/data", server.url());
3532 let mock = server
3533 .mock("GET", "/api/data")
3534 .with_status(200)
3535 .with_header("etag", "\"abc123\"")
3536 .with_body("fetched while online")
3537 .expect(1)
3538 .create_async()
3539 .await;
3540
3541 let cache = HttpCache::new();
3542 let online: Bytes = cache.get_cached(&url).await.unwrap();
3543 assert_eq!(online.as_ref(), b"fetched while online");
3544 mock.assert_async().await;
3545
3546 cache.set_offline(true);
3547 let offline: Bytes = cache.get_cached(&url).await.unwrap();
3548 assert_eq!(offline.as_ref(), b"fetched while online");
3549
3550 cache.set_offline(false);
3551 drop(mock);
3552 let revalidate = server
3553 .mock("GET", "/api/data")
3554 .match_header("if-none-match", "\"abc123\"")
3555 .with_status(304)
3556 .expect(1)
3557 .create_async()
3558 .await;
3559 let resumed: Bytes = cache.get_cached(&url).await.unwrap();
3560 assert_eq!(
3561 resumed.as_ref(),
3562 b"fetched while online",
3563 "returning online must resume live requests, not stay pinned to the cached body"
3564 );
3565 revalidate.assert_async().await;
3566 }
3567
3568 // Critic M6b: `get_cached_workspace` and `get_cached_trusted_origin` key entries under
3569 // distinct namespaces from `get_cached`'s baseline tier — the offline warm-cache path
3570 // needs its own proof it holds for each.
3571 #[tokio::test]
3572 async fn test_offline_warm_serves_workspace_tier_without_network() {
3573 let mut server = mockito::Server::new_async().await;
3574 let url = format!("{}/api/data", server.url());
3575 let mock = server
3576 .mock("GET", "/api/data")
3577 .with_status(200)
3578 .with_body("workspace fetch")
3579 .expect(1)
3580 .create_async()
3581 .await;
3582
3583 let cache = HttpCache::new();
3584 let online: Bytes = cache.get_cached_workspace(&url).await.unwrap();
3585 assert_eq!(online.as_ref(), b"workspace fetch");
3586
3587 cache.set_offline(true);
3588 let offline: Bytes = cache.get_cached_workspace(&url).await.unwrap();
3589 assert_eq!(offline.as_ref(), b"workspace fetch");
3590 mock.assert_async().await;
3591 }
3592
3593 #[tokio::test]
3594 async fn test_offline_warm_serves_trusted_origin_tier_without_network() {
3595 let mut server = mockito::Server::new_async().await;
3596 let trusted_origin = format!("{}/", server.url());
3597 let url = format!("{}/api/data", server.url());
3598 let mock = server
3599 .mock("GET", "/api/data")
3600 .with_status(200)
3601 .with_body("trusted-origin fetch")
3602 .expect(1)
3603 .create_async()
3604 .await;
3605
3606 let cache = HttpCache::new();
3607 let online: Bytes = cache
3608 .get_cached_trusted_origin(&url, &trusted_origin)
3609 .await
3610 .unwrap();
3611 assert_eq!(online.as_ref(), b"trusted-origin fetch");
3612
3613 cache.set_offline(true);
3614 let offline: Bytes = cache
3615 .get_cached_trusted_origin(&url, &trusted_origin)
3616 .await
3617 .unwrap();
3618 assert_eq!(offline.as_ref(), b"trusted-origin fetch");
3619 mock.assert_async().await;
3620 }
3621
3622 // --- issue #561/#562: CacheTier::Pinned, get_cached_pinned{,_with_headers} ---
3623
3624 #[tokio::test]
3625 async fn test_get_cached_pinned_attaches_auth_header() {
3626 let mut server = mockito::Server::new_async().await;
3627 let trusted_origin = format!("{}/", server.url());
3628 let url = format!("{}/api/data", server.url());
3629
3630 let _m = server
3631 .mock("GET", "/api/data")
3632 .match_header("authorization", "Basic dXNlcjpwYXQ=")
3633 .with_status(200)
3634 .with_body("authenticated data")
3635 .create_async()
3636 .await;
3637
3638 let cache = HttpCache::new();
3639 let result = cache
3640 .get_cached_pinned_with_headers(
3641 &url,
3642 &trusted_origin,
3643 true,
3644 Some(42),
3645 &[(header::AUTHORIZATION, "Basic dXNlcjpwYXQ=")],
3646 )
3647 .await
3648 .unwrap();
3649
3650 assert_eq!(result.as_ref(), b"authenticated data");
3651 }
3652
3653 /// FR-014: distinct `auth_id` values against the same `(url, trusted_origin)` never share
3654 /// a cache entry — a rotated or distinct credential never reads back a body fetched under a
3655 /// different one.
3656 #[tokio::test]
3657 async fn test_get_cached_pinned_distinct_auth_id_never_shares_cache_entry() {
3658 let mut server = mockito::Server::new_async().await;
3659 let trusted_origin = format!("{}/", server.url());
3660 let url = format!("{}/api/data", server.url());
3661
3662 let _m1 = server
3663 .mock("GET", "/api/data")
3664 .with_status(200)
3665 .with_body("body-for-credential-a")
3666 .create_async()
3667 .await;
3668
3669 let cache = HttpCache::new();
3670 let a = cache
3671 .get_cached_pinned(&url, &trusted_origin, true, Some(1))
3672 .await
3673 .unwrap();
3674 assert_eq!(a.as_ref(), b"body-for-credential-a");
3675 drop(_m1);
3676
3677 let _m2 = server
3678 .mock("GET", "/api/data")
3679 .with_status(200)
3680 .with_body("body-for-credential-b")
3681 .create_async()
3682 .await;
3683
3684 let b = cache
3685 .get_cached_pinned(&url, &trusted_origin, true, Some(2))
3686 .await
3687 .unwrap();
3688 assert_eq!(
3689 b.as_ref(),
3690 b"body-for-credential-b",
3691 "a distinct auth_id must not read back credential A's cached body"
3692 );
3693 }
3694
3695 /// FR-015/NFR-004: a 401 revalidation response against an authenticated `Pinned`-tier
3696 /// entry evicts the entry and returns the error — never the default
3697 /// stale-while-revalidate fallback that would serve the possibly-revoked credential's
3698 /// last-known-good body.
3699 #[tokio::test]
3700 async fn test_pinned_authenticated_401_revalidation_evicts_instead_of_stale_serve() {
3701 let mut server = mockito::Server::new_async().await;
3702 let trusted_origin = format!("{}/", server.url());
3703 let url = format!("{}/api/data", server.url());
3704
3705 let _m1 = server
3706 .mock("GET", "/api/data")
3707 .with_status(200)
3708 .with_header("etag", "\"abc123\"")
3709 .with_body("private data")
3710 .create_async()
3711 .await;
3712
3713 let cache = HttpCache::new();
3714 let first = cache
3715 .get_cached_pinned(&url, &trusted_origin, true, Some(7))
3716 .await
3717 .unwrap();
3718 assert_eq!(first.as_ref(), b"private data");
3719 assert_eq!(cache.len(), 1);
3720 drop(_m1);
3721
3722 let _m2 = server
3723 .mock("GET", "/api/data")
3724 .match_header("if-none-match", "\"abc123\"")
3725 .with_status(401)
3726 .create_async()
3727 .await;
3728
3729 let result = cache
3730 .get_cached_pinned(&url, &trusted_origin, true, Some(7))
3731 .await;
3732
3733 assert!(
3734 matches!(result, Err(DepsError::HttpStatus { status: 401, .. })),
3735 "expected the 401 to surface as an error, not a stale-served body: {result:?}"
3736 );
3737 assert_eq!(
3738 cache.len(),
3739 0,
3740 "the revoked-credential entry must be evicted, not left cached"
3741 );
3742 }
3743
3744 /// Every other tier keeps today's stale-while-revalidate fallback unchanged — only an
3745 /// *authenticated* `Pinned` entry evicts on 401/403 (FR-015's scope is deliberately
3746 /// narrow).
3747 #[tokio::test]
3748 async fn test_unauthenticated_pinned_401_revalidation_still_serves_stale() {
3749 let mut server = mockito::Server::new_async().await;
3750 let trusted_origin = format!("{}/", server.url());
3751 let url = format!("{}/api/data", server.url());
3752
3753 let _m1 = server
3754 .mock("GET", "/api/data")
3755 .with_status(200)
3756 .with_header("etag", "\"abc123\"")
3757 .with_body("workspace data")
3758 .create_async()
3759 .await;
3760
3761 let cache = HttpCache::new();
3762 let first = cache
3763 .get_cached_pinned(&url, &trusted_origin, false, None)
3764 .await
3765 .unwrap();
3766 assert_eq!(first.as_ref(), b"workspace data");
3767 drop(_m1);
3768
3769 let _m2 = server
3770 .mock("GET", "/api/data")
3771 .match_header("if-none-match", "\"abc123\"")
3772 .with_status(401)
3773 .create_async()
3774 .await;
3775
3776 let second = cache
3777 .get_cached_pinned(&url, &trusted_origin, false, None)
3778 .await
3779 .unwrap();
3780 assert_eq!(
3781 second.as_ref(),
3782 b"workspace data",
3783 "an unauthenticated Pinned entry must keep the default stale-while-revalidate fallback"
3784 );
3785 assert_eq!(cache.len(), 1);
3786 }
3787
3788 /// `set_registry_policy` purges every `Pinned`-tier cache entry (and pooled transport) on
3789 /// an actual policy transition — closing the round-trip hole for credential-carrying
3790 /// entries (NFR-004), unlike the pre-existing `WorkspaceDeclared` non-purge behavior.
3791 #[tokio::test]
3792 async fn test_set_registry_policy_purges_pinned_tier_entries() {
3793 let mut server = mockito::Server::new_async().await;
3794 let trusted_origin = format!("{}/", server.url());
3795 let url = format!("{}/api/data", server.url());
3796
3797 let _m = server
3798 .mock("GET", "/api/data")
3799 .with_status(200)
3800 .with_body("private data")
3801 .create_async()
3802 .await;
3803
3804 let policy = Arc::new(RegistryAccessPolicy::new(WorkspaceRegistryAccess::All));
3805 let cache = HttpCache::with_policy(Arc::clone(&policy));
3806 cache
3807 .get_cached_pinned(&url, &trusted_origin, true, Some(1))
3808 .await
3809 .unwrap();
3810 assert_eq!(cache.len(), 1);
3811
3812 cache.set_registry_policy(WorkspaceRegistryAccess::PublicOnly);
3813
3814 assert_eq!(
3815 cache.len(),
3816 0,
3817 "a Pinned-tier entry must be purged on any actual policy transition"
3818 );
3819 }
3820}