Skip to main content

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}