Skip to main content

deps_nuget/
registry.rs

1//! NuGet V3 registry client.
2//!
3//! NuGet base URLs are not hardcodable: the service index
4//! (`https://api.nuget.org/v3/index.json`) must be resolved first, then consulted for the
5//! flat-container ("PackageBaseAddress"), search ("SearchQueryService"), and registration
6//! ("RegistrationsBaseUrl") resource URLs — the last one backs both publish-time freshness
7//! and [`NuGetRegistry::unlisted_versions_for_hover`]'s hover-only unlisted enrichment (D1).
8
9use crate::config::{NuGetAuth, NuGetSourceChain, ResolvedHop};
10use crate::types::{NuGetVersion, PackageInfo};
11use crate::version::compare_versions;
12use dashmap::DashMap;
13use deps_core::net_policy::{PolicyGate, RegistryAccessPolicy};
14use deps_core::parser::DependencySource;
15use deps_core::{
16    DepsError, FreshnessSettings, HOVER_RECENT_VERSIONS, HttpCache, PublishTime, Result,
17};
18use serde::Deserialize;
19use std::any::Any;
20use std::collections::{HashMap, HashSet};
21use std::hash::{Hash, Hasher};
22use std::sync::{Arc, OnceLock};
23use tokio::sync::OnceCell;
24
25/// Per-process salt for [`own_auth_digest`]/[`chain_auth_digest`] (issue #561, FR-014). The
26/// salt matters because a chain's `key` is `tracing::debug!`-logged and surfaces as
27/// `DependencySource::AlternateRegistry.index`: an unsalted 64-bit non-cryptographic hash of a
28/// credential header value in a log file is a brute-force target.
29fn digest_salt() -> u64 {
30    static SALT: OnceLock<u64> = OnceLock::new();
31    *SALT.get_or_init(|| {
32        use std::collections::hash_map::RandomState;
33        use std::hash::BuildHasher;
34        let mut hasher = RandomState::new().build_hasher();
35        std::process::id().hash(&mut hasher);
36        std::time::SystemTime::now().hash(&mut hasher);
37        hasher.finish()
38    })
39}
40
41/// A per-request auth identity for [`HttpCache::get_cached_pinned_with_headers`]'s `auth_id`
42/// argument (FR-014) — `0` when `auth` is `None`, otherwise a salted hash of `declared_origin`
43/// and the credential's header value. Deliberately scoped to *one hop's own* credential, not
44/// the whole chain's (see [`chain_auth_digest`] for the distinct, chain-wide value
45/// `register_chain` uses for rotation detection): each hop's own cache-key correctness depends
46/// only on its own credential, independent of whether some other hop in the same chain also
47/// rotated.
48fn own_auth_digest(declared_origin: &str, auth: Option<&NuGetAuth>) -> u64 {
49    let Some(auth) = auth else { return 0 };
50    let mut hasher = std::collections::hash_map::DefaultHasher::new();
51    digest_salt().hash(&mut hasher);
52    declared_origin.hash(&mut hasher);
53    auth.header_value().hash(&mut hasher);
54    hasher.finish()
55}
56
57/// A chain-wide digest over every hop's `(url, auth)` pair, in order (issue #561, S3) — used
58/// only by `NuGetRegistry::register_chain` to detect whether *any* hop's credential in a
59/// re-resolved chain differs from what is currently registered, never as a per-request
60/// `auth_id` (see [`own_auth_digest`] for that, distinct, purpose).
61fn chain_auth_digest(hops: &[ResolvedHop]) -> u64 {
62    let mut hasher = std::collections::hash_map::DefaultHasher::new();
63    digest_salt().hash(&mut hasher);
64    for hop in hops {
65        hop.url.as_str().hash(&mut hasher);
66        match &hop.auth {
67            Some(auth) => {
68                1u8.hash(&mut hasher);
69                auth.header_value().hash(&mut hasher);
70            }
71            None => 0u8.hash(&mut hasher),
72        }
73    }
74    hasher.finish()
75}
76
77/// `Url::parse(s).ok().map(|u| u.origin().ascii_serialization() + "/")` (issue #561, §3.1/M1)
78/// — the sole comparison helper for C1's origin-binding rule, used at both comparison points in
79/// [`NuGetRegistry::fetch`]. Normalization-immune (host case, default port, IDN) — a plain
80/// string `starts_with` against a feed-supplied, un-reparsed value is not sufficient, and would
81/// not defeat a suffix trick like `https://pkgs.dev.azure.com.evil.test/`. A parse failure on
82/// either side is `None`, which [`NuGetRegistry::fetch`] treats as a mismatch — fail closed,
83/// unauthenticated, never an error.
84fn origin_of(s: &str) -> Option<String> {
85    url::Url::parse(s)
86        .ok()
87        .map(|u| format!("{}/", u.origin().ascii_serialization()))
88}
89
90/// The real public NuGet service index — used both as the default root registry's own feed
91/// and, via [`is_public_registry_url`], to identify "the nuget.org source" by normalized URL
92/// rather than by a source's configured `key` (issue #523, R3: a hostile config can name a
93/// private feed `"nuget.org"`).
94pub(crate) const NUGET_ORG_INDEX_URL: &str = "https://api.nuget.org/v3/index.json";
95
96/// Safety bound on external (non-inline) registration page fetches per `get_versions_with`
97/// call. Real packages need at most one (§1.1); this only guards a pathological feed.
98const MAX_EXTERNAL_PAGE_FETCHES: usize = 2;
99
100/// Upper bound on [`NuGetRegistry::alternates`]' entry count. Mirrors `deps-npm`'s/
101/// `deps-pypi`'s identical `MAX_ALTERNATE_REGISTRIES`. Once at capacity, a *new* chain is
102/// simply never registered (see [`NuGetRegistry::register_chain`]) — a dependency resolved to
103/// an unregistered chain degrades to [`DepsError::PackageNotFound`], never to an api.nuget.org
104/// lookup by name.
105const MAX_ALTERNATE_REGISTRIES: usize = 256;
106
107/// Display name for NuGet used in not-found and API-response error messages.
108pub const REGISTRY: &str = "NuGet";
109
110/// Returns `true` when `url` (an already-[`NuGetFeedUrl`]-normalized string) is the real
111/// public NuGet service index — never true for a source merely *named* `"nuget.org"` (issue
112/// #523, R3). Used to decide whether a `<packageSourceMapping>` group that resolves to exactly
113/// this one source should keep plain [`DependencySource::Registry`] (and so the
114/// OSV/deps.dev/hover-trust signal `SourcePolicy::source_is_public_registry_content` gates).
115pub(crate) fn is_public_registry_url(url: &str) -> bool {
116    url == NUGET_ORG_INDEX_URL
117}
118
119/// Which transport a [`NuGetRegistry`] instance fetches through (issue #523, mirrors
120/// `deps-pypi`'s/`deps-npm`'s identical tier split).
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum NuGetRegistryTier {
123    /// `api.nuget.org` (or a test override) — `HttpCache::get_cached`, today's path,
124    /// unchanged, `PolicyGate::Skip`.
125    Public,
126    /// A `NuGet.Config`-declared feed — `HttpCache::get_cached_workspace`, so every redirect
127    /// hop is re-classified against the live [`RegistryAccessPolicy`], and each service-index
128    /// resource `@id` is validated with `PolicyGate::Enforce` before being trusted.
129    WorkspaceDeclared,
130}
131
132#[derive(Debug, Deserialize)]
133struct ServiceIndexResponse {
134    /// A malformed feed may send a non-array `resources` value (or omit it) — both must
135    /// degrade to "no resources" rather than failing the whole document (issue #523, M1: R5
136    /// was originally half-implemented, covering only `@type`/`@id`'s *entry-level* shapes).
137    /// Also filters out any individual resource entry that fails to deserialize at all
138    /// (`serde_json::from_value(..).ok()`), rather than failing the whole array for one bad
139    /// entry.
140    #[serde(default, deserialize_with = "deserialize_resources")]
141    resources: Vec<ServiceResource>,
142}
143
144#[derive(Debug, Deserialize)]
145struct ServiceResource {
146    /// A malformed feed may send a non-string `@id` (e.g. `123`) — must degrade to "this
147    /// resource cannot be picked" rather than failing deserialization of the *entire*
148    /// document (issue #523, M1: `Option<String>` alone does not catch a type *mismatch*,
149    /// only an absent/null value). See [`deserialize_optional_string`].
150    #[serde(
151        rename = "@id",
152        default,
153        deserialize_with = "deserialize_optional_string"
154    )]
155    id: Option<String>,
156    /// A JSON-LD `@type` may legitimately be an array, and a malformed feed may send a
157    /// non-string scalar (`123`) — either shape must degrade to "this resource doesn't match
158    /// any type we look for" rather than failing deserialization of the *entire* service
159    /// index document (issue #523, R5). See [`deserialize_type_list`].
160    #[serde(rename = "@type", default, deserialize_with = "deserialize_type_list")]
161    r#type: Vec<String>,
162}
163
164/// Lenient `resources` deserializer: a non-array value (or an absent field, via `#[serde(default)]`
165/// on the caller) degrades to an empty list, and each array entry that itself fails to
166/// deserialize as a [`ServiceResource`] is dropped rather than failing the whole array.
167fn deserialize_resources<'de, D>(
168    deserializer: D,
169) -> std::result::Result<Vec<ServiceResource>, D::Error>
170where
171    D: serde::Deserializer<'de>,
172{
173    let value = serde_json::Value::deserialize(deserializer)?;
174    Ok(match value {
175        serde_json::Value::Array(items) => items
176            .into_iter()
177            .filter_map(|v| serde_json::from_value(v).ok())
178            .collect(),
179        _ => Vec::new(),
180    })
181}
182
183/// Lenient `Option<String>` deserializer: any non-string, non-null JSON shape (a number, an
184/// object, an array) degrades to `None` rather than failing deserialization of the containing
185/// document — the counterpart to [`deserialize_type_list`] for a single-string field.
186fn deserialize_optional_string<'de, D>(
187    deserializer: D,
188) -> std::result::Result<Option<String>, D::Error>
189where
190    D: serde::Deserializer<'de>,
191{
192    let value = serde_json::Value::deserialize(deserializer)?;
193    Ok(match value {
194        serde_json::Value::String(s) => Some(s),
195        _ => None,
196    })
197}
198
199/// Lenient `@type` deserializer: accepts a single string, an array of values (keeping only the
200/// string entries), or degrades any other shape (a bare number, `null`, an object) to an empty
201/// list — never an error, so one malformed resource entry cannot fail the whole document. No
202/// existing lenient-deserialization helper exists elsewhere in this workspace to reuse.
203fn deserialize_type_list<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
204where
205    D: serde::Deserializer<'de>,
206{
207    let value = serde_json::Value::deserialize(deserializer)?;
208    Ok(match value {
209        serde_json::Value::String(s) => vec![s],
210        serde_json::Value::Array(items) => items
211            .into_iter()
212            .filter_map(|v| match v {
213                serde_json::Value::String(s) => Some(s),
214                _ => None,
215            })
216            .collect(),
217        _ => Vec::new(),
218    })
219}
220
221/// Resolved base URLs from the NuGet service index.
222#[derive(Debug, Clone)]
223struct ServiceIndex {
224    /// `PackageBaseAddress/3.0.0` — flat-container version enumeration.
225    package_base_address: String,
226    /// `SearchQueryService/3.5.0` (preferred) or bare `SearchQueryService`. `Option`
227    /// (FR-016): a private V3 feed (e.g. GitHub Packages) may omit this resource entirely —
228    /// `search_typed` degrades to an empty result rather than failing.
229    search_query_service: Option<String>,
230    /// `RegistrationsBaseUrl/3.6.0` (SemVer 2.0.0, preferred), falling back to `3.4.0`
231    /// (SemVer 1) or the bare, undated resource. `Option`, not error-gated: a private V3
232    /// feed (Azure Artifacts, BaGet, GitHub Packages) may omit this resource entirely, in
233    /// which case freshness degrades to `published_at == None` everywhere rather than
234    /// failing `get_versions` for that feed.
235    registrations_base_url: Option<String>,
236}
237
238fn pick_resource(resources: &[ServiceResource], type_preference: &[&str]) -> Option<String> {
239    for want in type_preference {
240        if let Some(r) = resources
241            .iter()
242            .find(|r| r.id.is_some() && r.r#type.iter().any(|t| t == want))
243        {
244            return r
245                .id
246                .as_deref()
247                .map(|id| id.trim_end_matches('/').to_string());
248        }
249    }
250    None
251}
252
253impl ServiceIndex {
254    /// Resolves the service index's resources. For [`NuGetRegistryTier::WorkspaceDeclared`]
255    /// (issue #523, Q3), each picked resource `@id` is validated against `policy` before being
256    /// trusted — `HttpCache::get_cached_workspace`'s own doc states it does not re-check the
257    /// initial request URL's host class, only DNS-resolved addresses and redirect hops, so this
258    /// name-level gate is load-bearing. A rejected `PackageBaseAddress` fails the whole feed
259    /// (fail closed); a rejected `RegistrationsBaseUrl`/`SearchQueryService` degrades to
260    /// absent, matching how a feed that never declared the resource at all is already handled.
261    /// [`NuGetRegistryTier::Public`] never gates (`PolicyGate::Skip` equivalent) — the default
262    /// public registry is trusted unconditionally, matching every other ecosystem's baseline.
263    fn resolve(
264        response: &ServiceIndexResponse,
265        tier: NuGetRegistryTier,
266        policy: &RegistryAccessPolicy,
267    ) -> Result<Self> {
268        let package_base_address =
269            pick_resource(&response.resources, &["PackageBaseAddress/3.0.0"]).ok_or_else(|| {
270                deps_core::DepsError::ParseError {
271                    file_type: "NuGet service index".into(),
272                    source: Box::new(std::io::Error::other(
273                        "missing PackageBaseAddress/3.0.0 resource",
274                    )),
275                }
276            })?;
277        let search_query_service = pick_resource(
278            &response.resources,
279            &["SearchQueryService/3.5.0", "SearchQueryService"],
280        );
281        let registrations_base_url = pick_resource(
282            &response.resources,
283            &[
284                "RegistrationsBaseUrl/3.6.0",
285                "RegistrationsBaseUrl/3.4.0",
286                "RegistrationsBaseUrl",
287            ],
288        );
289
290        if tier != NuGetRegistryTier::WorkspaceDeclared {
291            return Ok(Self {
292                package_base_address,
293                search_query_service,
294                registrations_base_url,
295            });
296        }
297
298        let gate = PolicyGate::Enforce(policy);
299        deps_core::net_policy::validate_index_url(
300            &package_base_address,
301            &package_base_address,
302            "nuget",
303            gate,
304        )
305        .map_err(|e| deps_core::DepsError::ParseError {
306            file_type: "NuGet service index".into(),
307            source: Box::new(std::io::Error::other(format!(
308                "PackageBaseAddress blocked by workspace registry policy: {e}"
309            ))),
310        })?;
311        let search_query_service = search_query_service
312            .filter(|u| deps_core::net_policy::validate_index_url(u, u, "nuget", gate).is_ok());
313        let registrations_base_url = registrations_base_url
314            .filter(|u| deps_core::net_policy::validate_index_url(u, u, "nuget", gate).is_ok());
315
316        Ok(Self {
317            package_base_address,
318            search_query_service,
319            registrations_base_url,
320        })
321    }
322}
323
324/// Registration hive index (`RegistrationsBaseUrl/{id}/index.json`): a list of pages, each
325/// either inline (`items` present) or an external stub (`items` absent, fetched via `id`).
326#[derive(Debug, Deserialize)]
327struct RegistrationIndex {
328    #[serde(default)]
329    items: Vec<RegistrationPage>,
330}
331
332#[derive(Debug, Deserialize)]
333struct RegistrationPage {
334    #[serde(rename = "@id")]
335    id: String,
336    /// Present for an inline page; `None` for an externalized page, which must be fetched
337    /// separately at `id` to obtain the same shape as [`RegistrationPageBody`].
338    #[serde(default)]
339    items: Option<Vec<CatalogEntryWrapper>>,
340}
341
342/// Body of a fetched external registration page — structurally identical to a
343/// [`RegistrationPage`]'s inline `items`.
344#[derive(Debug, Deserialize)]
345struct RegistrationPageBody {
346    #[serde(default)]
347    items: Vec<CatalogEntryWrapper>,
348}
349
350#[derive(Debug, Deserialize)]
351struct CatalogEntryWrapper {
352    #[serde(rename = "catalogEntry")]
353    catalog_entry: CatalogEntry,
354}
355
356#[derive(Debug, Deserialize)]
357struct CatalogEntry {
358    version: String,
359    /// Absent/malformed degrades to no publish time for that version, not an error. The
360    /// unlisted sentinel (`1900-01-01T00:00:00+00:00`) parses successfully but is filtered
361    /// out by [`accumulate_catalog_entries`] rather than rendered as a bogus 126-year age.
362    #[serde(default)]
363    published: Option<String>,
364    /// Explicit unlist flag on current registrations. Absent on registrations predating this
365    /// field, which instead used `published`'s epoch sentinel to signal unlisted — see
366    /// [`accumulate_catalog_entries`] for how both signals are combined (D1, #451).
367    #[serde(default)]
368    listed: Option<bool>,
369}
370
371#[derive(Debug, Deserialize)]
372struct FlatContainerIndex {
373    #[serde(default)]
374    versions: Vec<String>,
375}
376
377#[derive(Debug, Deserialize)]
378struct SearchResponse {
379    #[serde(default)]
380    data: Vec<SearchResultDoc>,
381}
382
383#[derive(Debug, Deserialize)]
384struct SearchResultDoc {
385    id: String,
386    #[serde(default)]
387    version: Option<String>,
388    #[serde(default)]
389    description: Option<String>,
390    #[serde(default, rename = "projectUrl")]
391    project_url: Option<String>,
392}
393
394/// Returns the nuget.org package page URL for `name`.
395///
396/// Display link only, never fetched by this process — unlike [`flat_container_url`]/
397/// [`registration_index_url`] (fetch sinks), so it is deliberately not gated against a
398/// `.`/`..` name (see [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link
399/// scope split, #379).
400pub fn package_url(name: &str) -> String {
401    format!(
402        "https://www.nuget.org/packages/{}",
403        urlencoding::encode(name)
404    )
405}
406
407/// Rejects a dot-segment `name` before it would reach [`flat_container_url`]/
408/// [`registration_index_url`], as `DepsError::PackageNotFound` — mirroring `deps-npm`'s/
409/// `deps-dart`'s identical guard for the same vulnerability class (#341/#349/#365): a `name`
410/// of exactly `.`/`..`, once lowercased and percent-encoded, is followed by a real `/`
411/// separator before `index.json`, so it forms an exact dot-segment that a URL parser's
412/// dot-segment normalization collapses, escaping the intended `PackageBaseAddress`/
413/// `RegistrationsBaseUrl` prefix.
414fn reject_dot_segment(name: &str) -> Result<()> {
415    if deps_core::is_dot_segment(name) {
416        deps_core::lsp_helpers::warn_rejected_value(
417            "is_dot_segment",
418            "NuGet flat-container/registration request URL",
419            name,
420        );
421        return Err(deps_core::DepsError::PackageNotFound {
422            package: name.to_string(),
423            registry: REGISTRY,
424        });
425    }
426    Ok(())
427}
428
429#[derive(Clone)]
430pub struct NuGetRegistry {
431    cache: Arc<HttpCache>,
432    service_index_url: String,
433    service_index: Arc<OnceCell<ServiceIndex>>,
434    tier: NuGetRegistryTier,
435    /// Consulted only when [`Self::tier`] is [`NuGetRegistryTier::WorkspaceDeclared`] — see
436    /// [`ServiceIndex::resolve`]'s per-`@id` validation. A `Public`-tier instance carries a
437    /// default policy that is never consulted (`resolve` skips the gate entirely for that
438    /// tier), so `new`/`with_service_index_url` need not thread a live policy through.
439    policy: Arc<RegistryAccessPolicy>,
440    /// Resolved chain-router clients, keyed by [`NuGetSourceChain::key`]. Only the root
441    /// (`Public`-tier) instance this crate constructs via [`Self::new`] ever registers into
442    /// this or is ever looked up by [`Self::alternate_client`] — a chain-hop leaf's own map is
443    /// always empty by construction, the same invariant `deps-npm`'s/`deps-pypi`'s identical
444    /// field documents. `Arc<DashMap<..>>` (not a bare `DashMap`) since `NuGetRegistry` is
445    /// `Clone` — a bare field would silently fork the map.
446    alternates: Arc<DashMap<String, Arc<Self>>>,
447    /// Resolved, already-constructed hop clients this instance falls through to when it (hop
448    /// 0) misses. Empty for the `Public`-tier root and every leaf hop; populated only on the
449    /// *head* client [`Self::register_chain`] builds for a multi-hop chain. Never looked up by
450    /// string key at fetch time; `Self::get_versions_chained` walks this `Vec` positionally.
451    fallback_chain: Vec<Arc<Self>>,
452    /// This hop's own credential (issue #561), or `None` for an unauthenticated hop and always
453    /// for a `Public`-tier instance. Attached to a request only when [`Self::fetch`]'s C1
454    /// origin-binding rule holds for that specific request — never unconditionally.
455    auth: Option<NuGetAuth>,
456    /// `origin_of(&self.service_index_url)` (or `""` on a parse failure — fail closed: an
457    /// empty string can never equal a real request's origin), computed once at construction.
458    /// The declared source origin C1 pins both comparison sides to (§3.1).
459    declared_origin: String,
460    /// This hop's own [`own_auth_digest`] — the per-request `auth_id` [`Self::fetch`] passes
461    /// to `HttpCache::get_cached_pinned_with_headers`. `0` when [`Self::auth`] is `None`.
462    own_auth_id: u64,
463    /// Meaningful only on a chain's *head* client (the one `root.alternates` maps a
464    /// [`NuGetSourceChain::key`] to) — the chain-wide [`chain_auth_digest`] `register_chain`
465    /// last registered it under, used purely for O(1) rotation detection. `0` on every other
466    /// instance (a fallback hop, or a not-yet-registered client); never consulted by
467    /// [`Self::fetch`].
468    chain_auth_digest: u64,
469}
470
471impl NuGetRegistry {
472    pub fn new(cache: Arc<HttpCache>) -> Self {
473        Self::with_service_index_url(cache, NUGET_ORG_INDEX_URL.to_string())
474    }
475
476    pub(crate) fn with_service_index_url(cache: Arc<HttpCache>, service_index_url: String) -> Self {
477        let declared_origin = origin_of(&service_index_url).unwrap_or_default();
478        Self {
479            cache,
480            service_index_url,
481            service_index: Arc::new(OnceCell::new()),
482            tier: NuGetRegistryTier::Public,
483            policy: Arc::new(RegistryAccessPolicy::default()),
484            alternates: Arc::new(DashMap::new()),
485            fallback_chain: Vec::new(),
486            auth: None,
487            declared_origin,
488            own_auth_id: 0,
489            chain_auth_digest: 0,
490        }
491    }
492
493    /// Creates a [`NuGetRegistry`] client for one resolved `NuGet.Config`-declared feed
494    /// (issue #523) — `WorkspaceDeclared`-tier so it fetches through
495    /// `Self::fetch`'s origin-pinned transport and validates each service-index resource
496    /// `@id` against `policy`.
497    ///
498    /// `fallback_chain` is empty for every call except the *head* client
499    /// [`Self::register_chain`] builds for a multi-hop chain — every other hop is a dead end
500    /// with nothing further to fall through to. Its own `alternates` map starts empty and is
501    /// never populated — only the root ever registers a chain.
502    ///
503    /// Takes `hop: &ResolvedHop`, not a bare `NuGetFeedUrl` (issue #561, FR-016) — carrying the
504    /// hop's own credential and slot identity is unrepresentable to omit, closing the trap
505    /// where `NuGetConfig::resolve_source_for`/`resolved_chains` could independently disagree
506    /// on a hop's credential data (both reach `NuGetSourceChain::chain` exclusively through
507    /// `NuGetConfig::valid_hops`/`hops_for_mapping_keys`, which now build this same type).
508    #[must_use]
509    pub fn with_base(
510        cache: Arc<HttpCache>,
511        hop: &ResolvedHop,
512        policy: Arc<RegistryAccessPolicy>,
513        fallback_chain: Vec<Arc<Self>>,
514    ) -> Self {
515        let declared_origin = origin_of(hop.url.as_str()).unwrap_or_default();
516        let own_auth_id = own_auth_digest(&declared_origin, hop.auth.as_ref());
517        Self {
518            cache,
519            service_index_url: hop.url.as_str().to_string(),
520            service_index: Arc::new(OnceCell::new()),
521            tier: NuGetRegistryTier::WorkspaceDeclared,
522            policy,
523            alternates: Arc::new(DashMap::new()),
524            fallback_chain,
525            auth: hop.auth.clone(),
526            declared_origin,
527            own_auth_id,
528            chain_auth_digest: 0,
529        }
530    }
531
532    /// FR-011: routes every HTTP fetch for a `WorkspaceDeclared`-tier or credentialed source
533    /// through this single decision point (§3.9's four call sites). `trusted_prefix` is the
534    /// caller's already-validated prefix for this specific resource.
535    ///
536    /// Dispatches to:
537    /// 1. The authenticated, origin-pinned transport — iff [`Self::auth`] is `Some` **and**
538    ///    both `url`'s origin and `trusted_prefix`'s origin equal [`Self::declared_origin`]
539    ///    (C1, §3.1). A parse failure on either side of that comparison is a mismatch, never an
540    ///    error — fails closed to arm 2/3, unauthenticated.
541    /// 2. The unauthenticated, origin-pinned workspace transport (#562) — when [`Self::tier`]
542    ///    is [`NuGetRegistryTier::WorkspaceDeclared`] and arm 1 declined.
543    /// 3. Today's public `get_cached_trusted_origin` path — otherwise, byte-identical to spec
544    ///    035.
545    ///
546    /// # Errors
547    ///
548    /// Whatever the underlying `HttpCache` fetch returns.
549    async fn fetch(&self, url: &str, trusted_prefix: &str) -> Result<bytes::Bytes> {
550        let declared = self.declared_origin.as_str();
551        let can_authenticate = origin_of(url).as_deref() == Some(declared)
552            && origin_of(trusted_prefix).as_deref() == Some(declared);
553
554        if let Some(auth) = self.auth.as_ref()
555            && can_authenticate
556        {
557            return self
558                .cache
559                .get_cached_pinned_with_headers(
560                    url,
561                    trusted_prefix,
562                    true,
563                    Some(self.own_auth_id),
564                    &[(reqwest::header::AUTHORIZATION, auth.header_value())],
565                )
566                .await;
567        }
568
569        if self.tier == NuGetRegistryTier::WorkspaceDeclared {
570            return self
571                .cache
572                .get_cached_pinned(url, trusted_prefix, false, None)
573                .await;
574        }
575
576        self.cache
577            .get_cached_trusted_origin(url, trusted_prefix)
578            .await
579    }
580
581    /// Builds the full hop tree for one [`NuGetSourceChain`] and inserts the head into
582    /// `root.alternates` under `chain.key`. Called only from `NuGetEcosystem::parse_manifest`,
583    /// at parse time.
584    ///
585    /// The implicit-public final hop (when `chain.implicit_public_fallback` is set) is a
586    /// **freshly-constructed `Public`-tier client** pointed at `root`'s own
587    /// `service_index_url` (never `Arc::clone(root)`, which would create a
588    /// root→alternates→head→fallback_chain→root reference cycle).
589    ///
590    /// Issue #561 (S3/FR-016): a vacant slot is capacity-capped at `MAX_ALTERNATE_REGISTRIES`
591    /// as before. An **occupied** slot whose currently-registered
592    /// `Self::chain_auth_digest` differs from `chain`'s freshly-computed
593    /// `chain_auth_digest` is **replaced in place** — rebuilt exactly like the vacant arm,
594    /// then inserted over the old `Arc`. This is deliberately **not** gated by the
595    /// capacity check (M4): replacing an already-occupied slot does not grow the map, and
596    /// gating it would silently strand a credential rotation once the cap is hit — reintroducing
597    /// the revoked-PAT staleness bug this replace arm exists to fix. Not LRU: `chain.key` is
598    /// stored as `DependencySource::AlternateRegistry.index` inside already-parsed documents,
599    /// and `Self::alternate_client` is a pure lookup with no re-registration path — evicting a
600    /// key a live document still references would degrade that document's every hover to
601    /// `PackageNotFound` until re-parse.
602    pub fn register_chain(
603        root: &Arc<Self>,
604        chain: &NuGetSourceChain,
605        policy: &Arc<RegistryAccessPolicy>,
606    ) {
607        let Some((first_hop, _)) = chain.hops.split_first() else {
608            return;
609        };
610        let new_digest = chain_auth_digest(&chain.hops);
611
612        // Read before `entry()`: `DashMap::len` read-locks every shard, and `entry()` holds a
613        // write guard on one — checking capacity from inside the `Vacant` arm would
614        // self-deadlock on that shard.
615        let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
616
617        match root.alternates.entry(chain.key.clone()) {
618            dashmap::mapref::entry::Entry::Occupied(mut occupied) => {
619                if occupied.get().chain_auth_digest != new_digest {
620                    let head = Self::build_head(root, chain, first_hop, policy, new_digest);
621                    occupied.insert(Arc::new(head));
622                }
623            }
624            dashmap::mapref::entry::Entry::Vacant(slot) => {
625                if at_capacity {
626                    tracing::warn!(
627                        key = %chain.key,
628                        cap = MAX_ALTERNATE_REGISTRIES,
629                        "NuGet alternate registry cap reached; not registering a new chain"
630                    );
631                    return;
632                }
633                let head = Self::build_head(root, chain, first_hop, policy, new_digest);
634                slot.insert(Arc::new(head));
635            }
636        }
637    }
638
639    /// Constructs the head client (and its full `fallback_chain`) for `chain`, stamping
640    /// `chain_auth_digest` on the head only — factored out of [`Self::register_chain`]'s two
641    /// insertion arms (Vacant and the occupied-with-differing-digest replace arm), which must
642    /// build an identical hop tree.
643    fn build_head(
644        root: &Arc<Self>,
645        chain: &NuGetSourceChain,
646        first_hop: &ResolvedHop,
647        policy: &Arc<RegistryAccessPolicy>,
648        chain_auth_digest: u64,
649    ) -> Self {
650        let mut fallback_chain: Vec<Arc<Self>> = chain.hops[1..]
651            .iter()
652            .map(|hop| {
653                Arc::new(Self::with_base(
654                    Arc::clone(&root.cache),
655                    hop,
656                    Arc::clone(policy),
657                    Vec::new(),
658                ))
659            })
660            .collect();
661        if chain.implicit_public_fallback {
662            fallback_chain.push(Arc::new(Self::with_service_index_url(
663                Arc::clone(&root.cache),
664                root.service_index_url.clone(),
665            )));
666        }
667
668        let mut head = Self::with_base(
669            Arc::clone(&root.cache),
670            first_hop,
671            Arc::clone(policy),
672            fallback_chain,
673        );
674        head.chain_auth_digest = chain_auth_digest;
675        head
676    }
677
678    /// The registered client for `index` (a [`NuGetSourceChain::key`]), if any — read-only,
679    /// performs no registration. Intentionally only ever meaningful on the **root**: a
680    /// chain-hop leaf's own `alternates` map is always empty by construction.
681    #[must_use]
682    pub fn alternate_client(&self, index: &str) -> Option<Arc<Self>> {
683        self.alternates.get(index).map(|entry| Arc::clone(&entry))
684    }
685
686    /// FR-005/FR-007: tries `self` (hop 0) first, then each already-resolved
687    /// [`Self::fallback_chain`] entry in order. Mirrors `deps-pypi`'s
688    /// `get_versions_chained`'s three-way failure taxonomy: `Ok(versions)` non-empty is
689    /// terminal success; a not-found response (`DepsError::PackageNotFound`, or — unlike
690    /// pypi, which converts this earlier — a raw `HttpStatus{404}` from a real NuGet
691    /// flat-container response for an unknown package id, both covered by
692    /// [`DepsError::is_not_found`]) or an empty `Ok` continues to the next hop; any other
693    /// `Err` (5xx, timeout, network error) is terminal, reported as
694    /// [`DepsError::ChainResolutionHalted`] rather than the underlying error unchanged — never
695    /// falling back to api.nuget.org or the next configured feed, which would leak the
696    /// package's name past a merely-unreachable private feed.
697    async fn get_versions_chained(&self, name: &str) -> Result<Vec<NuGetVersion>> {
698        let mut last_miss: Result<Vec<NuGetVersion>> = Err(DepsError::PackageNotFound {
699            package: name.to_string(),
700            registry: REGISTRY,
701        });
702
703        for hop in std::iter::once(self).chain(self.fallback_chain.iter().map(Arc::as_ref)) {
704            match hop.get_versions_typed(name).await {
705                Ok(versions) if !versions.is_empty() => return Ok(versions),
706                Ok(empty) => last_miss = Ok(empty),
707                Err(error) if error.is_not_found() => {
708                    last_miss = Err(DepsError::PackageNotFound {
709                        package: name.to_string(),
710                        registry: REGISTRY,
711                    });
712                }
713                Err(other) => {
714                    tracing::warn!(
715                        package = name,
716                        error = %other,
717                        "NuGet alternate-feed chain resolution halted on a transport error \
718                         — not falling back to api.nuget.org or the next configured feed"
719                    );
720                    return Err(DepsError::ChainResolutionHalted);
721                }
722            }
723        }
724
725        last_miss
726    }
727
728    /// Resolves the service index once per process, retrying on the next call if
729    /// resolution failed. `get_or_try_init` (not `get_or_init`) is load-bearing: it leaves
730    /// the cell empty on `Err` so a transient failure does not permanently poison lookups,
731    /// and it serializes concurrent initializers so a cold start with many dependencies
732    /// does not stampede the index endpoint.
733    async fn service_index(&self) -> Result<&ServiceIndex> {
734        self.service_index
735            .get_or_try_init(|| async {
736                // FR-011, §3.9: `trusted_prefix` is the declared source origin itself — no
737                // resource has been resolved yet to derive a narrower one from.
738                let data = self
739                    .fetch(&self.service_index_url, &self.declared_origin)
740                    .await?;
741                let response: ServiceIndexResponse = deps_core::parse_json_checked(&data)?;
742                ServiceIndex::resolve(&response, self.tier, &self.policy)
743            })
744            .await
745    }
746
747    /// Fetches all available versions for `name` from the flat-container endpoint,
748    /// sorted newest-first.
749    ///
750    /// Delegates to [`Self::get_versions_typed_with`] with freshness disabled so the two
751    /// paths cannot drift apart.
752    ///
753    /// # Errors
754    ///
755    /// Returns an error if the service index cannot be resolved or the flat-container
756    /// request fails.
757    pub async fn get_versions_typed(&self, name: &str) -> Result<Vec<NuGetVersion>> {
758        self.get_versions_typed_with(name, false).await
759    }
760
761    /// Same as [`Self::get_versions_typed`], but attaches [`NuGetVersion::published_at`]
762    /// from the registration hive when `freshness_enabled` and the feed exposes a
763    /// `RegistrationsBaseUrl` resource.
764    ///
765    /// The flat-container fetch (version list) and the registration-index fetch (for
766    /// publish times) are independent once the service index is resolved, so they run
767    /// concurrently via `tokio::join!` rather than sequentially — this matters because
768    /// `complete_versions_generic` is a per-keystroke completion path and `HttpCache` has
769    /// no TTL, so every call revalidates over the network.
770    ///
771    /// A registration-index fetch or parse failure degrades to no publish times, never to
772    /// an error: the version list itself must be unaffected by a listing problem.
773    ///
774    /// Both fetches go through `HttpCache::get_cached_trusted_origin`, scoped to the
775    /// resolved `PackageBaseAddress`/`RegistrationsBaseUrl` respectively — not just the
776    /// external registration pages `publish_times_from_index` walks. Every *redirect* this
777    /// method's requests can follow (index, flat container, registration index, and — down
778    /// in `publish_times_from_index` — the page `@id`s the index itself supplies) is
779    /// checked against its trusted prefix, since `get_cached_trusted_origin` selects a
780    /// redirect-policy-scoped client — it does not itself validate the *initial* request
781    /// URL. That initial URL's safety instead comes from `reject_dot_segment`, which gates
782    /// `name` before `flat_container_url`/`registration_index_url` are ever called (#365
783    /// M5).
784    ///
785    /// # Errors
786    ///
787    /// Returns an error if the service index cannot be resolved or the flat-container
788    /// request fails.
789    pub async fn get_versions_typed_with(
790        &self,
791        name: &str,
792        freshness_enabled: bool,
793    ) -> Result<Vec<NuGetVersion>> {
794        reject_dot_segment(name)?;
795        let index = self.service_index().await?;
796        let flat_url = flat_container_url(&index.package_base_address, name);
797        let flat_trusted_prefix = format!("{}/", index.package_base_address);
798        let registration_base = if freshness_enabled {
799            index.registrations_base_url.clone()
800        } else {
801            None
802        };
803
804        // Issue #561/#562, FR-012: both tiers route through `Self::fetch` (§3.9) — for a
805        // `WorkspaceDeclared`-tier or credentialed source this is the origin-pinned,
806        // connect-address-guarded transport (closing the residual risk the prior early return
807        // here used to document); registration-hive enrichment (publish times, hover-only
808        // unlisted markers) is no longer skipped for alternate feeds.
809        if let Some(base) = registration_base {
810            let registration_url = registration_index_url(&base, name);
811            let registration_trusted_prefix = format!("{base}/");
812            let (flat_result, registration_result) = tokio::join!(
813                self.fetch(&flat_url, &flat_trusted_prefix),
814                self.fetch(&registration_url, &registration_trusted_prefix),
815            );
816            let mut versions = parse_flat_container(&flat_result?)?;
817            match registration_result {
818                Ok(registration_body) => {
819                    let enrichment = self
820                        .registration_enrichment_from_index(
821                            &registration_body,
822                            &registration_trusted_prefix,
823                        )
824                        .await;
825                    attach_publish_times(&mut versions, &enrichment.published);
826                }
827                Err(e) => {
828                    tracing::debug!(package = %name, error = %e, "registration index fetch failed, publish times unavailable");
829                }
830            }
831            Ok(versions)
832        } else {
833            let data = self.fetch(&flat_url, &flat_trusted_prefix).await?;
834            parse_flat_container(&data)
835        }
836    }
837
838    /// Walks the registration hive backwards from the last page, collecting `published`
839    /// dates and unlisted markers until at least [`HOVER_RECENT_VERSIONS`] entries have been
840    /// examined.
841    ///
842    /// Pages are ordered ascending by version (mirroring the flat container's descending
843    /// order in reverse), so the tail of `index.items` holds the most recent versions —
844    /// exactly what hover renders. Terminates on whichever comes first: enough entries
845    /// collected, the index exhausted (packages with fewer total versions than the target),
846    /// or [`MAX_EXTERNAL_PAGE_FETCHES`] external pages fetched (a safety bound; real
847    /// packages need at most one, per the live measurements this plan is based on).
848    ///
849    /// Never fails the caller: a malformed index, an unreachable page, or a page `@id`
850    /// outside `trusted_prefix` all degrade to fewer (or zero) entries in the returned
851    /// [`RegistrationEnrichment`].
852    async fn registration_enrichment_from_index(
853        &self,
854        index_body: &[u8],
855        trusted_prefix: &str,
856    ) -> RegistrationEnrichment {
857        let mut enrichment = RegistrationEnrichment::default();
858        let Ok(index) = deps_core::parse_json_checked::<RegistrationIndex>(index_body) else {
859            return enrichment;
860        };
861
862        let mut collected = 0usize;
863        let mut external_fetches = 0usize;
864
865        for page in index.items.iter().rev() {
866            if collected >= HOVER_RECENT_VERSIONS {
867                break;
868            }
869
870            match &page.items {
871                Some(inline) => {
872                    accumulate_catalog_entries(&mut enrichment, &mut collected, inline);
873                }
874                None => {
875                    // A page `@id` outside the resolved registration base is skipped, not
876                    // trusted — the feed chooses `@id` values. `Self::fetch`'s underlying
877                    // transport additionally stops any redirect that would otherwise escape
878                    // `trusted_prefix` after this initial check passes (S2/M2).
879                    if !page.id.starts_with(trusted_prefix) {
880                        continue;
881                    }
882                    if external_fetches >= MAX_EXTERNAL_PAGE_FETCHES {
883                        break;
884                    }
885                    external_fetches += 1;
886                    let Ok(body) = self.fetch(&page.id, trusted_prefix).await else {
887                        continue;
888                    };
889                    let Ok(parsed) = deps_core::parse_json_checked::<RegistrationPageBody>(&body)
890                    else {
891                        continue;
892                    };
893                    accumulate_catalog_entries(&mut enrichment, &mut collected, &parsed.items);
894                }
895            }
896        }
897
898        enrichment
899    }
900
901    /// Hover-only enrichment (D1, #451): returns the subset of `name`'s recent versions
902    /// (the same [`HOVER_RECENT_VERSIONS`]-bounded window `registration_enrichment_from_index`
903    /// walks) that the registry currently reports as unlisted.
904    ///
905    /// Deliberately **not** wired into [`Self::get_versions_typed_with`]/[`NuGetVersion`]:
906    /// that shared path backs `get_versions_with`, which both hover *and*
907    /// `complete_versions_generic` (completion) call, and its results also feed the
908    /// per-document version cache that inlay hints and diagnostics render from. Threading
909    /// `listed` through [`deps_core::Version::removal_status`] there would make an unlisted
910    /// version silently vanish from completion suggestions too
911    /// (`prepare_version_display_items` filters on `removal_status().blocks_resolution()`
912    /// unconditionally) — the wrong tradeoff the spec calls out. This method is instead
913    /// called only from [`crate::ecosystem::NuGetEcosystem`]'s `generate_hover` override,
914    /// so only a hover request ever pays for it.
915    ///
916    /// Degrades to an empty set (never an error) on any fetch/parse failure, or when the
917    /// feed has no `RegistrationsBaseUrl` resource at all — hover must still render the
918    /// ordinary version list rather than disappear because this optional decoration failed.
919    ///
920    /// # Errors
921    ///
922    /// Returns an error only if `name` is rejected as a dot-segment or the service index
923    /// itself cannot be resolved — both of which also fail the hover response's main
924    /// version fetch, so this never surfaces a *distinct* failure mode to the caller.
925    pub async fn unlisted_versions_for_hover(&self, name: &str) -> Result<HashSet<String>> {
926        // Issue #562, FR-012: registration-hive enrichment is no longer skipped for
927        // `WorkspaceDeclared`-tier feeds — routed through `Self::fetch` (§3.9) like every
928        // other site, closing spec 035's NFR-003(3) residual risk.
929        reject_dot_segment(name)?;
930        let index = self.service_index().await?;
931        let Some(base) = index.registrations_base_url.clone() else {
932            return Ok(HashSet::new());
933        };
934        let registration_url = registration_index_url(&base, name);
935        let trusted_prefix = format!("{base}/");
936        let Ok(body) = self.fetch(&registration_url, &trusted_prefix).await else {
937            return Ok(HashSet::new());
938        };
939        Ok(self
940            .registration_enrichment_from_index(&body, &trusted_prefix)
941            .await
942            .unlisted)
943    }
944
945    /// Finds the highest version of `name` matching `req` (exact pin, interval notation,
946    /// or floating pattern). Prerelease versions are excluded unless `req` itself is
947    /// prerelease-bearing.
948    ///
949    /// # Errors
950    ///
951    /// Returns an error if the service index cannot be resolved or the flat-container
952    /// request fails.
953    pub async fn get_latest_matching_typed(
954        &self,
955        name: &str,
956        req: &str,
957    ) -> Result<Option<NuGetVersion>> {
958        let versions = self.get_versions_typed(name).await?;
959        Ok(pick_latest_matching(versions, req))
960    }
961
962    /// Searches the NuGet `SearchQueryService` for `query`, returning up to `limit` results.
963    ///
964    /// # Errors
965    ///
966    /// Returns an error if the service index cannot be resolved or the search request fails.
967    pub async fn search_typed(&self, query: &str, limit: usize) -> Result<Vec<PackageInfo>> {
968        let index = self.service_index().await?;
969        // FR-016 (spec 035): a feed may omit `SearchQueryService` entirely (e.g. GitHub
970        // Packages).
971        let Some(search_base) = index.search_query_service.as_deref() else {
972            return Ok(Vec::new());
973        };
974        let url = search_url(search_base, query, limit);
975        // §3.9: the `SearchQueryService`'s own *origin*, not `{search_base}/` — `search_url`
976        // appends a `?q=...` query string to `search_base`, so `{search_base}/` would not be a
977        // prefix of `url` at all. Falls back to the un-origin-narrowed `search_base` itself on
978        // an (unreachable in practice) parse failure — still a specific, safe pin.
979        let trusted_prefix = origin_of(search_base).unwrap_or_else(|| search_base.to_string());
980
981        let data = self.fetch(&url, &trusted_prefix).await?;
982        parse_search_response(&data, limit)
983    }
984}
985
986/// Builds the flat-container version-enumeration URL for `name`.
987///
988/// The package id is lowercased (NuGet ids are case-insensitive and every V3 API path
989/// segment is lowercased) and percent-encoded before being interpolated into the path.
990/// Encoding is load-bearing, not cosmetic: an unencoded id lets a crafted
991/// `PackageReference Include="..."` value inject path segments (`../../etc/passwd`
992/// collapses dot-segments) or truncate the path at `#`/`?`/control characters, making
993/// deps-lsp silently resolve and display a *different* real package's version data under
994/// an attacker-chosen name.
995pub fn flat_container_url(base: &str, name: &str) -> String {
996    let lower = name.to_lowercase();
997    format!("{base}/{}/index.json", urlencoding::encode(&lower))
998}
999
1000/// Builds the registration-hive index URL for `name`. Same lowercasing/encoding rationale
1001/// as [`flat_container_url`].
1002pub fn registration_index_url(base: &str, name: &str) -> String {
1003    let lower = name.to_lowercase();
1004    format!("{base}/{}/index.json", urlencoding::encode(&lower))
1005}
1006
1007/// Attaches `published_at` to each version whose string matches an entry in `times`.
1008///
1009/// A version present in `versions` but absent from `times` (or vice versa) is not an
1010/// error: it simply keeps/never gets a `published_at`. Order is untouched.
1011fn attach_publish_times(versions: &mut [NuGetVersion], times: &HashMap<String, PublishTime>) {
1012    for v in versions {
1013        v.published_at = times.get(v.version.as_str()).copied();
1014    }
1015}
1016
1017/// Per-package result of walking a slice of the registration hive: publish timestamps
1018/// keyed by version string, and the subset of examined versions the registry reports as
1019/// unlisted. See [`NuGetRegistry::registration_enrichment_from_index`].
1020#[derive(Debug, Default)]
1021struct RegistrationEnrichment {
1022    published: HashMap<String, PublishTime>,
1023    unlisted: HashSet<String>,
1024}
1025
1026/// Extracts publish times and unlisted markers from a page's catalog entries into
1027/// `enrichment`. A version is unlisted when the entry carries an explicit `"listed": false`,
1028/// or — for registrations that predate that field — when `published` is the unlisted
1029/// sentinel epoch (`<= 1970-01-01T00:00:00Z`); `published` itself still filters that same
1030/// sentinel out (and any unparseable timestamp) so it's never rendered as a bogus 126-year
1031/// age. Advances `collected` by the entry count regardless of what was extracted, since the
1032/// walk target in [`NuGetRegistry::registration_enrichment_from_index`] is "entries
1033/// examined", not "entries successfully timed".
1034fn accumulate_catalog_entries(
1035    enrichment: &mut RegistrationEnrichment,
1036    collected: &mut usize,
1037    entries: &[CatalogEntryWrapper],
1038) {
1039    for entry in entries {
1040        let ce = &entry.catalog_entry;
1041        let parsed_published = ce.published.as_deref().and_then(PublishTime::parse_rfc3339);
1042        let is_sentinel = parsed_published.is_some_and(|t| t.as_unix_secs() <= 0);
1043
1044        if let Some(published) = parsed_published.filter(|t| t.as_unix_secs() > 0) {
1045            enrichment.published.insert(ce.version.clone(), published);
1046        }
1047
1048        let unlisted = ce.listed == Some(false) || (ce.listed.is_none() && is_sentinel);
1049        if unlisted {
1050            enrichment.unlisted.insert(ce.version.clone());
1051        }
1052
1053        *collected += 1;
1054    }
1055}
1056
1057/// Builds the `SearchQueryService` URL for `query`, limited to `limit` results.
1058///
1059/// `semVerLevel=2.0.0` is mandatory (spec §1) — omitting it silently hides every package
1060/// whose latest version uses a dotted prerelease label.
1061pub fn search_url(base: &str, query: &str, limit: usize) -> String {
1062    format!(
1063        "{base}?q={}&take={limit}&prerelease=false&semVerLevel=2.0.0",
1064        urlencoding::encode(query),
1065    )
1066}
1067
1068/// Parses a flat-container `index.json` response into descending-sorted versions.
1069pub fn parse_flat_container(data: &[u8]) -> Result<Vec<NuGetVersion>> {
1070    let parsed: FlatContainerIndex = deps_core::parse_json_checked(data)?;
1071
1072    let mut versions = parsed.versions;
1073    // Sort locally, descending: the flat container's observed ascending order is not a
1074    // documented contract, so relying on `.reverse()` would silently corrupt "latest" if
1075    // the CDN/backend ever changes it (rev2, S3).
1076    versions.sort_by(|a, b| compare_versions(b, a));
1077
1078    Ok(versions
1079        .into_iter()
1080        .map(|version| NuGetVersion {
1081            version: version.into(),
1082            published_at: None,
1083        })
1084        .collect())
1085}
1086
1087/// Picks the highest version matching `req` from an already-fetched, descending-sorted
1088/// version list. `req` is treated as `"*"` when empty. Prerelease versions are excluded
1089/// unless `req` itself is prerelease-bearing (contains `-`) or is a floating pattern whose
1090/// own prerelease inclusion is handled by `crate::version::resolve_float`.
1091///
1092/// Under an existence-check wildcard (`req` trimmed is `""` or `"*"`), a normal match that
1093/// comes up empty falls back to [`deps_core::select_latest_for_existence`] so a
1094/// prerelease-only package still reports its newest version instead of `None` — see that
1095/// function's doc comment for the 3-rung contract. Non-wildcard requirements (e.g.
1096/// `"*-*"`, `"1.*"`) are unaffected: this fallback never fires for them.
1097fn pick_latest_matching(versions: Vec<NuGetVersion>, req: &str) -> Option<NuGetVersion> {
1098    if versions.is_empty() {
1099        return None;
1100    }
1101
1102    let req = if req.is_empty() { "*" } else { req };
1103
1104    let matched = if req.contains('*') {
1105        let strings: Vec<String> = versions.iter().map(|v| v.version.to_string()).collect();
1106        crate::version::resolve_float(&strings, req).map(|v| NuGetVersion {
1107            version: v.into(),
1108            published_at: None,
1109        })
1110    } else {
1111        let req_is_prerelease_bearing = req.contains('-');
1112        versions
1113            .iter()
1114            .find(|v| {
1115                crate::version::satisfies(v.version.as_str(), req)
1116                    && (req_is_prerelease_bearing
1117                        || !crate::version::is_prerelease(v.version.as_str()))
1118            })
1119            .cloned()
1120    };
1121
1122    matched.or_else(|| {
1123        if deps_core::is_existence_wildcard_str(req) {
1124            let idx = deps_core::select_latest_for_existence(&versions, |v| {
1125                v as &dyn deps_core::Version
1126            })?;
1127            Some(versions[idx].clone())
1128        } else {
1129            None
1130        }
1131    })
1132}
1133
1134fn parse_search_response(data: &[u8], limit: usize) -> Result<Vec<PackageInfo>> {
1135    let response: SearchResponse = deps_core::parse_json_checked(data)?;
1136
1137    Ok(response
1138        .data
1139        .into_iter()
1140        .take(limit)
1141        .map(|d| PackageInfo {
1142            name: d.id.into(),
1143            description: d.description,
1144            repository: d.project_url,
1145            documentation: None,
1146            latest_version: d.version.unwrap_or_default().into(),
1147        })
1148        .collect())
1149}
1150
1151impl deps_core::Registry for NuGetRegistry {
1152    fn get_versions<'a>(
1153        &'a self,
1154        name: &'a deps_core::PackageName,
1155    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1156        Box::pin(async move {
1157            let versions = self.get_versions_typed(name.as_str()).await?;
1158            Ok(versions
1159                .into_iter()
1160                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1161                .collect())
1162        })
1163    }
1164
1165    fn get_versions_with<'a>(
1166        &'a self,
1167        name: &'a deps_core::PackageName,
1168        freshness: deps_core::FreshnessSettings,
1169    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1170        Box::pin(async move {
1171            let versions = self
1172                .get_versions_typed_with(name.as_str(), freshness.enabled)
1173                .await?;
1174            Ok(versions
1175                .into_iter()
1176                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1177                .collect())
1178        })
1179    }
1180
1181    fn get_latest_matching<'a>(
1182        &'a self,
1183        name: &'a deps_core::PackageName,
1184        req: &'a deps_core::VersionReq,
1185    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
1186        Box::pin(async move {
1187            let version = self
1188                .get_latest_matching_typed(name.as_str(), req.as_str())
1189                .await?;
1190            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
1191        })
1192    }
1193
1194    /// Dispatches by `source` (issue #523): an `AlternateRegistry` whose index has a
1195    /// registered client routes through `Self::get_versions_chained`; one with **no**
1196    /// registered client is `PackageNotFound`, never a fall back to api.nuget.org (falling
1197    /// back would send a private package name to the public registry — the dependency
1198    /// confusion leak this feature closes). Every other source keeps today's public-registry
1199    /// path unchanged.
1200    fn get_versions_from<'a>(
1201        &'a self,
1202        name: &'a deps_core::PackageName,
1203        source: &'a DependencySource,
1204        freshness: FreshnessSettings,
1205    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1206        Box::pin(async move {
1207            match source {
1208                DependencySource::AlternateRegistry { index, .. } => {
1209                    match self.alternate_client(index) {
1210                        Some(client) => {
1211                            let versions = client.get_versions_chained(name.as_str()).await?;
1212                            Ok(versions
1213                                .into_iter()
1214                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1215                                .collect())
1216                        }
1217                        None => Err(DepsError::PackageNotFound {
1218                            package: name.to_string(),
1219                            registry: "alternate registry (not registered)",
1220                        }),
1221                    }
1222                }
1223                _ => deps_core::Registry::get_versions_with(self, name, freshness).await,
1224            }
1225        })
1226    }
1227
1228    /// `get_versions_from`'s `get_latest_matching`-shaped counterpart — same dispatch, same
1229    /// "never fall back to api.nuget.org for an unregistered `AlternateRegistry`" invariant.
1230    /// The winning hop (first hop with a non-empty version list, chosen once by
1231    /// `Self::get_versions_chained`) is where `req` is matched — a hop with no match is
1232    /// terminal (`Ok(None)`), not a trigger to search later hops for a "better" match.
1233    fn get_latest_matching_from<'a>(
1234        &'a self,
1235        name: &'a deps_core::PackageName,
1236        source: &'a DependencySource,
1237        req: &'a deps_core::VersionReq,
1238        _minimum_stability: Option<&'a str>,
1239    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
1240        Box::pin(async move {
1241            match source {
1242                DependencySource::AlternateRegistry { index, .. } => {
1243                    match self.alternate_client(index) {
1244                        Some(client) => {
1245                            let versions: Vec<Box<dyn deps_core::Version>> = client
1246                                .get_versions_chained(name.as_str())
1247                                .await?
1248                                .into_iter()
1249                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1250                                .collect();
1251                            let idx = client.select_latest_matching(&versions, req);
1252                            Ok(idx.and_then(|i| versions.into_iter().nth(i)))
1253                        }
1254                        None => Err(DepsError::PackageNotFound {
1255                            package: name.to_string(),
1256                            registry: "alternate registry (not registered)",
1257                        }),
1258                    }
1259                }
1260                _ => {
1261                    let version = self
1262                        .get_latest_matching_typed(name.as_str(), req.as_str())
1263                        .await?;
1264                    Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
1265                }
1266            }
1267        })
1268    }
1269
1270    fn search<'a>(
1271        &'a self,
1272        query: &'a str,
1273        limit: usize,
1274    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
1275        Box::pin(async move {
1276            let results = self.search_typed(query, limit).await?;
1277            Ok(results
1278                .into_iter()
1279                .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
1280                .collect())
1281        })
1282    }
1283
1284    fn select_latest_matching(
1285        &self,
1286        versions: &[Box<dyn deps_core::Version>],
1287        req: &deps_core::VersionReq,
1288    ) -> Option<usize> {
1289        if versions.is_empty() {
1290            return None;
1291        }
1292        let req_str = req.as_str();
1293        let req_str = if req_str.is_empty() { "*" } else { req_str };
1294
1295        let matched = if req_str.contains('*') {
1296            let strings: Vec<String> = versions
1297                .iter()
1298                .map(|v| v.version_string().to_string())
1299                .collect();
1300            crate::version::resolve_float(&strings, req_str)
1301                .and_then(|matched| strings.iter().position(|s| s == matched))
1302        } else {
1303            let req_is_prerelease_bearing = req_str.contains('-');
1304            versions.iter().position(|v| {
1305                crate::version::satisfies(v.version_string().as_str(), req_str)
1306                    && (req_is_prerelease_bearing
1307                        || !crate::version::is_prerelease(v.version_string().as_str()))
1308            })
1309        };
1310
1311        matched.or_else(|| {
1312            deps_core::is_existence_wildcard_str(req_str)
1313                .then(|| deps_core::select_latest_for_existence(versions, |v| v.as_ref()))
1314                .flatten()
1315        })
1316    }
1317
1318    // `Version::removal_status` uses the trait's default `Available` (no override in
1319    // `types.rs`) — `get_versions`/`get_latest_matching` (this trait's freshness-blind
1320    // entry points, per `reports_yanked`'s own contract) always resolve through the flat
1321    // container, which carries no `listed` flag, so `removal_status()` can never reflect
1322    // real registry data there (#233). `Self::unlisted_versions_for_hover` (D1, #451) is a
1323    // separate, hover-only enrichment that deliberately bypasses `Version` entirely —
1324    // see its doc comment — so it does not change this answer.
1325    fn reports_yanked(&self) -> bool {
1326        false
1327    }
1328
1329    fn as_any(&self) -> &dyn Any {
1330        self
1331    }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336    use super::*;
1337    use crate::config::NuGetFeedUrl;
1338    use std::assert_matches;
1339
1340    fn service_index_body(package_base_address: &str, search_query_service: &str) -> String {
1341        format!(
1342            r#"{{
1343                "version": "3.0.0",
1344                "resources": [
1345                    {{"@id": "{package_base_address}", "@type": "PackageBaseAddress/3.0.0"}},
1346                    {{"@id": "{search_query_service}", "@type": "SearchQueryService/3.5.0"}}
1347                ]
1348            }}"#
1349        )
1350    }
1351
1352    fn service_index_body_with_registrations(
1353        package_base_address: &str,
1354        search_query_service: &str,
1355        registrations_base_url: &str,
1356    ) -> String {
1357        format!(
1358            r#"{{
1359                "version": "3.0.0",
1360                "resources": [
1361                    {{"@id": "{package_base_address}", "@type": "PackageBaseAddress/3.0.0"}},
1362                    {{"@id": "{search_query_service}", "@type": "SearchQueryService/3.5.0"}},
1363                    {{"@id": "{registrations_base_url}", "@type": "RegistrationsBaseUrl/3.6.0"}}
1364                ]
1365            }}"#
1366        )
1367    }
1368
1369    #[test]
1370    fn test_package_url() {
1371        assert_eq!(
1372            package_url("Newtonsoft.Json"),
1373            "https://www.nuget.org/packages/Newtonsoft.Json"
1374        );
1375    }
1376
1377    #[test]
1378    fn test_package_url_encodes_malicious_name() {
1379        let url = package_url("evil](https://evil.example)[pkg");
1380        assert!(!url.contains('('));
1381        assert!(!url.contains(')'));
1382        assert!(!url.contains('['));
1383        assert!(!url.contains(']'));
1384    }
1385
1386    #[test]
1387    fn test_package_url_encodes_newline_autolink_and_percent() {
1388        let url = package_url("evil\n<https://evil%zz.example>");
1389        assert!(!url.contains('\n'));
1390        assert!(!url.contains('<'));
1391        assert!(!url.contains('>'));
1392        assert!(url.contains("%25"));
1393    }
1394
1395    #[test]
1396    fn test_package_url_empty_name() {
1397        assert_eq!(package_url(""), "https://www.nuget.org/packages/");
1398    }
1399
1400    #[test]
1401    fn test_flat_container_url_lowercases_and_encodes() {
1402        assert_eq!(
1403            flat_container_url("https://api.nuget.org/v3-flatcontainer", "Newtonsoft.Json"),
1404            "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.json"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_flat_container_url_encodes_path_traversal_attempt() {
1410        // A crafted `Include="../../../../etc/passwd"` must not produce raw dot-segments
1411        // that a URL-parsing layer could collapse across path boundaries.
1412        let url = flat_container_url(
1413            "https://api.nuget.org/v3-flatcontainer",
1414            "../../../../etc/passwd",
1415        );
1416        assert_eq!(
1417            url,
1418            "https://api.nuget.org/v3-flatcontainer/..%2F..%2F..%2F..%2Fetc%2Fpasswd/index.json"
1419        );
1420        assert!(
1421            !url.contains("/../"),
1422            "raw path traversal segment leaked into URL: {url}"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_flat_container_url_encodes_fragment_and_query_delimiters() {
1428        // '#'/'?' must not be able to truncate the path and silently resolve as a
1429        // different, shorter package name.
1430        assert_eq!(
1431            flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo#x"),
1432            "https://api.nuget.org/v3-flatcontainer/foo%23x/index.json"
1433        );
1434        assert_eq!(
1435            flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo?x=1"),
1436            "https://api.nuget.org/v3-flatcontainer/foo%3Fx%3D1/index.json"
1437        );
1438    }
1439
1440    #[test]
1441    fn test_flat_container_url_encodes_control_characters() {
1442        let url = flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo\tBar");
1443        assert_eq!(
1444            url,
1445            "https://api.nuget.org/v3-flatcontainer/foo%09bar/index.json"
1446        );
1447    }
1448
1449    #[test]
1450    fn test_reject_dot_segment_rejects_bare_dot_dot() {
1451        assert!(reject_dot_segment("..").is_err());
1452    }
1453
1454    #[test]
1455    fn test_reject_dot_segment_rejects_bare_dot() {
1456        assert!(reject_dot_segment(".").is_err());
1457    }
1458
1459    #[test]
1460    fn test_reject_dot_segment_accepts_normal_names() {
1461        assert!(reject_dot_segment("Newtonsoft.Json").is_ok());
1462    }
1463
1464    /// Demonstrates the vulnerability `reject_dot_segment` exists to prevent:
1465    /// `flat_container_url` alone (with no caller-side guard) builds a URL that, once
1466    /// parsed, has already lost the `v3-flatcontainer` path component.
1467    #[test]
1468    fn test_flat_container_url_bare_dot_dot_normalizes_above_base_prefix() {
1469        let url = flat_container_url("https://api.nuget.org/v3-flatcontainer", "..");
1470        let parsed = url::Url::parse(&url).unwrap();
1471        assert_eq!(
1472            parsed.path(),
1473            "/index.json",
1474            "parsed path: {}",
1475            parsed.path()
1476        );
1477    }
1478
1479    /// #365 regression sweep: exercises the real production `reject_dot_segment` gate and
1480    /// `flat_container_url` sink together against the shared adversarial input set,
1481    /// guarding against a 6th recurrence of the dot-segment defect class in this crate.
1482    #[test]
1483    fn test_flat_container_url_dot_segment_sweep() {
1484        deps_core::test_util::assert_dot_segment_gated_or_contained(
1485            |seg| {
1486                reject_dot_segment(seg)
1487                    .ok()
1488                    .map(|()| flat_container_url("https://api.nuget.org/v3-flatcontainer", seg))
1489            },
1490            "api.nuget.org",
1491            "/v3-flatcontainer/",
1492        );
1493    }
1494
1495    #[test]
1496    fn test_search_url_includes_mandatory_semver_level_and_prerelease_false() {
1497        let url = search_url("https://azuresearch-usnc.nuget.org/query", "json", 10);
1498        assert_eq!(
1499            url,
1500            "https://azuresearch-usnc.nuget.org/query?q=json&take=10&prerelease=false&semVerLevel=2.0.0"
1501        );
1502    }
1503
1504    #[test]
1505    fn test_search_url_encodes_query() {
1506        let url = search_url("https://azuresearch-usnc.nuget.org/query", "a b&c", 5);
1507        assert!(url.contains("q=a%20b%26c"), "query not encoded: {url}");
1508    }
1509
1510    fn public_policy() -> RegistryAccessPolicy {
1511        RegistryAccessPolicy::default()
1512    }
1513
1514    #[test]
1515    fn test_service_index_resolve_success() {
1516        let response: ServiceIndexResponse = serde_json::from_str(&service_index_body(
1517            "https://api.nuget.org/v3-flatcontainer/",
1518            "https://azuresearch-usnc.nuget.org/query",
1519        ))
1520        .unwrap();
1521        let index =
1522            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1523        assert_eq!(
1524            index.package_base_address,
1525            "https://api.nuget.org/v3-flatcontainer"
1526        );
1527        assert_eq!(
1528            index.search_query_service.as_deref(),
1529            Some("https://azuresearch-usnc.nuget.org/query")
1530        );
1531    }
1532
1533    #[test]
1534    fn test_service_index_resolve_missing_resource_errors() {
1535        let response: ServiceIndexResponse = serde_json::from_str(
1536            r#"{"version": "3.0.0", "resources": [{"@id": "https://x", "@type": "SomeOtherType"}]}"#,
1537        )
1538        .unwrap();
1539        assert!(
1540            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).is_err()
1541        );
1542    }
1543
1544    #[test]
1545    fn test_service_index_search_query_service_fallback() {
1546        let response: ServiceIndexResponse = serde_json::from_str(
1547            r#"{"version": "3.0.0", "resources": [
1548                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1549                {"@id": "https://search/", "@type": "SearchQueryService"}
1550            ]}"#,
1551        )
1552        .unwrap();
1553        let index =
1554            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1555        assert_eq!(
1556            index.search_query_service.as_deref(),
1557            Some("https://search")
1558        );
1559    }
1560
1561    /// R5: a JSON-LD `@type` array must still resolve the resource, not fail the document.
1562    #[test]
1563    fn test_service_index_resolve_type_as_array() {
1564        let response: ServiceIndexResponse = serde_json::from_str(
1565            r#"{"version": "3.0.0", "resources": [
1566                {"@id": "https://flat/", "@type": ["PackageBaseAddress/3.0.0", "Other"]},
1567                {"@id": "https://search/", "@type": "SearchQueryService"}
1568            ]}"#,
1569        )
1570        .unwrap();
1571        let index =
1572            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1573        assert_eq!(index.package_base_address, "https://flat");
1574    }
1575
1576    /// R5: a malformed non-string `@type` scalar must degrade to "doesn't match", not fail
1577    /// deserialization of the whole document.
1578    #[test]
1579    fn test_service_index_resolve_type_malformed_scalar_degrades() {
1580        let response: ServiceIndexResponse = serde_json::from_str(
1581            r#"{"version": "3.0.0", "resources": [
1582                {"@id": "https://malformed/", "@type": 123},
1583                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1584                {"@id": "https://search/", "@type": "SearchQueryService"}
1585            ]}"#,
1586        )
1587        .unwrap();
1588        let index =
1589            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1590        assert_eq!(index.package_base_address, "https://flat");
1591    }
1592
1593    /// R5: a resource with a missing `@id` must be skipped, not crash resolution.
1594    #[test]
1595    fn test_service_index_resolve_missing_id_skipped() {
1596        let response: ServiceIndexResponse = serde_json::from_str(
1597            r#"{"version": "3.0.0", "resources": [
1598                {"@type": "PackageBaseAddress/3.0.0"},
1599                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1600                {"@id": "https://search/", "@type": "SearchQueryService"}
1601            ]}"#,
1602        )
1603        .unwrap();
1604        let index =
1605            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1606        assert_eq!(index.package_base_address, "https://flat");
1607    }
1608
1609    /// FR-016: a feed omitting `SearchQueryService` entirely must resolve — this is a real
1610    /// GitHub Packages shape, not an error.
1611    #[test]
1612    fn test_service_index_resolve_no_search_query_service_is_none() {
1613        let response: ServiceIndexResponse = serde_json::from_str(
1614            r#"{"version": "3.0.0", "resources": [
1615                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"}
1616            ]}"#,
1617        )
1618        .unwrap();
1619        let index =
1620            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1621        assert!(index.search_query_service.is_none());
1622    }
1623
1624    /// Q3: for a `WorkspaceDeclared`-tier feed, a `PackageBaseAddress` resolving to a
1625    /// policy-blocked host class must fail the whole feed (fail closed), not be silently
1626    /// trusted the way the `Public` tier is.
1627    #[test]
1628    fn test_service_index_resolve_workspace_tier_blocks_disallowed_package_base_address() {
1629        let response: ServiceIndexResponse = serde_json::from_str(
1630            r#"{"version": "3.0.0", "resources": [
1631                {"@id": "https://10.0.0.5/flat", "@type": "PackageBaseAddress/3.0.0"}
1632            ]}"#,
1633        )
1634        .unwrap();
1635        let policy =
1636            RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly);
1637        assert!(
1638            ServiceIndex::resolve(&response, NuGetRegistryTier::WorkspaceDeclared, &policy)
1639                .is_err()
1640        );
1641    }
1642
1643    /// Q3: a blocked `RegistrationsBaseUrl` degrades to absent rather than failing the feed.
1644    #[test]
1645    fn test_service_index_resolve_workspace_tier_degrades_blocked_registrations_base() {
1646        let response: ServiceIndexResponse = serde_json::from_str(
1647            r#"{"version": "3.0.0", "resources": [
1648                {"@id": "https://feed.example/flat", "@type": "PackageBaseAddress/3.0.0"},
1649                {"@id": "https://10.0.0.5/reg", "@type": "RegistrationsBaseUrl/3.6.0"}
1650            ]}"#,
1651        )
1652        .unwrap();
1653        let policy =
1654            RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly);
1655        let index = ServiceIndex::resolve(&response, NuGetRegistryTier::WorkspaceDeclared, &policy)
1656            .unwrap();
1657        assert!(index.registrations_base_url.is_none());
1658    }
1659
1660    #[test]
1661    fn test_parse_flat_container_sorted_descending() {
1662        let data = br#"{"versions": ["12.0.1", "13.0.3", "13.0.0-beta1"]}"#;
1663        let versions = parse_flat_container(data).unwrap();
1664        let strings: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1665        assert_eq!(strings, vec!["13.0.3", "13.0.0-beta1", "12.0.1"]);
1666    }
1667
1668    #[test]
1669    fn test_parse_flat_container_empty() {
1670        let data = br#"{"versions": []}"#;
1671        let versions = parse_flat_container(data).unwrap();
1672        assert!(versions.is_empty());
1673    }
1674
1675    #[test]
1676    fn test_parse_flat_container_invalid_json_errors() {
1677        assert!(parse_flat_container(b"not json").is_err());
1678    }
1679
1680    #[test]
1681    fn test_parse_search_response() {
1682        let data = br#"{"totalHits": 1, "data": [{"id": "Newtonsoft.Json", "version": "13.0.3", "description": "JSON framework", "projectUrl": "https://example.com"}]}"#;
1683        let results = parse_search_response(data, 10).unwrap();
1684        assert_eq!(results.len(), 1);
1685        assert_eq!(results[0].name, "Newtonsoft.Json");
1686        assert_eq!(results[0].latest_version, "13.0.3");
1687        assert_eq!(
1688            results[0].repository.as_deref(),
1689            Some("https://example.com")
1690        );
1691    }
1692
1693    #[test]
1694    fn test_parse_search_response_respects_limit() {
1695        let data = br#"{"totalHits": 2, "data": [
1696            {"id": "A", "version": "1.0.0"},
1697            {"id": "B", "version": "2.0.0"}
1698        ]}"#;
1699        let results = parse_search_response(data, 1).unwrap();
1700        assert_eq!(results.len(), 1);
1701        assert_eq!(results[0].name, "A");
1702    }
1703
1704    fn v(s: &str) -> NuGetVersion {
1705        NuGetVersion {
1706            version: s.into(),
1707            published_at: None,
1708        }
1709    }
1710
1711    #[test]
1712    fn test_pick_latest_matching_wildcard_excludes_prerelease() {
1713        let versions = vec![v("1.0.0"), v("1.1.0-rc.1")];
1714        let latest = pick_latest_matching(versions, "*");
1715        assert_eq!(latest.unwrap().version, "1.0.0");
1716    }
1717
1718    #[test]
1719    fn test_pick_latest_matching_empty_req_behaves_like_wildcard() {
1720        let versions = vec![v("1.0.0"), v("1.1.0-rc.1")];
1721        let latest = pick_latest_matching(versions, "");
1722        assert_eq!(latest.unwrap().version, "1.0.0");
1723    }
1724
1725    #[test]
1726    fn test_pick_latest_matching_exact_pin() {
1727        let versions = vec![v("1.0.1"), v("1.0.0")];
1728        let matched = pick_latest_matching(versions, "[1.0.0]");
1729        assert_eq!(matched.unwrap().version, "1.0.0");
1730    }
1731
1732    #[test]
1733    fn test_pick_latest_matching_floating_prefix() {
1734        let versions = vec![v("1.2.0"), v("1.1.5"), v("1.1.0")];
1735        let matched = pick_latest_matching(versions, "1.1.*");
1736        assert_eq!(matched.unwrap().version, "1.1.5");
1737    }
1738
1739    #[test]
1740    fn test_pick_latest_matching_prerelease_bearing_requirement_allows_prerelease() {
1741        let versions = vec![v("1.0.0-rc.2"), v("0.9.0")];
1742        let matched = pick_latest_matching(versions, "[1.0.0-rc.2]");
1743        assert_eq!(matched.unwrap().version, "1.0.0-rc.2");
1744    }
1745
1746    #[test]
1747    fn test_pick_latest_matching_empty_versions_returns_none() {
1748        assert!(pick_latest_matching(vec![], "*").is_none());
1749    }
1750
1751    #[test]
1752    fn test_pick_latest_matching_no_match_returns_none() {
1753        let versions = vec![v("2.0.0")];
1754        assert!(pick_latest_matching(versions, "[1.0.0]").is_none());
1755    }
1756
1757    /// Regression for #423: a package whose only releases so far are all prerelease must
1758    /// still resolve under a wildcard requirement — matching `deps-cargo`/`deps-composer`'s
1759    /// existence-check behavior — instead of `pick_latest_matching` returning `None`.
1760    #[test]
1761    fn test_pick_latest_matching_wildcard_prerelease_only_still_resolves() {
1762        let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1763        let matched = pick_latest_matching(versions, "*");
1764        assert_eq!(matched.unwrap().version, "2.0.0-beta2");
1765    }
1766
1767    /// Regression for #423: empty `req` is treated as `"*"`, so it must also rescue a
1768    /// prerelease-only package instead of returning `None`.
1769    #[test]
1770    fn test_pick_latest_matching_empty_req_prerelease_only_still_resolves() {
1771        let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1772        let matched = pick_latest_matching(versions, "");
1773        assert_eq!(matched.unwrap().version, "2.0.0-beta2");
1774    }
1775
1776    /// Regression guard for #423: `"*-*"` is prerelease-bearing but not an existence-check
1777    /// wildcard (`is_existence_wildcard_str` requires trimmed `""`/`"*"`), so it must keep
1778    /// resolving via `resolve_float`'s own best-match logic, unaffected by the new fallback.
1779    #[test]
1780    fn test_pick_latest_matching_prerelease_bearing_wildcard_unaffected_by_rescue() {
1781        let versions = vec![v("2.0.0-rc"), v("1.5.0"), v("1.0.0")];
1782        let matched = pick_latest_matching(versions, "*-*");
1783        assert_eq!(matched.unwrap().version, "2.0.0-rc");
1784    }
1785
1786    /// Regression guard for #423: a concrete non-wildcard requirement must NOT be rescued
1787    /// by the existence-check fallback, even over a prerelease-only version list — the gate
1788    /// is `is_existence_wildcard_str`, not "contains a `*`". If that gate were ever loosened
1789    /// to `req.contains('*')`, a floating pattern like `1.*` would start getting rescued
1790    /// too; this pins `None` so such a regression fails loudly instead of silently.
1791    #[test]
1792    fn test_pick_latest_matching_concrete_floating_requirement_not_rescued() {
1793        let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1794        assert!(pick_latest_matching(versions, "1.*").is_none());
1795    }
1796
1797    /// Regression guard for #423: an exact-pin requirement that matches nothing in a
1798    /// prerelease-only list must NOT be rescued either — mirrors
1799    /// `test_pick_latest_matching_concrete_floating_requirement_not_rescued` for the
1800    /// non-floating (exact-pin) matcher branch.
1801    #[test]
1802    fn test_pick_latest_matching_exact_pin_not_rescued() {
1803        let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1804        assert!(pick_latest_matching(versions, "[9.9.9]").is_none());
1805    }
1806
1807    #[test]
1808    fn test_registry_creation_and_trait_impls() {
1809        use deps_core::Registry;
1810        let cache = Arc::new(HttpCache::new());
1811        let registry = NuGetRegistry::new(cache);
1812        assert!(registry.as_any().is::<NuGetRegistry>());
1813    }
1814
1815    /// #365 end-to-end coverage (critic S2): exercises the real production
1816    /// `get_versions_typed_with` — not a reimplemented gate+sink pair — proving the gate is
1817    /// actually wired into the call path a real completion/hover/diagnostic request would
1818    /// take. No mock is needed: `reject_dot_segment` runs before `service_index()`, so the
1819    /// gate must reject before any network request is issued.
1820    ///
1821    /// Asserts the exact `PackageNotFound` variant (gate rejected before any request), not
1822    /// the broader `is_not_found()` (also true for a live 404 `HttpStatus`) — critic R1:
1823    /// `api.nuget.org` 404ing for this path today would make a deleted gate go undetected
1824    /// by this test.
1825    #[tokio::test]
1826    async fn test_get_versions_typed_with_rejects_bare_dot_dot_as_not_found() {
1827        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
1828        let err = registry
1829            .get_versions_typed_with("..", false)
1830            .await
1831            .unwrap_err();
1832        assert_matches!(err, deps_core::DepsError::PackageNotFound { .. });
1833    }
1834
1835    #[test]
1836    fn test_with_service_index_url_used_by_new() {
1837        let cache = Arc::new(HttpCache::new());
1838        let registry = NuGetRegistry::new(cache);
1839        assert_eq!(registry.service_index_url, NUGET_ORG_INDEX_URL);
1840    }
1841
1842    #[test]
1843    fn test_select_latest_matching_not_default_none() {
1844        use deps_core::{Registry, VersionReq};
1845
1846        let cache = Arc::new(HttpCache::new());
1847        let registry = NuGetRegistry::new(cache);
1848        let versions: Vec<Box<dyn deps_core::Version>> =
1849            vec![Box::new(v("1.1.0-rc.1")), Box::new(v("1.0.0"))];
1850        let req = VersionReq::new("*");
1851        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1852    }
1853
1854    /// Regression for #423: the trait impl's `select_latest_matching` must rescue a
1855    /// prerelease-only package under a wildcard requirement too, mirroring
1856    /// `test_pick_latest_matching_wildcard_prerelease_only_still_resolves`.
1857    #[test]
1858    fn test_select_latest_matching_wildcard_prerelease_only_still_resolves() {
1859        use deps_core::{Registry, VersionReq};
1860
1861        let cache = Arc::new(HttpCache::new());
1862        let registry = NuGetRegistry::new(cache);
1863        let versions: Vec<Box<dyn deps_core::Version>> =
1864            vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1865        let req = VersionReq::new("*");
1866        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1867    }
1868
1869    /// Regression for #423: empty `req` on the trait impl must also rescue a
1870    /// prerelease-only package, matching the free-function behavior.
1871    #[test]
1872    fn test_select_latest_matching_empty_req_prerelease_only_still_resolves() {
1873        use deps_core::{Registry, VersionReq};
1874
1875        let cache = Arc::new(HttpCache::new());
1876        let registry = NuGetRegistry::new(cache);
1877        let versions: Vec<Box<dyn deps_core::Version>> =
1878            vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1879        let req = VersionReq::new("");
1880        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1881    }
1882
1883    /// Regression guard for #423: `"*-*"` is not an existence-check wildcard, so the trait
1884    /// impl must keep resolving via `resolve_float`'s best-match logic, unaffected by the
1885    /// new fallback — mirrors
1886    /// `test_pick_latest_matching_prerelease_bearing_wildcard_unaffected_by_rescue`.
1887    #[test]
1888    fn test_select_latest_matching_prerelease_bearing_wildcard_unaffected_by_rescue() {
1889        use deps_core::{Registry, VersionReq};
1890
1891        let cache = Arc::new(HttpCache::new());
1892        let registry = NuGetRegistry::new(cache);
1893        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1894            Box::new(v("2.0.0-rc")),
1895            Box::new(v("1.5.0")),
1896            Box::new(v("1.0.0")),
1897        ];
1898        let req = VersionReq::new("*-*");
1899        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1900    }
1901
1902    /// Regression guard for #423: a concrete floating requirement must NOT be rescued by
1903    /// the trait impl's existence-check fallback, even over a prerelease-only list — mirrors
1904    /// `test_pick_latest_matching_concrete_floating_requirement_not_rescued`.
1905    #[test]
1906    fn test_select_latest_matching_concrete_floating_requirement_not_rescued() {
1907        use deps_core::{Registry, VersionReq};
1908
1909        let cache = Arc::new(HttpCache::new());
1910        let registry = NuGetRegistry::new(cache);
1911        let versions: Vec<Box<dyn deps_core::Version>> =
1912            vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1913        let req = VersionReq::new("1.*");
1914        assert_eq!(registry.select_latest_matching(&versions, &req), None);
1915    }
1916
1917    /// Regression guard for #423: a concrete exact-pin requirement matching nothing in a
1918    /// prerelease-only list must NOT be rescued either — mirrors
1919    /// `test_pick_latest_matching_exact_pin_not_rescued` for the trait impl.
1920    #[test]
1921    fn test_select_latest_matching_exact_pin_not_rescued() {
1922        use deps_core::{Registry, VersionReq};
1923
1924        let cache = Arc::new(HttpCache::new());
1925        let registry = NuGetRegistry::new(cache);
1926        let versions: Vec<Box<dyn deps_core::Version>> =
1927            vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1928        let req = VersionReq::new("[9.9.9]");
1929        assert_eq!(registry.select_latest_matching(&versions, &req), None);
1930    }
1931
1932    // --- ServiceIndex::resolve: registrations_base_url preference (S2/rev2 OQ6) ---
1933
1934    #[test]
1935    fn test_service_index_resolve_registrations_base_url_prefers_3_6_0() {
1936        let response: ServiceIndexResponse = serde_json::from_str(
1937            r#"{"version": "3.0.0", "resources": [
1938                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1939                {"@id": "https://search/", "@type": "SearchQueryService"},
1940                {"@id": "https://reg-semver1/", "@type": "RegistrationsBaseUrl/3.4.0"},
1941                {"@id": "https://reg-semver2/", "@type": "RegistrationsBaseUrl/3.6.0"}
1942            ]}"#,
1943        )
1944        .unwrap();
1945        let index =
1946            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1947        assert_eq!(
1948            index.registrations_base_url.as_deref(),
1949            Some("https://reg-semver2")
1950        );
1951    }
1952
1953    #[test]
1954    fn test_service_index_resolve_registrations_base_url_falls_back_to_3_4_0() {
1955        let response: ServiceIndexResponse = serde_json::from_str(
1956            r#"{"version": "3.0.0", "resources": [
1957                {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1958                {"@id": "https://search/", "@type": "SearchQueryService"},
1959                {"@id": "https://reg-semver1/", "@type": "RegistrationsBaseUrl/3.4.0"}
1960            ]}"#,
1961        )
1962        .unwrap();
1963        let index =
1964            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1965        assert_eq!(
1966            index.registrations_base_url.as_deref(),
1967            Some("https://reg-semver1")
1968        );
1969    }
1970
1971    #[test]
1972    fn test_service_index_resolve_registrations_base_url_absent_is_none() {
1973        let response: ServiceIndexResponse =
1974            serde_json::from_str(&service_index_body("https://flat/", "https://search/")).unwrap();
1975        let index =
1976            ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1977        assert!(index.registrations_base_url.is_none());
1978    }
1979
1980    #[test]
1981    fn test_registration_index_url_lowercases_and_encodes() {
1982        assert_eq!(
1983            registration_index_url(
1984                "https://api.nuget.org/v3/registration5-gz-semver2",
1985                "Newtonsoft.Json"
1986            ),
1987            "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json"
1988        );
1989    }
1990
1991    /// #365 regression sweep: exercises the real production `reject_dot_segment` gate and
1992    /// `registration_index_url` sink together against the shared adversarial input set,
1993    /// mirroring `test_flat_container_url_dot_segment_sweep` for the sibling sink (#380).
1994    #[test]
1995    fn test_registration_index_url_dot_segment_sweep() {
1996        deps_core::test_util::assert_dot_segment_gated_or_contained(
1997            |seg| {
1998                reject_dot_segment(seg).ok().map(|()| {
1999                    registration_index_url("https://api.nuget.org/v3/registration5-gz-semver2", seg)
2000                })
2001            },
2002            "api.nuget.org",
2003            "/v3/registration5-gz-semver2/",
2004        );
2005    }
2006
2007    // --- attach_publish_times ---
2008
2009    #[test]
2010    fn test_attach_publish_times_matches_by_version_string() {
2011        let mut versions = vec![v("1.0.0"), v("2.0.0")];
2012        let mut times = HashMap::new();
2013        times.insert(
2014            "1.0.0".to_string(),
2015            PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2016        );
2017        attach_publish_times(&mut versions, &times);
2018        assert_eq!(
2019            versions[0].published_at,
2020            PublishTime::parse_rfc3339("2020-01-01T00:00:00Z")
2021        );
2022        assert_eq!(versions[1].published_at, None);
2023    }
2024
2025    #[test]
2026    fn test_attach_publish_times_empty_map_leaves_all_none() {
2027        let mut versions = vec![v("1.0.0"), v("2.0.0")];
2028        attach_publish_times(&mut versions, &HashMap::new());
2029        assert!(versions.iter().all(|ver| ver.published_at.is_none()));
2030    }
2031
2032    // --- registration_enrichment_from_index: pure fixtures, no network for inline pages ---
2033
2034    fn inline_registration_index(base: &str, entries: &[(&str, Option<&str>)]) -> String {
2035        inline_registration_index_with_listed(
2036            base,
2037            &entries
2038                .iter()
2039                .map(|(v, p)| (*v, *p, None))
2040                .collect::<Vec<_>>(),
2041        )
2042    }
2043
2044    fn inline_registration_index_with_listed(
2045        base: &str,
2046        entries: &[(&str, Option<&str>, Option<bool>)],
2047    ) -> String {
2048        let items: Vec<String> = entries
2049            .iter()
2050            .map(|(version, published, listed)| {
2051                let published_field = published
2052                    .map(|p| format!(r#", "published": "{p}""#))
2053                    .unwrap_or_default();
2054                let listed_field = listed
2055                    .map(|l| format!(r#", "listed": {l}"#))
2056                    .unwrap_or_default();
2057                format!(
2058                    r#"{{"catalogEntry": {{"version": "{version}"{published_field}{listed_field}}}}}"#
2059                )
2060            })
2061            .collect();
2062        format!(
2063            r#"{{"count": 1, "items": [{{"@id": "{base}/pkg/page/0.json", "count": {n}, "items": [{items}]}}]}}"#,
2064            n = entries.len(),
2065            items = items.join(",")
2066        )
2067    }
2068
2069    #[tokio::test]
2070    async fn test_registration_enrichment_from_index_inline_happy_path() {
2071        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2072        let body = inline_registration_index(
2073            "https://api.nuget.org/v3/reg",
2074            &[
2075                ("1.0.0", Some("2020-01-01T00:00:00Z")),
2076                ("2.0.0", Some("2021-01-01T00:00:00Z")),
2077            ],
2078        );
2079        let enrichment = registry
2080            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2081            .await;
2082        assert_eq!(
2083            enrichment.published.get("2.0.0").copied(),
2084            PublishTime::parse_rfc3339("2021-01-01T00:00:00Z")
2085        );
2086        assert_eq!(enrichment.published.len(), 2);
2087        assert!(enrichment.unlisted.is_empty());
2088    }
2089
2090    #[tokio::test]
2091    async fn test_registration_enrichment_from_index_sentinel_filtered() {
2092        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2093        let body = inline_registration_index(
2094            "https://api.nuget.org/v3/reg",
2095            &[
2096                ("1.0.0", Some("1900-01-01T00:00:00+00:00")),
2097                ("2.0.0", Some("2021-01-01T00:00:00Z")),
2098            ],
2099        );
2100        let enrichment = registry
2101            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2102            .await;
2103        assert!(!enrichment.published.contains_key("1.0.0"));
2104        assert!(enrichment.published.contains_key("2.0.0"));
2105    }
2106
2107    /// D1/#451: a registration predating the `listed` field signals unlisted solely via the
2108    /// sentinel `published` epoch — the same entry `test_registration_enrichment_from_index_sentinel_filtered`
2109    /// already proves is excluded from `published`, but it must still land in `unlisted`.
2110    #[tokio::test]
2111    async fn test_registration_enrichment_from_index_legacy_sentinel_marks_unlisted() {
2112        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2113        let body = inline_registration_index(
2114            "https://api.nuget.org/v3/reg",
2115            &[
2116                ("1.0.0", Some("1900-01-01T00:00:00+00:00")),
2117                ("2.0.0", Some("2021-01-01T00:00:00Z")),
2118            ],
2119        );
2120        let enrichment = registry
2121            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2122            .await;
2123        assert!(enrichment.unlisted.contains("1.0.0"));
2124        assert!(!enrichment.unlisted.contains("2.0.0"));
2125    }
2126
2127    /// D1/#451: a current registration signals unlisted via an explicit `"listed": false`,
2128    /// independent of `published` (which can be a perfectly ordinary, non-sentinel date).
2129    #[tokio::test]
2130    async fn test_registration_enrichment_from_index_explicit_listed_false_marks_unlisted() {
2131        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2132        let body = inline_registration_index_with_listed(
2133            "https://api.nuget.org/v3/reg",
2134            &[
2135                ("1.0.0", Some("2020-01-01T00:00:00Z"), Some(false)),
2136                ("2.0.0", Some("2021-01-01T00:00:00Z"), Some(true)),
2137            ],
2138        );
2139        let enrichment = registry
2140            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2141            .await;
2142        assert!(enrichment.unlisted.contains("1.0.0"));
2143        assert!(!enrichment.unlisted.contains("2.0.0"));
2144        // `listed: false` doesn't suppress an otherwise-valid publish date.
2145        assert!(enrichment.published.contains_key("1.0.0"));
2146    }
2147
2148    #[tokio::test]
2149    async fn test_registration_enrichment_from_index_missing_published_is_absent_rest_intact() {
2150        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2151        let body = inline_registration_index(
2152            "https://api.nuget.org/v3/reg",
2153            &[("1.0.0", None), ("2.0.0", Some("2021-01-01T00:00:00Z"))],
2154        );
2155        let enrichment = registry
2156            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2157            .await;
2158        assert!(!enrichment.published.contains_key("1.0.0"));
2159        assert!(enrichment.published.contains_key("2.0.0"));
2160        // No `published` and no explicit `listed` is not itself an unlisted signal.
2161        assert!(!enrichment.unlisted.contains("1.0.0"));
2162    }
2163
2164    #[tokio::test]
2165    async fn test_registration_enrichment_from_index_malformed_json_returns_empty() {
2166        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2167        let enrichment = registry
2168            .registration_enrichment_from_index(b"not json", "https://api.nuget.org/v3/reg/")
2169            .await;
2170        assert!(enrichment.published.is_empty());
2171        assert!(enrichment.unlisted.is_empty());
2172    }
2173
2174    #[tokio::test]
2175    async fn test_registration_enrichment_from_index_foreign_origin_page_skipped_no_request() {
2176        // A page @id outside `base`'s origin must be skipped without ever being fetched —
2177        // if the implementation issued a real request here, this test would hang/fail on
2178        // network access rather than complete instantly.
2179        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2180        let body = r#"{"count": 1, "items": [
2181            {"@id": "https://evil.example/pkg/page/0.json", "count": 1}
2182        ]}"#;
2183        let enrichment = registry
2184            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2185            .await;
2186        assert!(enrichment.published.is_empty());
2187        assert!(enrichment.unlisted.is_empty());
2188    }
2189
2190    #[tokio::test]
2191    async fn test_registration_enrichment_from_index_lookalike_origin_page_skipped_no_request() {
2192        // A prefix-lookalike host (`…nuget.org.evil.test`) must also be rejected — the
2193        // trailing-slash check in the trust boundary is what catches this.
2194        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2195        let body = r#"{"count": 1, "items": [
2196            {"@id": "https://api.nuget.org.evil.test/v3/reg/pkg/page/0.json", "count": 1}
2197        ]}"#;
2198        let enrichment = registry
2199            .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2200            .await;
2201        assert!(enrichment.published.is_empty());
2202        assert!(enrichment.unlisted.is_empty());
2203    }
2204
2205    // --- unlisted_versions_for_hover: end-to-end (mockito) ---
2206
2207    #[tokio::test]
2208    async fn test_unlisted_versions_for_hover_reports_explicit_and_legacy_unlisted() {
2209        let mut server = mockito::Server::new_async().await;
2210        let base = server.url();
2211
2212        let _service_index_mock = server
2213            .mock("GET", "/index.json")
2214            .with_status(200)
2215            .with_body(service_index_body_with_registrations(
2216                &format!("{base}/flatcontainer"),
2217                &format!("{base}/query"),
2218                &format!("{base}/registrations"),
2219            ))
2220            .create_async()
2221            .await;
2222        let registration_body = inline_registration_index_with_listed(
2223            &format!("{base}/registrations"),
2224            &[
2225                ("1.0.0", Some("1900-01-01T00:00:00+00:00"), None),
2226                ("2.0.0", Some("2021-01-01T00:00:00Z"), Some(false)),
2227                ("3.0.0", Some("2022-01-01T00:00:00Z"), Some(true)),
2228            ],
2229        );
2230        let _reg_mock = server
2231            .mock("GET", "/registrations/widget/index.json")
2232            .with_status(200)
2233            .with_body(registration_body)
2234            .create_async()
2235            .await;
2236
2237        let registry = NuGetRegistry::with_service_index_url(
2238            Arc::new(HttpCache::new()),
2239            format!("{base}/index.json"),
2240        );
2241        let unlisted = registry
2242            .unlisted_versions_for_hover("widget")
2243            .await
2244            .unwrap();
2245
2246        assert!(unlisted.contains("1.0.0"));
2247        assert!(unlisted.contains("2.0.0"));
2248        assert!(!unlisted.contains("3.0.0"));
2249    }
2250
2251    #[tokio::test]
2252    async fn test_unlisted_versions_for_hover_no_registrations_base_url_degrades_to_empty() {
2253        let mut server = mockito::Server::new_async().await;
2254        let base = server.url();
2255
2256        let _service_index_mock = server
2257            .mock("GET", "/index.json")
2258            .with_status(200)
2259            .with_body(service_index_body(
2260                &format!("{base}/flatcontainer"),
2261                &format!("{base}/query"),
2262            ))
2263            .create_async()
2264            .await;
2265
2266        let registry = NuGetRegistry::with_service_index_url(
2267            Arc::new(HttpCache::new()),
2268            format!("{base}/index.json"),
2269        );
2270        let unlisted = registry
2271            .unlisted_versions_for_hover("widget")
2272            .await
2273            .unwrap();
2274        assert!(unlisted.is_empty());
2275    }
2276
2277    #[tokio::test]
2278    async fn test_unlisted_versions_for_hover_fetch_failure_degrades_to_empty() {
2279        let mut server = mockito::Server::new_async().await;
2280        let base = server.url();
2281
2282        let _service_index_mock = server
2283            .mock("GET", "/index.json")
2284            .with_status(200)
2285            .with_body(service_index_body_with_registrations(
2286                &format!("{base}/flatcontainer"),
2287                &format!("{base}/query"),
2288                &format!("{base}/registrations"),
2289            ))
2290            .create_async()
2291            .await;
2292        let _reg_mock = server
2293            .mock("GET", "/registrations/widget/index.json")
2294            .with_status(500)
2295            .create_async()
2296            .await;
2297
2298        let registry = NuGetRegistry::with_service_index_url(
2299            Arc::new(HttpCache::new()),
2300            format!("{base}/index.json"),
2301        );
2302        let unlisted = registry
2303            .unlisted_versions_for_hover("widget")
2304            .await
2305            .unwrap();
2306        assert!(unlisted.is_empty());
2307    }
2308
2309    #[tokio::test]
2310    async fn test_unlisted_versions_for_hover_rejects_bare_dot_dot_as_not_found() {
2311        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2312        let err = registry
2313            .unlisted_versions_for_hover("..")
2314            .await
2315            .unwrap_err();
2316        assert_matches!(err, deps_core::DepsError::PackageNotFound { .. });
2317    }
2318
2319    // --- get_versions_typed_with: end-to-end gating and registration-hive walk (mockito) ---
2320
2321    #[tokio::test]
2322    async fn test_get_versions_typed_with_disabled_issues_zero_registration_requests() {
2323        let mut server = mockito::Server::new_async().await;
2324        let base = server.url();
2325
2326        let _service_index_mock = server
2327            .mock("GET", "/index.json")
2328            .with_status(200)
2329            .with_body(service_index_body_with_registrations(
2330                &format!("{base}/flatcontainer"),
2331                &format!("{base}/query"),
2332                &format!("{base}/registrations"),
2333            ))
2334            .create_async()
2335            .await;
2336        let _flat_mock = server
2337            .mock("GET", "/flatcontainer/widget/index.json")
2338            .with_status(200)
2339            .with_body(r#"{"versions": ["1.0.0", "2.0.0"]}"#)
2340            .create_async()
2341            .await;
2342        // No mock registered for /registrations/* — a request there would fail the test.
2343
2344        let registry = NuGetRegistry::with_service_index_url(
2345            Arc::new(HttpCache::new()),
2346            format!("{base}/index.json"),
2347        );
2348
2349        let versions = registry
2350            .get_versions_typed_with("widget", false)
2351            .await
2352            .unwrap();
2353        assert_eq!(versions.len(), 2);
2354        assert!(versions.iter().all(|v| v.published_at.is_none()));
2355    }
2356
2357    #[tokio::test]
2358    async fn test_get_versions_typed_with_enabled_matches_disabled_set_and_order() {
2359        // FR-006 regression guard: enabling freshness must not change the returned list's
2360        // set or order, only populate `published_at`.
2361        let mut server = mockito::Server::new_async().await;
2362        let base = server.url();
2363
2364        let _service_index_mock = server
2365            .mock("GET", "/index.json")
2366            .with_status(200)
2367            .with_body(service_index_body_with_registrations(
2368                &format!("{base}/flatcontainer"),
2369                &format!("{base}/query"),
2370                &format!("{base}/registrations"),
2371            ))
2372            .create_async()
2373            .await;
2374        let _flat_mock = server
2375            .mock("GET", "/flatcontainer/widget/index.json")
2376            .with_status(200)
2377            .with_body(r#"{"versions": ["1.0.0", "2.0.0"]}"#)
2378            .create_async()
2379            .await;
2380        let registration_body = inline_registration_index(
2381            &format!("{base}/registrations"),
2382            &[
2383                ("1.0.0", Some("2020-01-01T00:00:00Z")),
2384                ("2.0.0", Some("2021-01-01T00:00:00Z")),
2385            ],
2386        );
2387        let _reg_mock = server
2388            .mock("GET", "/registrations/widget/index.json")
2389            .with_status(200)
2390            .with_body(registration_body)
2391            .create_async()
2392            .await;
2393
2394        let registry = NuGetRegistry::with_service_index_url(
2395            Arc::new(HttpCache::new()),
2396            format!("{base}/index.json"),
2397        );
2398
2399        let disabled = registry
2400            .get_versions_typed_with("widget", false)
2401            .await
2402            .unwrap();
2403        let enabled = registry
2404            .get_versions_typed_with("widget", true)
2405            .await
2406            .unwrap();
2407
2408        let disabled_strings: Vec<&str> = disabled.iter().map(|v| v.version.as_str()).collect();
2409        let enabled_strings: Vec<&str> = enabled.iter().map(|v| v.version.as_str()).collect();
2410        assert_eq!(disabled_strings, enabled_strings);
2411        assert!(enabled.iter().all(|v| v.published_at.is_some()));
2412    }
2413
2414    #[tokio::test]
2415    async fn test_get_versions_typed_with_externalized_index_fetches_only_needed_pages() {
2416        let mut server = mockito::Server::new_async().await;
2417        let base = server.url();
2418        let reg_base = format!("{base}/registrations");
2419
2420        let _service_index_mock = server
2421            .mock("GET", "/index.json")
2422            .with_status(200)
2423            .with_body(service_index_body_with_registrations(
2424                &format!("{base}/flatcontainer"),
2425                &format!("{base}/query"),
2426                &reg_base,
2427            ))
2428            .create_async()
2429            .await;
2430        let _flat_mock = server
2431            .mock("GET", "/flatcontainer/widget/index.json")
2432            .with_status(200)
2433            .with_body(r#"{"versions": ["9.0.0", "8.0.0", "7.0.0"]}"#)
2434            .create_async()
2435            .await;
2436
2437        // Two external stub pages; only the last one (page 1) should ever be requested,
2438        // since it alone already covers >= HOVER_RECENT_VERSIONS entries.
2439        let index_body = format!(
2440            r#"{{"count": 2, "items": [
2441                {{"@id": "{reg_base}/widget/page/0.json", "count": 5}},
2442                {{"@id": "{reg_base}/widget/page/1.json", "count": 5}}
2443            ]}}"#
2444        );
2445        let _reg_mock = server
2446            .mock("GET", "/registrations/widget/index.json")
2447            .with_status(200)
2448            .with_body(index_body)
2449            .create_async()
2450            .await;
2451
2452        // Page 1 alone carries HOVER_RECENT_VERSIONS (8) entries, so the walk must stop
2453        // here and never touch page 0.
2454        let page1_body = r#"{"items": [
2455            {"catalogEntry": {"version": "2.0.0", "published": "2015-01-01T00:00:00Z"}},
2456            {"catalogEntry": {"version": "3.0.0", "published": "2016-01-01T00:00:00Z"}},
2457            {"catalogEntry": {"version": "4.0.0", "published": "2017-01-01T00:00:00Z"}},
2458            {"catalogEntry": {"version": "5.0.0", "published": "2018-01-01T00:00:00Z"}},
2459            {"catalogEntry": {"version": "6.0.0", "published": "2019-01-01T00:00:00Z"}},
2460            {"catalogEntry": {"version": "7.0.0", "published": "2020-01-01T00:00:00Z"}},
2461            {"catalogEntry": {"version": "8.0.0", "published": "2021-01-01T00:00:00Z"}},
2462            {"catalogEntry": {"version": "9.0.0", "published": "2022-01-01T00:00:00Z"}}
2463        ]}"#;
2464        let page1_mock = server
2465            .mock("GET", "/registrations/widget/page/1.json")
2466            .with_status(200)
2467            .with_body(page1_body)
2468            .expect(1)
2469            .create_async()
2470            .await;
2471        let page0_mock = server
2472            .mock("GET", "/registrations/widget/page/0.json")
2473            .with_status(200)
2474            .with_body(r#"{"items": []}"#)
2475            .expect(0)
2476            .create_async()
2477            .await;
2478
2479        let registry = NuGetRegistry::with_service_index_url(
2480            Arc::new(HttpCache::new()),
2481            format!("{base}/index.json"),
2482        );
2483        let versions = registry
2484            .get_versions_typed_with("widget", true)
2485            .await
2486            .unwrap();
2487
2488        assert_eq!(versions.len(), 3);
2489        assert!(versions.iter().all(|v| v.published_at.is_some()));
2490        page1_mock.assert_async().await;
2491        page0_mock.assert_async().await;
2492    }
2493
2494    #[tokio::test]
2495    async fn test_get_versions_typed_with_external_fetch_cap_stops_walk_at_two_pages() {
2496        // Tester gap: MAX_EXTERNAL_PAGE_FETCHES = 2 was never actually hit by any prior
2497        // test. Three external pages, each carrying too few entries to reach
2498        // HOVER_RECENT_VERSIONS alone or even combined two-at-a-time, so the walk must stop
2499        // after exactly 2 external fetches (pages 2 and 1) and never touch page 0 — proving
2500        // the cap terminates the walk rather than the count or exhaustion terminators.
2501        let mut server = mockito::Server::new_async().await;
2502        let base = server.url();
2503        let reg_base = format!("{base}/registrations");
2504
2505        let _service_index_mock = server
2506            .mock("GET", "/index.json")
2507            .with_status(200)
2508            .with_body(service_index_body_with_registrations(
2509                &format!("{base}/flatcontainer"),
2510                &format!("{base}/query"),
2511                &reg_base,
2512            ))
2513            .create_async()
2514            .await;
2515        let _flat_mock = server
2516            .mock("GET", "/flatcontainer/widget/index.json")
2517            .with_status(200)
2518            .with_body(r#"{"versions": ["6.0.0", "5.0.0", "4.0.0", "3.0.0", "2.0.0", "1.0.0"]}"#)
2519            .create_async()
2520            .await;
2521
2522        let index_body = format!(
2523            r#"{{"count": 3, "items": [
2524                {{"@id": "{reg_base}/widget/page/0.json", "count": 2}},
2525                {{"@id": "{reg_base}/widget/page/1.json", "count": 2}},
2526                {{"@id": "{reg_base}/widget/page/2.json", "count": 2}}
2527            ]}}"#
2528        );
2529        let _reg_mock = server
2530            .mock("GET", "/registrations/widget/index.json")
2531            .with_status(200)
2532            .with_body(index_body)
2533            .create_async()
2534            .await;
2535
2536        let page2_body = r#"{"items": [
2537            {"catalogEntry": {"version": "5.0.0", "published": "2021-01-01T00:00:00Z"}},
2538            {"catalogEntry": {"version": "6.0.0", "published": "2022-01-01T00:00:00Z"}}
2539        ]}"#;
2540        let page2_mock = server
2541            .mock("GET", "/registrations/widget/page/2.json")
2542            .with_status(200)
2543            .with_body(page2_body)
2544            .expect(1)
2545            .create_async()
2546            .await;
2547
2548        let page1_body = r#"{"items": [
2549            {"catalogEntry": {"version": "3.0.0", "published": "2019-01-01T00:00:00Z"}},
2550            {"catalogEntry": {"version": "4.0.0", "published": "2020-01-01T00:00:00Z"}}
2551        ]}"#;
2552        let page1_mock = server
2553            .mock("GET", "/registrations/widget/page/1.json")
2554            .with_status(200)
2555            .with_body(page1_body)
2556            .expect(1)
2557            .create_async()
2558            .await;
2559
2560        // Only 4 entries collected across the two allowed external fetches (< 8), so a
2561        // buggy implementation would keep walking into page 0. This mock must see zero hits.
2562        let page0_mock = server
2563            .mock("GET", "/registrations/widget/page/0.json")
2564            .with_status(200)
2565            .with_body(
2566                r#"{"items": [
2567                {"catalogEntry": {"version": "1.0.0", "published": "2017-01-01T00:00:00Z"}},
2568                {"catalogEntry": {"version": "2.0.0", "published": "2018-01-01T00:00:00Z"}}
2569            ]}"#,
2570            )
2571            .expect(0)
2572            .create_async()
2573            .await;
2574
2575        let registry = NuGetRegistry::with_service_index_url(
2576            Arc::new(HttpCache::new()),
2577            format!("{base}/index.json"),
2578        );
2579        let versions = registry
2580            .get_versions_typed_with("widget", true)
2581            .await
2582            .unwrap();
2583
2584        assert_eq!(versions.len(), 6);
2585        // Only the 4 versions covered by the two allowed external pages got a date.
2586        for v in ["3.0.0", "4.0.0", "5.0.0", "6.0.0"] {
2587            assert!(
2588                versions
2589                    .iter()
2590                    .find(|ver| ver.version == v)
2591                    .unwrap()
2592                    .published_at
2593                    .is_some(),
2594                "{v} should have a published_at"
2595            );
2596        }
2597        for v in ["1.0.0", "2.0.0"] {
2598            assert!(
2599                versions
2600                    .iter()
2601                    .find(|ver| ver.version == v)
2602                    .unwrap()
2603                    .published_at
2604                    .is_none(),
2605                "{v} is beyond the external-fetch cap and must have no published_at"
2606            );
2607        }
2608        page2_mock.assert_async().await;
2609        page1_mock.assert_async().await;
2610        page0_mock.assert_async().await;
2611    }
2612
2613    #[tokio::test]
2614    async fn test_get_versions_typed_with_single_version_package_terminates() {
2615        // S3 regression: a package with fewer total versions than HOVER_RECENT_VERSIONS
2616        // must terminate via index exhaustion rather than hanging or looping.
2617        let mut server = mockito::Server::new_async().await;
2618        let base = server.url();
2619
2620        let _service_index_mock = server
2621            .mock("GET", "/index.json")
2622            .with_status(200)
2623            .with_body(service_index_body_with_registrations(
2624                &format!("{base}/flatcontainer"),
2625                &format!("{base}/query"),
2626                &format!("{base}/registrations"),
2627            ))
2628            .create_async()
2629            .await;
2630        let _flat_mock = server
2631            .mock("GET", "/flatcontainer/orchard.core/index.json")
2632            .with_status(200)
2633            .with_body(r#"{"versions": ["1.0.0"]}"#)
2634            .create_async()
2635            .await;
2636        let registration_body = inline_registration_index(
2637            &format!("{base}/registrations"),
2638            &[("1.0.0", Some("2020-01-01T00:00:00Z"))],
2639        );
2640        let _reg_mock = server
2641            .mock("GET", "/registrations/orchard.core/index.json")
2642            .with_status(200)
2643            .with_body(registration_body)
2644            .create_async()
2645            .await;
2646
2647        let registry = NuGetRegistry::with_service_index_url(
2648            Arc::new(HttpCache::new()),
2649            format!("{base}/index.json"),
2650        );
2651        let versions = registry
2652            .get_versions_typed_with("orchard.core", true)
2653            .await
2654            .unwrap();
2655
2656        assert_eq!(versions.len(), 1);
2657        assert!(versions[0].published_at.is_some());
2658    }
2659
2660    #[tokio::test]
2661    async fn test_get_versions_typed_with_no_registrations_base_url_degrades_gracefully() {
2662        let mut server = mockito::Server::new_async().await;
2663        let base = server.url();
2664
2665        let _service_index_mock = server
2666            .mock("GET", "/index.json")
2667            .with_status(200)
2668            .with_body(service_index_body(
2669                &format!("{base}/flatcontainer"),
2670                &format!("{base}/query"),
2671            ))
2672            .create_async()
2673            .await;
2674        let _flat_mock = server
2675            .mock("GET", "/flatcontainer/widget/index.json")
2676            .with_status(200)
2677            .with_body(r#"{"versions": ["1.0.0"]}"#)
2678            .create_async()
2679            .await;
2680
2681        let registry = NuGetRegistry::with_service_index_url(
2682            Arc::new(HttpCache::new()),
2683            format!("{base}/index.json"),
2684        );
2685        let versions = registry
2686            .get_versions_typed_with("widget", true)
2687            .await
2688            .unwrap();
2689
2690        assert_eq!(versions.len(), 1);
2691        assert!(versions[0].published_at.is_none());
2692    }
2693
2694    // --- NFR-006 live verification (real network, run explicitly with `--ignored`) ---
2695
2696    #[tokio::test]
2697    #[ignore]
2698    async fn test_live_nuget_attaches_publish_times() {
2699        let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2700        let versions = registry
2701            .get_versions_typed_with("Newtonsoft.Json", true)
2702            .await
2703            .unwrap();
2704
2705        assert!(!versions.is_empty());
2706        assert!(versions.iter().take(5).any(|v| v.published_at.is_some()));
2707    }
2708
2709    // --- get_versions_chained: 3-way failure taxonomy (tester gap #1) ---
2710
2711    fn all_policy() -> Arc<RegistryAccessPolicy> {
2712        Arc::new(RegistryAccessPolicy::new(
2713            deps_core::net_policy::WorkspaceRegistryAccess::All,
2714        ))
2715    }
2716
2717    /// Wraps `feed` in an unauthenticated [`ResolvedHop`] — the shape every
2718    /// `NuGetRegistry::with_base` test call site below needs post-FR-016.
2719    fn hop(feed: &NuGetFeedUrl) -> ResolvedHop {
2720        ResolvedHop {
2721            url: feed.clone(),
2722            slot: None,
2723            auth: None,
2724        }
2725    }
2726
2727    fn workspace_client(base: &str, policy: &Arc<RegistryAccessPolicy>) -> NuGetRegistry {
2728        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), policy).unwrap();
2729        NuGetRegistry::with_base(
2730            Arc::new(HttpCache::new()),
2731            &hop(&feed),
2732            Arc::clone(policy),
2733            Vec::new(),
2734        )
2735    }
2736
2737    #[tokio::test]
2738    async fn test_get_versions_chained_falls_through_on_package_not_found() {
2739        let mut hop0 = mockito::Server::new_async().await;
2740        let hop0_index = hop0
2741            .mock("GET", "/index.json")
2742            .with_status(200)
2743            .with_body(service_index_body(
2744                &format!("{}/flat", hop0.url()),
2745                &format!("{}/search", hop0.url()),
2746            ))
2747            .create_async()
2748            .await;
2749        let hop0_flat = hop0
2750            .mock("GET", "/flat/pkg/index.json")
2751            .with_status(404)
2752            .create_async()
2753            .await;
2754
2755        let mut hop1 = mockito::Server::new_async().await;
2756        let hop1_index = hop1
2757            .mock("GET", "/index.json")
2758            .with_status(200)
2759            .with_body(service_index_body(
2760                &format!("{}/flat", hop1.url()),
2761                &format!("{}/search", hop1.url()),
2762            ))
2763            .create_async()
2764            .await;
2765        let hop1_flat = hop1
2766            .mock("GET", "/flat/pkg/index.json")
2767            .with_status(200)
2768            .with_body(r#"{"versions": ["2.0.0"]}"#)
2769            .create_async()
2770            .await;
2771
2772        let policy = all_policy();
2773        let cache = Arc::new(HttpCache::new());
2774        let hop1_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop1.url()), &policy).unwrap();
2775        let hop1_client = Arc::new(NuGetRegistry::with_base(
2776            Arc::clone(&cache),
2777            &hop(&hop1_feed),
2778            Arc::clone(&policy),
2779            Vec::new(),
2780        ));
2781        let hop0_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2782        let head = NuGetRegistry::with_base(
2783            cache,
2784            &hop(&hop0_feed),
2785            Arc::clone(&policy),
2786            vec![hop1_client],
2787        );
2788
2789        let versions = head.get_versions_chained("pkg").await.unwrap();
2790        assert_eq!(versions.len(), 1);
2791        assert_eq!(versions[0].version.as_str(), "2.0.0");
2792
2793        hop0_index.assert_async().await;
2794        hop0_flat.assert_async().await;
2795        hop1_index.assert_async().await;
2796        hop1_flat.assert_async().await;
2797    }
2798
2799    #[tokio::test]
2800    async fn test_get_versions_chained_falls_through_on_empty_listing() {
2801        let mut hop0 = mockito::Server::new_async().await;
2802        hop0.mock("GET", "/index.json")
2803            .with_status(200)
2804            .with_body(service_index_body(
2805                &format!("{}/flat", hop0.url()),
2806                &format!("{}/search", hop0.url()),
2807            ))
2808            .create_async()
2809            .await;
2810        hop0.mock("GET", "/flat/pkg/index.json")
2811            .with_status(200)
2812            .with_body(r#"{"versions": []}"#)
2813            .create_async()
2814            .await;
2815
2816        let mut hop1 = mockito::Server::new_async().await;
2817        hop1.mock("GET", "/index.json")
2818            .with_status(200)
2819            .with_body(service_index_body(
2820                &format!("{}/flat", hop1.url()),
2821                &format!("{}/search", hop1.url()),
2822            ))
2823            .create_async()
2824            .await;
2825        hop1.mock("GET", "/flat/pkg/index.json")
2826            .with_status(200)
2827            .with_body(r#"{"versions": ["3.0.0"]}"#)
2828            .create_async()
2829            .await;
2830
2831        let policy = all_policy();
2832        let cache = Arc::new(HttpCache::new());
2833        let hop1_client = Arc::new(workspace_client(&hop1.url(), &policy));
2834        let head = {
2835            let feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2836            NuGetRegistry::with_base(cache, &hop(&feed), Arc::clone(&policy), vec![hop1_client])
2837        };
2838
2839        let versions = head.get_versions_chained("pkg").await.unwrap();
2840        assert_eq!(versions.len(), 1);
2841        assert_eq!(versions[0].version.as_str(), "3.0.0");
2842    }
2843
2844    /// A terminal transport error on hop 0 halts the chain — hop 1's `.expect(0)` mock fails
2845    /// the test if the chain wrongly fell through instead.
2846    #[tokio::test]
2847    async fn test_get_versions_chained_terminates_on_transport_error_never_tries_next_hop() {
2848        let mut hop0 = mockito::Server::new_async().await;
2849        hop0.mock("GET", "/index.json")
2850            .with_status(200)
2851            .with_body(service_index_body(
2852                &format!("{}/flat", hop0.url()),
2853                &format!("{}/search", hop0.url()),
2854            ))
2855            .create_async()
2856            .await;
2857        hop0.mock("GET", "/flat/pkg/index.json")
2858            .with_status(503)
2859            .create_async()
2860            .await;
2861
2862        let mut hop1 = mockito::Server::new_async().await;
2863        let hop1_flat = hop1
2864            .mock("GET", "/flat/pkg/index.json")
2865            .expect(0)
2866            .create_async()
2867            .await;
2868
2869        let policy = all_policy();
2870        let cache = Arc::new(HttpCache::new());
2871        let hop1_client = Arc::new(workspace_client(&hop1.url(), &policy));
2872        let head = {
2873            let feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2874            NuGetRegistry::with_base(cache, &hop(&feed), Arc::clone(&policy), vec![hop1_client])
2875        };
2876
2877        let err = head.get_versions_chained("pkg").await.unwrap_err();
2878        assert!(
2879            matches!(err, DepsError::ChainResolutionHalted),
2880            "expected ChainResolutionHalted, got: {err:?}"
2881        );
2882        hop1_flat.assert_async().await;
2883    }
2884
2885    // --- register_chain: multi-hop fallback_chain construction (tester gap #2) ---
2886
2887    /// End-to-end proof that `register_chain` wires a 2-declared-hop chain plus the
2888    /// implicit-public-fallback hop into a working, positionally-ordered `fallback_chain`:
2889    /// hop 0 misses, hop 1 misses, the implicit public hop (root's own service index)
2890    /// succeeds — every prior test registers only single-hop chains.
2891    #[tokio::test]
2892    async fn test_register_chain_multi_hop_fallback_chain_walks_every_hop_in_order() {
2893        let mut hop0 = mockito::Server::new_async().await;
2894        hop0.mock("GET", "/index.json")
2895            .with_status(200)
2896            .with_body(service_index_body(
2897                &format!("{}/flat", hop0.url()),
2898                &format!("{}/search", hop0.url()),
2899            ))
2900            .create_async()
2901            .await;
2902        hop0.mock("GET", "/flat/pkg/index.json")
2903            .with_status(404)
2904            .create_async()
2905            .await;
2906
2907        let mut hop1 = mockito::Server::new_async().await;
2908        hop1.mock("GET", "/index.json")
2909            .with_status(200)
2910            .with_body(service_index_body(
2911                &format!("{}/flat", hop1.url()),
2912                &format!("{}/search", hop1.url()),
2913            ))
2914            .create_async()
2915            .await;
2916        hop1.mock("GET", "/flat/pkg/index.json")
2917            .with_status(404)
2918            .create_async()
2919            .await;
2920
2921        let mut public = mockito::Server::new_async().await;
2922        public
2923            .mock("GET", "/index.json")
2924            .with_status(200)
2925            .with_body(service_index_body(
2926                &format!("{}/flat", public.url()),
2927                &format!("{}/search", public.url()),
2928            ))
2929            .create_async()
2930            .await;
2931        public
2932            .mock("GET", "/flat/pkg/index.json")
2933            .with_status(200)
2934            .with_body(r#"{"versions": ["9.0.0"]}"#)
2935            .create_async()
2936            .await;
2937
2938        let policy = all_policy();
2939        let cache = Arc::new(HttpCache::new());
2940        let root = Arc::new(NuGetRegistry::with_service_index_url(
2941            Arc::clone(&cache),
2942            format!("{}/index.json", public.url()),
2943        ));
2944
2945        let hop0_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2946        let hop1_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop1.url()), &policy).unwrap();
2947        let chain = NuGetSourceChain {
2948            key: "nuget-chain:test-multi-hop".to_string(),
2949            hops: vec![hop(&hop0_feed), hop(&hop1_feed)],
2950            implicit_public_fallback: true,
2951        };
2952        NuGetRegistry::register_chain(&root, &chain, &policy);
2953
2954        let client = root
2955            .alternate_client(&chain.key)
2956            .expect("chain must be registered");
2957        assert_eq!(
2958            client.fallback_chain.len(),
2959            2,
2960            "expected [hop1, implicit-public] in the head's fallback_chain"
2961        );
2962
2963        let versions = client.get_versions_chained("pkg").await.unwrap();
2964        assert_eq!(versions.len(), 1);
2965        assert_eq!(versions[0].version.as_str(), "9.0.0");
2966    }
2967
2968    // --- issue #561: authenticated fetch (C1 origin-binding, four-site routing) ---
2969
2970    fn auth_hop(feed: &NuGetFeedUrl, slot: &str, username: &str, password: &str) -> ResolvedHop {
2971        ResolvedHop {
2972            url: feed.clone(),
2973            slot: Some(slot.to_string()),
2974            auth: Some(NuGetAuth::new(username, password)),
2975        }
2976    }
2977
2978    /// SC-001/SC-011: an authenticated `WorkspaceDeclared` client attaches the credential to
2979    /// both the service-index fetch and the flat-container fetch — two of the four §3.9 sites,
2980    /// same origin as declared.
2981    #[tokio::test]
2982    async fn test_fetch_attaches_credential_on_service_index_and_flat_container() {
2983        let mut server = mockito::Server::new_async().await;
2984        let base = server.url();
2985        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
2986        let auth = NuGetAuth::new("user", "pat");
2987
2988        let _index = server
2989            .mock("GET", "/index.json")
2990            .match_header("authorization", auth.header_value())
2991            .with_status(200)
2992            .with_body(service_index_body(
2993                &format!("{base}/flat"),
2994                &format!("{base}/search"),
2995            ))
2996            .create_async()
2997            .await;
2998        let _flat = server
2999            .mock("GET", "/flat/pkg/index.json")
3000            .match_header("authorization", auth.header_value())
3001            .with_status(200)
3002            .with_body(r#"{"versions": ["1.0.0"]}"#)
3003            .create_async()
3004            .await;
3005
3006        let policy = all_policy();
3007        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3008        let client = NuGetRegistry::with_base(
3009            Arc::new(HttpCache::new()),
3010            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3011            &auth_hop(&feed, "corpfeed", "user", "pat"),
3012            Arc::clone(&policy),
3013            Vec::new(),
3014        );
3015
3016        let versions = client.get_versions_typed("pkg").await.unwrap();
3017        assert_eq!(versions.len(), 1);
3018        _index.assert_async().await;
3019        _flat.assert_async().await;
3020    }
3021
3022    /// SC-011: `search_typed` is covered by the same authenticated routing as the other three
3023    /// sites — a 401 on `SearchQueryService` (mocked here as a 200-with-header-assertion) must
3024    /// not be a site the credential skips.
3025    #[tokio::test]
3026    async fn test_fetch_attaches_credential_on_search_query_service() {
3027        let mut server = mockito::Server::new_async().await;
3028        let base = server.url();
3029        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3030        let auth = NuGetAuth::new("user", "pat");
3031
3032        let _index = server
3033            .mock("GET", "/index.json")
3034            .with_status(200)
3035            .with_body(service_index_body(
3036                &format!("{base}/flat"),
3037                &format!("{base}/search"),
3038            ))
3039            .create_async()
3040            .await;
3041        let _search = server
3042            .mock("GET", "/search")
3043            .match_query(mockito::Matcher::Any)
3044            .match_header("authorization", auth.header_value())
3045            .with_status(200)
3046            .with_body(r#"{"data": []}"#)
3047            .create_async()
3048            .await;
3049
3050        let policy = all_policy();
3051        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3052        let client = NuGetRegistry::with_base(
3053            Arc::new(HttpCache::new()),
3054            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3055            &auth_hop(&feed, "corpfeed", "user", "pat"),
3056            Arc::clone(&policy),
3057            Vec::new(),
3058        );
3059
3060        let results = client.search_typed("query", 10).await.unwrap();
3061        assert!(results.is_empty());
3062        _search.assert_async().await;
3063    }
3064
3065    /// SC-011: the registration-hive fetch site (§3.9's third `Self::fetch` call site, inside
3066    /// `get_versions_typed_with`) **and** its external-page-walk fetch (inside
3067    /// `registration_enrichment_from_index`) both attach the credential — distinct from the
3068    /// service-index/flat-container coverage above, since a call-site-local regression at
3069    /// either (wrong hop or trusted-prefix passed into that specific `self.fetch` call) would
3070    /// otherwise go undetected.
3071    #[tokio::test]
3072    async fn test_fetch_attaches_credential_on_registration_hive() {
3073        let mut server = mockito::Server::new_async().await;
3074        let base = server.url();
3075        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3076        let auth = NuGetAuth::new("user", "pat");
3077        let reg_base = format!("{base}/registrations");
3078
3079        let _index = server
3080            .mock("GET", "/index.json")
3081            .match_header("authorization", auth.header_value())
3082            .with_status(200)
3083            .with_body(service_index_body_with_registrations(
3084                &format!("{base}/flat"),
3085                &format!("{base}/search"),
3086                &reg_base,
3087            ))
3088            .create_async()
3089            .await;
3090        let _flat = server
3091            .mock("GET", "/flat/pkg/index.json")
3092            .match_header("authorization", auth.header_value())
3093            .with_status(200)
3094            .with_body(r#"{"versions": ["1.0.0"]}"#)
3095            .create_async()
3096            .await;
3097        let index_body = format!(
3098            r#"{{"count": 1, "items": [{{"@id": "{reg_base}/pkg/page/0.json", "count": 1}}]}}"#
3099        );
3100        let _reg = server
3101            .mock("GET", "/registrations/pkg/index.json")
3102            .match_header("authorization", auth.header_value())
3103            .with_status(200)
3104            .with_body(index_body)
3105            .create_async()
3106            .await;
3107        let _reg_page = server
3108            .mock("GET", "/registrations/pkg/page/0.json")
3109            .match_header("authorization", auth.header_value())
3110            .with_status(200)
3111            .with_body(
3112                r#"{"items": [{"catalogEntry": {"version": "1.0.0", "published": "2020-01-01T00:00:00Z"}}]}"#,
3113            )
3114            .create_async()
3115            .await;
3116
3117        let policy = all_policy();
3118        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3119        let client = NuGetRegistry::with_base(
3120            Arc::new(HttpCache::new()),
3121            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3122            &auth_hop(&feed, "corpfeed", "user", "pat"),
3123            Arc::clone(&policy),
3124            Vec::new(),
3125        );
3126
3127        let versions = client.get_versions_typed_with("pkg", true).await.unwrap();
3128        assert_eq!(versions.len(), 1);
3129        assert!(versions[0].published_at.is_some());
3130        _index.assert_async().await;
3131        _flat.assert_async().await;
3132        _reg.assert_async().await;
3133        _reg_page.assert_async().await;
3134    }
3135
3136    /// SC-011: `unlisted_versions_for_hover`'s registration-hive fetch (§3.9's fourth site)
3137    /// also attaches the credential — a distinct call site from
3138    /// `get_versions_typed_with`'s, per hover's own dedicated enrichment path.
3139    #[tokio::test]
3140    async fn test_fetch_attaches_credential_on_unlisted_versions_for_hover() {
3141        let mut server = mockito::Server::new_async().await;
3142        let base = server.url();
3143        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3144        let auth = NuGetAuth::new("user", "pat");
3145        let reg_base = format!("{base}/registrations");
3146
3147        let _index = server
3148            .mock("GET", "/index.json")
3149            .match_header("authorization", auth.header_value())
3150            .with_status(200)
3151            .with_body(service_index_body_with_registrations(
3152                &format!("{base}/flat"),
3153                &format!("{base}/search"),
3154                &reg_base,
3155            ))
3156            .create_async()
3157            .await;
3158        let _reg = server
3159            .mock("GET", "/registrations/pkg/index.json")
3160            .match_header("authorization", auth.header_value())
3161            .with_status(200)
3162            .with_body(r#"{"count": 0, "items": []}"#)
3163            .create_async()
3164            .await;
3165
3166        let policy = all_policy();
3167        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3168        let client = NuGetRegistry::with_base(
3169            Arc::new(HttpCache::new()),
3170            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3171            &auth_hop(&feed, "corpfeed", "user", "pat"),
3172            Arc::clone(&policy),
3173            Vec::new(),
3174        );
3175
3176        let unlisted = client.unlisted_versions_for_hover("pkg").await.unwrap();
3177        assert!(unlisted.is_empty());
3178        _index.assert_async().await;
3179        _reg.assert_async().await;
3180    }
3181
3182    /// SC-003/FR-010: a compromised service index resolving `PackageBaseAddress` off the
3183    /// declared origin gets the flat-container request served, but never with the credential
3184    /// attached — `Matcher::Missing` fails the mock (and so the test) if an `Authorization`
3185    /// header is present.
3186    #[tokio::test]
3187    async fn test_fetch_withholds_credential_when_resolved_resource_is_off_origin() {
3188        let mut declared = mockito::Server::new_async().await;
3189        let mut attacker = mockito::Server::new_async().await;
3190        let attacker_base = attacker.url();
3191
3192        let _index = declared
3193            .mock("GET", "/index.json")
3194            .with_status(200)
3195            .with_body(service_index_body(
3196                &format!("{attacker_base}/flat"),
3197                &format!("{attacker_base}/search"),
3198            ))
3199            .create_async()
3200            .await;
3201        let _attacker_flat = attacker
3202            .mock("GET", "/flat/pkg/index.json")
3203            .match_header("authorization", mockito::Matcher::Missing)
3204            .with_status(200)
3205            .with_body(r#"{"versions": ["1.0.0"]}"#)
3206            .create_async()
3207            .await;
3208
3209        let policy = all_policy();
3210        let feed = NuGetFeedUrl::new(&format!("{}/index.json", declared.url()), &policy).unwrap();
3211        let client = NuGetRegistry::with_base(
3212            Arc::new(HttpCache::new()),
3213            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3214            &auth_hop(&feed, "corpfeed", "user", "pat"),
3215            Arc::clone(&policy),
3216            Vec::new(),
3217        );
3218
3219        let versions = client.get_versions_typed("pkg").await.unwrap();
3220        assert_eq!(versions.len(), 1);
3221        _index.assert_async().await;
3222        _attacker_flat.assert_async().await;
3223    }
3224
3225    /// SC-008/FR-016: a credential rotation on an already-registered chain replaces the head
3226    /// client in place even when `alternates` is at `MAX_ALTERNATE_REGISTRIES` — the replace
3227    /// arm must not be gated by the capacity check that governs only the Vacant-insertion arm.
3228    #[tokio::test]
3229    async fn test_register_chain_credential_rotation_at_capacity_replaces_in_place() {
3230        let mut server = mockito::Server::new_async().await;
3231        let base = server.url();
3232        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3233        let auth_v2 = NuGetAuth::new("user", "pat-v2");
3234
3235        let _index = server
3236            .mock("GET", "/index.json")
3237            .match_header("authorization", auth_v2.header_value())
3238            .with_status(200)
3239            .with_body(service_index_body(
3240                &format!("{base}/flat"),
3241                &format!("{base}/search"),
3242            ))
3243            .create_async()
3244            .await;
3245        let _flat = server
3246            .mock("GET", "/flat/pkg/index.json")
3247            .match_header("authorization", auth_v2.header_value())
3248            .with_status(200)
3249            .with_body(r#"{"versions": ["2.0.0"]}"#)
3250            .create_async()
3251            .await;
3252
3253        let policy = all_policy();
3254        let cache = Arc::new(HttpCache::new());
3255        let root = Arc::new(NuGetRegistry::with_service_index_url(
3256            Arc::clone(&cache),
3257            NUGET_ORG_INDEX_URL.to_string(),
3258        ));
3259        let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3260        let chain_key = "nuget-chain:rotation-test".to_string();
3261
3262        // Fill to one under capacity, register `chain_v1` to reach capacity exactly, then
3263        // rotate — proving the *replace* arm (occupied, differing digest) is reachable and
3264        // unblocked even when the map is genuinely at `MAX_ALTERNATE_REGISTRIES`.
3265        for i in 0..MAX_ALTERNATE_REGISTRIES - 1 {
3266            let dummy = NuGetRegistry::with_base(
3267                Arc::clone(&cache),
3268                &hop(&feed),
3269                Arc::clone(&policy),
3270                Vec::new(),
3271            );
3272            root.alternates
3273                .insert(format!("dummy-{i}"), Arc::new(dummy));
3274        }
3275        assert_eq!(root.alternates.len(), MAX_ALTERNATE_REGISTRIES - 1);
3276
3277        let chain_v1 = NuGetSourceChain {
3278            key: chain_key.clone(),
3279            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3280            hops: vec![auth_hop(&feed, "corpfeed", "user", "pat-v1")],
3281            implicit_public_fallback: false,
3282        };
3283        NuGetRegistry::register_chain(&root, &chain_v1, &policy);
3284        assert_eq!(
3285            root.alternates.len(),
3286            MAX_ALTERNATE_REGISTRIES,
3287            "the vacant-slot arm must still be allowed to insert its own new key up to the cap"
3288        );
3289
3290        let chain_v2 = NuGetSourceChain {
3291            key: chain_key.clone(),
3292            // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3293            hops: vec![auth_hop(&feed, "corpfeed", "user", "pat-v2")],
3294            implicit_public_fallback: false,
3295        };
3296        NuGetRegistry::register_chain(&root, &chain_v2, &policy);
3297        assert_eq!(
3298            root.alternates.len(),
3299            MAX_ALTERNATE_REGISTRIES,
3300            "the replace arm must not grow the map, and must not be blocked by the cap either"
3301        );
3302
3303        let client = root
3304            .alternate_client(&chain_key)
3305            .expect("chain must remain registered after rotation");
3306        let versions = client.get_versions_chained("pkg").await.unwrap();
3307        assert_eq!(versions[0].version.as_str(), "2.0.0");
3308        _index.assert_async().await;
3309        _flat.assert_async().await;
3310    }
3311}