Skip to main content

deps_cargo/
registry.rs

1//! crates.io registry client, and the [`CargoRegistry`] router dispatching a resolved
2//! alternate/private registry to its own [`crate::sparse::SparseIndexClient`].
3//!
4//! Provides access to crates.io via:
5//! - Sparse index protocol (<https://index.crates.io>) for version lookups
6//! - REST API (<https://crates.io/api/v1>) for search
7//!
8//! All HTTP requests are cached aggressively using ETag/Last-Modified headers.
9//!
10//! # Examples
11//!
12//! ```no_run
13//! use deps_cargo::CratesIoRegistry;
14//! use deps_core::HttpCache;
15//! use std::sync::Arc;
16//!
17//! #[tokio::main]
18//! async fn main() {
19//!     let cache = Arc::new(HttpCache::new());
20//!     let registry = CratesIoRegistry::new(cache);
21//!
22//!     let versions = registry.get_versions("serde").await.unwrap();
23//!     println!("Latest serde: {}", versions[0].num);
24//! }
25//! ```
26
27use crate::config::{AuthToken, RegistryIndex};
28use crate::sparse::SparseIndexClient;
29use crate::types::{CargoVersion, CrateInfo};
30use deps_core::parser::DependencySource;
31use deps_core::{DepsError, HttpCache, PackageName, Result};
32use semver::{Version, VersionReq};
33use serde::Deserialize;
34use std::any::Any;
35use std::sync::Arc;
36
37const SPARSE_INDEX_BASE: &str = "https://index.crates.io";
38const SEARCH_API_BASE: &str = "https://crates.io/api/v1";
39
40/// Display name for crates.io used in not-found and API-response error messages.
41pub const REGISTRY: &str = "crates.io";
42
43/// Base URL for crate pages on crates.io
44pub const CRATES_IO_URL: &str = "https://crates.io/crates";
45
46/// Returns the URL for a crate's page on crates.io.
47///
48/// Display link only, never fetched by this process — unlike the sparse-index
49/// registry-fetch URL (`sparse_index_path`), so it is deliberately not gated against a
50/// `.`/`..` name (see [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link
51/// scope split, #379).
52pub fn crate_url(name: &str) -> String {
53    format!("{CRATES_IO_URL}/{}", urlencoding::encode(name))
54}
55
56/// Client for interacting with crates.io registry.
57///
58/// Uses the sparse index protocol for fast version lookups (via [`SparseIndexClient`]) and
59/// the REST API for package search. All requests are cached via the provided HttpCache.
60#[derive(Clone)]
61pub struct CratesIoRegistry {
62    sparse: SparseIndexClient,
63    cache: Arc<HttpCache>,
64}
65
66impl CratesIoRegistry {
67    /// Creates a new registry client with the given HTTP cache.
68    pub fn new(cache: Arc<HttpCache>) -> Self {
69        Self {
70            sparse: SparseIndexClient::new(
71                RegistryIndex::builtin(SPARSE_INDEX_BASE),
72                Arc::clone(&cache),
73            ),
74            cache,
75        }
76    }
77
78    /// Fetches all versions for a crate from the sparse index.
79    ///
80    /// Returns versions sorted newest-first. Includes yanked versions.
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if:
85    /// - HTTP request fails
86    /// - Response body is invalid UTF-8
87    /// - JSON parsing fails
88    ///
89    /// # Examples
90    ///
91    /// ```no_run
92    /// # use deps_cargo::CratesIoRegistry;
93    /// # use deps_core::HttpCache;
94    /// # use std::sync::Arc;
95    /// # #[tokio::main]
96    /// # async fn main() {
97    /// let cache = Arc::new(HttpCache::new());
98    /// let registry = CratesIoRegistry::new(cache);
99    ///
100    /// let versions = registry.get_versions("serde").await.unwrap();
101    /// assert!(!versions.is_empty());
102    /// # }
103    /// ```
104    pub async fn get_versions(&self, name: &str) -> Result<Vec<CargoVersion>> {
105        self.sparse.get_versions(name).await
106    }
107
108    /// Like [`Self::get_versions`], but threads `freshness` through so a caller routing via
109    /// `CargoRegistry`'s `get_versions_for_source` crates.io fallback arms cannot silently
110    /// drop it if crates.io ever gains its own publish-time enrichment (issue #588 critic
111    /// M10) — today this is a pure pass-through, identical to [`Self::get_versions`], since
112    /// crates.io's sparse index carries no such enrichment yet.
113    pub async fn get_versions_with(
114        &self,
115        name: &str,
116        _freshness: deps_core::freshness::FreshnessSettings,
117    ) -> Result<Vec<CargoVersion>> {
118        self.get_versions(name).await
119    }
120
121    /// Finds the latest version matching the given semver requirement.
122    ///
123    /// Only returns non-yanked versions.
124    ///
125    /// # Errors
126    ///
127    /// Returns an error if:
128    /// - Version requirement string is invalid semver
129    /// - HTTP request fails
130    ///
131    /// # Examples
132    ///
133    /// ```no_run
134    /// # use deps_cargo::CratesIoRegistry;
135    /// # use deps_core::HttpCache;
136    /// # use std::sync::Arc;
137    /// # #[tokio::main]
138    /// # async fn main() {
139    /// let cache = Arc::new(HttpCache::new());
140    /// let registry = CratesIoRegistry::new(cache);
141    ///
142    /// let latest = registry.get_latest_matching("serde", "^1.0").await.unwrap();
143    /// assert!(latest.is_some());
144    /// # }
145    /// ```
146    pub async fn get_latest_matching(
147        &self,
148        name: &str,
149        req_str: &str,
150    ) -> Result<Option<CargoVersion>> {
151        self.sparse.get_latest_matching(name, req_str).await
152    }
153
154    /// Searches for crates by name/keywords.
155    ///
156    /// Returns up to `limit` results sorted by relevance.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if:
161    /// - HTTP request fails
162    /// - JSON parsing fails
163    ///
164    /// # Examples
165    ///
166    /// ```no_run
167    /// # use deps_cargo::CratesIoRegistry;
168    /// # use deps_core::HttpCache;
169    /// # use std::sync::Arc;
170    /// # #[tokio::main]
171    /// # async fn main() {
172    /// let cache = Arc::new(HttpCache::new());
173    /// let registry = CratesIoRegistry::new(cache);
174    ///
175    /// let results = registry.search("serde", 10).await.unwrap();
176    /// assert!(!results.is_empty());
177    /// # }
178    /// ```
179    pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<CrateInfo>> {
180        let url = format!(
181            "{}/crates?q={}&per_page={}&sort=downloads",
182            SEARCH_API_BASE,
183            urlencoding::encode(query),
184            limit
185        );
186
187        let data = self.cache.get_cached(&url).await?;
188        parse_search_response(&data)
189    }
190}
191
192/// Response from crates.io search API.
193#[derive(Deserialize)]
194struct SearchResponse {
195    crates: Vec<SearchCrate>,
196}
197
198/// Crate entry in search response.
199#[derive(Deserialize)]
200struct SearchCrate {
201    name: String,
202    #[serde(default)]
203    description: Option<String>,
204    #[serde(default)]
205    repository: Option<String>,
206    #[serde(default)]
207    documentation: Option<String>,
208    max_version: String,
209}
210
211/// Parses JSON response from crates.io search API.
212fn parse_search_response(data: &[u8]) -> Result<Vec<CrateInfo>> {
213    let response: SearchResponse = deps_core::parse_json_checked(data)?;
214
215    Ok(response
216        .crates
217        .into_iter()
218        .map(|c| CrateInfo {
219            name: c.name.into(),
220            description: c.description,
221            repository: c.repository,
222            documentation: c.documentation,
223            max_version: c.max_version.into(),
224        })
225        .collect())
226}
227
228/// Index-by-position pick of the latest version matching `req`, shared by
229/// [`CratesIoRegistry`] and [`CargoRegistry`] (both ultimately backed by the same sparse
230/// wire format, so the same `semver`-based selection logic applies regardless of which
231/// index served the list).
232fn select_latest_matching_impl(
233    versions: &[Box<dyn deps_core::Version>],
234    req: &deps_core::VersionReq,
235) -> Option<usize> {
236    if deps_core::is_existence_wildcard(req) {
237        return deps_core::select_latest_for_existence(versions, |v| v.as_ref());
238    }
239    let parsed_req: VersionReq = req.as_str().parse().ok()?;
240    versions.iter().position(|v| {
241        v.version_string()
242            .as_str()
243            .parse::<Version>()
244            .is_ok_and(|ver| parsed_req.matches(&ver) && !v.removal_status().blocks_resolution())
245    })
246}
247
248impl deps_core::Registry for CratesIoRegistry {
249    fn get_versions<'a>(
250        &'a self,
251        name: &'a deps_core::PackageName,
252    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
253        Box::pin(async move {
254            let versions = self.get_versions(name.as_str()).await?;
255            Ok(versions
256                .into_iter()
257                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
258                .collect())
259        })
260    }
261
262    fn get_latest_matching<'a>(
263        &'a self,
264        name: &'a deps_core::PackageName,
265        req: &'a deps_core::VersionReq,
266    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
267        Box::pin(async move {
268            let version = self
269                .get_latest_matching(name.as_str(), req.as_str())
270                .await?;
271            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
272        })
273    }
274
275    fn select_latest_matching(
276        &self,
277        versions: &[Box<dyn deps_core::Version>],
278        req: &deps_core::VersionReq,
279    ) -> Option<usize> {
280        select_latest_matching_impl(versions, req)
281    }
282
283    fn search<'a>(
284        &'a self,
285        query: &'a str,
286        limit: usize,
287    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
288        Box::pin(async move {
289            let results = self.search(query, limit).await?;
290            Ok(results
291                .into_iter()
292                .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
293                .collect())
294        })
295    }
296
297    fn as_any(&self) -> &dyn Any {
298        self
299    }
300}
301
302/// A [`deps_core::Registry`] implementation over one [`SparseIndexClient`] directly — used
303/// for a resolved alternate registry, so completion (which needs a concrete `&dyn Registry`
304/// to hand to `deps_core::completion`'s shared generic helpers) can address one specific
305/// alternate index without going through [`CargoRegistry`]'s source-based dispatch.
306///
307/// `search` is always empty: the sparse index protocol has no search endpoint (spec
308/// Out-of-Scope), so an alternate registry can never support package-name completion —
309/// only crates.io's REST API does.
310impl deps_core::Registry for SparseIndexClient {
311    fn get_versions<'a>(
312        &'a self,
313        name: &'a deps_core::PackageName,
314    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
315        Box::pin(async move {
316            let versions = self.get_versions(name.as_str()).await?;
317            Ok(versions
318                .into_iter()
319                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
320                .collect())
321        })
322    }
323
324    fn get_latest_matching<'a>(
325        &'a self,
326        name: &'a deps_core::PackageName,
327        req: &'a deps_core::VersionReq,
328    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
329        Box::pin(async move {
330            let version = self
331                .get_latest_matching(name.as_str(), req.as_str())
332                .await?;
333            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
334        })
335    }
336
337    fn select_latest_matching(
338        &self,
339        versions: &[Box<dyn deps_core::Version>],
340        req: &deps_core::VersionReq,
341    ) -> Option<usize> {
342        select_latest_matching_impl(versions, req)
343    }
344
345    fn search<'a>(
346        &'a self,
347        _query: &'a str,
348        _limit: usize,
349    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
350        Box::pin(async move { Ok(Vec::new()) })
351    }
352
353    fn as_any(&self) -> &dyn Any {
354        self
355    }
356}
357
358/// Upper bound on [`CargoRegistry::alternates`]' entry count (spec NFR-007). Generous for
359/// any realistic `.cargo/config.toml` registry count; exists only to keep this DashMap,
360/// keyed by workspace-controlled URLs, from growing unbounded for the process lifetime.
361/// Once at capacity, a *new* index is simply never registered (see
362/// [`CargoRegistry::register_alternate`]) rather than evicted — a dependency on an
363/// unregistered alternate index degrades to [`DepsError::PackageNotFound`], never to a
364/// crates.io lookup by name.
365const MAX_ALTERNATE_REGISTRIES: usize = 256;
366
367/// Router in front of crates.io and every resolved alternate/private registry a workspace's
368/// `.cargo/config.toml` hierarchy names.
369///
370/// The value behind `CargoEcosystem::registry` (formerly a bare
371/// [`CratesIoRegistry`]). [`deps_core::Registry::get_versions`]/`get_latest_matching`/`search`
372/// (the source-blind trait methods) always mean crates.io, matching pre-1a behavior exactly
373/// (spec NFR-008); only the source-aware `get_versions_from`/`get_latest_matching_from`
374/// entry points route to an alternate index, and only for a
375/// [`DependencySource::AlternateRegistry`] source.
376///
377/// # Examples
378///
379/// ```no_run
380/// use deps_cargo::CargoRegistry;
381/// use deps_core::HttpCache;
382/// use std::sync::Arc;
383///
384/// let cache = Arc::new(HttpCache::new());
385/// let registry = CargoRegistry::new(cache);
386/// ```
387pub struct CargoRegistry {
388    crates_io: CratesIoRegistry,
389    /// Resolved alternate-registry clients, keyed by [`RegistryIndex::as_str`] (plan-1b
390    /// §1.2, critic S2: re-keyed from `RegistryIndex` to `String` so registration
391    /// (`Self::register_alternate`) and lookup (`Self::alternate_client`) both key off one
392    /// already-validated `RegistryIndex::as_str()`, symmetrically — a lookup no longer needs
393    /// to reconstruct and re-validate a `RegistryIndex` of its own, which would otherwise
394    /// need its own `IndexTrust`/policy argument just to answer a plain map lookup).
395    /// Populated by [`Self::register_alternate`] at parse time (see `crate::ecosystem`'s
396    /// `parse_manifest` override) — the *only* place an [`AuthToken`] is ever attached to a
397    /// client, since only that call site has the [`crate::config::CargoConfig`]/
398    /// [`crate::config::SourceReplacement`] resolution in hand. A fetch that lands here with
399    /// an unregistered index (never resolved, or dropped for capacity) has no way to
400    /// recover a token and must not fall back to crates.io unless the dependency is a
401    /// verified crates.io mirror (`mirrors_crates_io`, spec plan-1b §6 M2) — see
402    /// [`Self::get_versions_for_source`]/[`Self::get_latest_matching_for_source`].
403    alternates: dashmap::DashMap<String, Arc<SparseIndexClient>>,
404    cache: Arc<HttpCache>,
405}
406
407impl CargoRegistry {
408    /// Creates a new router with the given HTTP cache, backing both crates.io and every
409    /// alternate registry client this router later registers.
410    pub fn new(cache: Arc<HttpCache>) -> Self {
411        Self {
412            crates_io: CratesIoRegistry::new(Arc::clone(&cache)),
413            alternates: dashmap::DashMap::new(),
414            cache,
415        }
416    }
417
418    /// Registers (or reuses an existing registration for) `index`, with `auth` attached to
419    /// every request against it.
420    ///
421    /// A no-op when `index` is already registered — the first successful registration for
422    /// a given index URL sticks for the process lifetime; a later registration attempt
423    /// carrying a *different* `auth` for the same already-registered `index` is silently
424    /// ignored. This is a known, accepted limitation for a P4-priority feature (see
425    /// `ECOSYSTEM_GUIDE.md`): it can only matter if the same index URL is reachable through
426    /// two different resolution paths with different tokens across the process's lifetime,
427    /// which no current call site does.
428    ///
429    /// Also a no-op, with a `tracing::warn!`, once `MAX_ALTERNATE_REGISTRIES` is reached
430    /// and `index` is not already present (spec NFR-007) — the dependency stays
431    /// unregistered rather than evicting an existing, possibly still-in-use, client.
432    ///
433    /// Issue #455, C3: a re-registration of an *already-registered* index URL now folds to
434    /// the stricter of the stored and incoming [`crate::config::IndexTrust`] tier, dropping
435    /// any stored credential when the fold actually tightens — closing the shape where a
436    /// `Trusted`+token registration of URL X (from `$CARGO_HOME`) makes a later
437    /// `WorkspaceDeclared` registration of the same X a no-op, leaving a workspace-controlled
438    /// alias fetch through the looser `Trusted`-tier client. A re-registration that does not
439    /// tighten the tier keeps the existing client (and its credential) untouched — this is a
440    /// stricter, not weaker, version of the pre-existing idempotency contract this method's
441    /// summary line above documents.
442    pub fn register_alternate(&self, index: RegistryIndex, auth: Option<AuthToken>) {
443        // Read before `entry()`: `DashMap::len` read-locks every shard, and `entry()` holds a
444        // write guard on one — checking capacity from inside the `Vacant` arm below would
445        // self-deadlock on that shard.
446        let at_capacity = self.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
447        let key = index.as_str().to_string();
448        let incoming_trust = index.trust();
449        let index_display = index.to_string();
450
451        match self.alternates.entry(key) {
452            dashmap::mapref::entry::Entry::Occupied(mut slot) => {
453                let stored_trust = slot.get().trust();
454                let folded = stored_trust.min(incoming_trust);
455                if folded != stored_trust {
456                    tracing::warn!(
457                        index = %index_display,
458                        "re-registration folds an alternate registry to the stricter trust \
459                         tier; dropping any stored credential"
460                    );
461                    slot.insert(Arc::new(SparseIndexClient::with_auth(
462                        index,
463                        Arc::clone(&self.cache),
464                        None,
465                        "alternate registry",
466                    )));
467                }
468            }
469            dashmap::mapref::entry::Entry::Vacant(slot) => {
470                if at_capacity {
471                    tracing::warn!(
472                        index = %index_display,
473                        cap = MAX_ALTERNATE_REGISTRIES,
474                        "alternate registry cap reached; not registering a new index"
475                    );
476                    return;
477                }
478                slot.insert(Arc::new(SparseIndexClient::with_auth(
479                    index,
480                    Arc::clone(&self.cache),
481                    auth,
482                    "alternate registry",
483                )));
484            }
485        }
486    }
487
488    /// The registered client for `index`, if any — read-only, performs no registration, no
489    /// validation. A plain map lookup: `index` only ever originates from an already-validated
490    /// [`RegistryIndex::as_str`], on both sides — registration above, and a dependency's own
491    /// resolved `index` string (`crate::parser`) — so normalization stays symmetric with no
492    /// need to reconstruct a `RegistryIndex` (and the `IndexTrust`/policy that would require)
493    /// just to look one up (plan-1b §1.2, critic S2).
494    ///
495    /// Used by completion (`crate::ecosystem::CargoEcosystem::generate_completions`, FR-012)
496    /// to address one specific alternate index directly via [`deps_core::Registry`]'s
497    /// generic helpers, without going through this router's source-based dispatch.
498    #[must_use]
499    pub fn alternate_client(&self, index: &str) -> Option<Arc<SparseIndexClient>> {
500        self.alternates.get(index).map(|entry| Arc::clone(&entry))
501    }
502
503    async fn get_versions_for_source(
504        &self,
505        name: &PackageName,
506        source: &DependencySource,
507        freshness: deps_core::freshness::FreshnessSettings,
508    ) -> Result<Vec<CargoVersion>> {
509        match source {
510            DependencySource::AlternateRegistry {
511                index,
512                mirrors_crates_io,
513            } => match self.alternate_client(index) {
514                Some(client) => client.get_versions(name.as_str()).await,
515                // M2 (plan-1b §6): an unregistered *verified crates.io mirror* degrades to
516                // crates.io rather than blanking the whole manifest — correct for a mirror
517                // (Cargo verifies per-version checksum equality against crates.io for it),
518                // wrong for a genuinely private/unregistered registry, which must keep
519                // failing `PackageNotFound` below.
520                None if *mirrors_crates_io => {
521                    self.crates_io
522                        .get_versions_with(name.as_str(), freshness)
523                        .await
524                }
525                None => Err(DepsError::PackageNotFound {
526                    package: name.to_string(),
527                    registry: "alternate registry (not registered)",
528                }),
529            },
530            _ => {
531                self.crates_io
532                    .get_versions_with(name.as_str(), freshness)
533                    .await
534            }
535        }
536    }
537
538    async fn get_latest_matching_for_source(
539        &self,
540        name: &PackageName,
541        source: &DependencySource,
542        req: &deps_core::VersionReq,
543    ) -> Result<Option<CargoVersion>> {
544        match source {
545            DependencySource::AlternateRegistry {
546                index,
547                mirrors_crates_io,
548            } => match self.alternate_client(index) {
549                Some(client) => {
550                    client
551                        .get_latest_matching(name.as_str(), req.as_str())
552                        .await
553                }
554                // M2 (plan-1b §6, N4): the hover-fallback/background-fetch-mirror dispatch
555                // site needs the identical arm — this exact enumeration has been wrong twice
556                // during design review, so both sites are asserted independently in tests.
557                None if *mirrors_crates_io => {
558                    self.crates_io
559                        .get_latest_matching(name.as_str(), req.as_str())
560                        .await
561                }
562                None => Err(DepsError::PackageNotFound {
563                    package: name.to_string(),
564                    registry: "alternate registry (not registered)",
565                }),
566            },
567            _ => {
568                self.crates_io
569                    .get_latest_matching(name.as_str(), req.as_str())
570                    .await
571            }
572        }
573    }
574}
575
576impl deps_core::Registry for CargoRegistry {
577    fn get_versions<'a>(
578        &'a self,
579        name: &'a PackageName,
580    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
581        deps_core::Registry::get_versions(&self.crates_io, name)
582    }
583
584    fn get_versions_with<'a>(
585        &'a self,
586        name: &'a PackageName,
587        freshness: deps_core::freshness::FreshnessSettings,
588    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
589        deps_core::Registry::get_versions_with(&self.crates_io, name, freshness)
590    }
591
592    fn get_versions_from<'a>(
593        &'a self,
594        name: &'a PackageName,
595        source: &'a DependencySource,
596        freshness: deps_core::freshness::FreshnessSettings,
597    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
598        Box::pin(async move {
599            let versions = self
600                .get_versions_for_source(name, source, freshness)
601                .await?;
602            Ok(versions
603                .into_iter()
604                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
605                .collect())
606        })
607    }
608
609    fn get_latest_matching<'a>(
610        &'a self,
611        name: &'a PackageName,
612        req: &'a deps_core::VersionReq,
613    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
614        deps_core::Registry::get_latest_matching(&self.crates_io, name, req)
615    }
616
617    fn get_latest_matching_with_context<'a>(
618        &'a self,
619        name: &'a PackageName,
620        req: &'a deps_core::VersionReq,
621        minimum_stability: Option<&'a str>,
622    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
623        deps_core::Registry::get_latest_matching_with_context(
624            &self.crates_io,
625            name,
626            req,
627            minimum_stability,
628        )
629    }
630
631    fn get_latest_matching_from<'a>(
632        &'a self,
633        name: &'a PackageName,
634        source: &'a DependencySource,
635        req: &'a deps_core::VersionReq,
636        _minimum_stability: Option<&'a str>,
637    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
638        Box::pin(async move {
639            let version = self
640                .get_latest_matching_for_source(name, source, req)
641                .await?;
642            Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
643        })
644    }
645
646    fn select_latest_matching(
647        &self,
648        versions: &[Box<dyn deps_core::Version>],
649        req: &deps_core::VersionReq,
650    ) -> Option<usize> {
651        select_latest_matching_impl(versions, req)
652    }
653
654    /// Always crates.io — the sparse index protocol has no search endpoint, so this is
655    /// unreachable for an alternate source by construction (spec FR-001).
656    fn search<'a>(
657        &'a self,
658        query: &'a str,
659        limit: usize,
660    ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
661        deps_core::Registry::search(&self.crates_io, query, limit)
662    }
663
664    fn as_any(&self) -> &dyn Any {
665        self
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672    use crate::config::IndexTrust;
673    use deps_core::net_policy::RegistryAccessPolicy;
674    use std::assert_matches;
675    use std::collections::HashMap;
676
677    /// Wraps `raw` into a [`RegistryIndex`] for test call sites — see `sparse.rs`'s
678    /// identical helper for why an all-allow-equivalent (`Trusted`) policy is used.
679    fn test_index(raw: &str) -> RegistryIndex {
680        let policy = RegistryAccessPolicy::default();
681        RegistryIndex::new(raw, IndexTrust::Trusted, &policy).unwrap()
682    }
683
684    /// Like [`test_index`], but takes an explicit [`IndexTrust`] — issue #455's C3 fold test
685    /// needs to construct indices at both `Trusted` and `WorkspaceDeclared`, unlike every other
686    /// test in this module.
687    fn test_index_with_trust(raw: &str, trust: IndexTrust) -> RegistryIndex {
688        let policy = RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::All);
689        RegistryIndex::new(raw, trust, &policy).unwrap()
690    }
691
692    /// Live-network smoke test against the real crates.io search API. Restored (review
693    /// finding #8) after being dropped, undisclosed, during the `SparseIndexClient`
694    /// extraction — `search` stayed on `CratesIoRegistry`, so this test's home is
695    /// unchanged from before that refactor. `#[ignore]`d: not run in CI, only on demand.
696    #[tokio::test]
697    #[ignore]
698    async fn test_search_real() {
699        let cache = Arc::new(HttpCache::new());
700        let registry = CratesIoRegistry::new(cache);
701        let results = registry.search("serde", 5).await.unwrap();
702
703        assert!(!results.is_empty());
704        assert!(results.iter().any(|r| r.name == "serde"));
705    }
706
707    #[test]
708    fn test_parse_search_response() {
709        let json = r#"{
710            "crates": [
711                {
712                    "name": "serde",
713                    "description": "A serialization framework",
714                    "repository": "https://github.com/serde-rs/serde",
715                    "documentation": "https://docs.rs/serde",
716                    "max_version": "1.0.214"
717                }
718            ]
719        }"#;
720
721        let results = parse_search_response(json.as_bytes()).unwrap();
722        assert_eq!(results.len(), 1);
723        assert_eq!(results[0].name, "serde");
724        assert_eq!(results[0].max_version, "1.0.214");
725    }
726
727    #[test]
728    fn test_parse_search_response_empty() {
729        let json = r#"{"crates": []}"#;
730        let results = parse_search_response(json.as_bytes()).unwrap();
731        assert_eq!(results.len(), 0);
732    }
733
734    #[test]
735    fn test_parse_search_response_missing_optional_fields() {
736        let json = r#"{
737            "crates": [
738                {
739                    "name": "minimal",
740                    "max_version": "1.0.0"
741                }
742            ]
743        }"#;
744
745        let results = parse_search_response(json.as_bytes()).unwrap();
746        assert_eq!(results.len(), 1);
747        assert_eq!(results[0].name, "minimal");
748        assert_eq!(results[0].description, None);
749        assert_eq!(results[0].repository, None);
750    }
751
752    #[test]
753    fn test_parse_search_response_nesting_at_max_depth_accepted() {
754        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
755        let json = format!(
756            r#"{{"crates": [], "extra": {}1{}}}"#,
757            "[".repeat(depth - 1),
758            "]".repeat(depth - 1)
759        );
760        assert!(parse_search_response(json.as_bytes()).is_ok());
761    }
762
763    #[test]
764    fn test_parse_search_response_nesting_over_max_depth_rejected() {
765        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
766        let json = format!(
767            r#"{{"crates": [], "extra": {}1{}}}"#,
768            "[".repeat(depth),
769            "]".repeat(depth)
770        );
771        assert!(parse_search_response(json.as_bytes()).is_err());
772    }
773
774    #[test]
775    fn test_crate_url() {
776        assert_eq!(crate_url("serde"), "https://crates.io/crates/serde");
777        assert_eq!(crate_url("tokio"), "https://crates.io/crates/tokio");
778    }
779
780    #[test]
781    fn test_crate_url_with_hyphens() {
782        assert_eq!(
783            crate_url("serde-json"),
784            "https://crates.io/crates/serde-json"
785        );
786    }
787
788    #[test]
789    fn test_crate_url_encodes_malicious_name() {
790        let url = crate_url("evil](https://evil.example)[pkg");
791        assert!(!url.contains('('));
792        assert!(!url.contains(')'));
793        assert!(!url.contains('['));
794        assert!(!url.contains(']'));
795    }
796
797    #[test]
798    fn test_crate_url_encodes_newline_autolink_and_percent() {
799        let url = crate_url("evil\n<https://evil%zz.example>");
800        assert!(!url.contains('\n'));
801        assert!(!url.contains('<'));
802        assert!(!url.contains('>'));
803        assert!(url.contains("%25"));
804    }
805
806    #[test]
807    fn test_crate_url_empty_name() {
808        assert_eq!(crate_url(""), "https://crates.io/crates/");
809    }
810
811    #[tokio::test]
812    async fn test_registry_creation() {
813        let cache = Arc::new(HttpCache::new());
814        let _registry = CratesIoRegistry::new(cache);
815    }
816
817    #[tokio::test]
818    async fn test_registry_clone() {
819        let cache = Arc::new(HttpCache::new());
820        let registry = CratesIoRegistry::new(cache);
821        let _cloned = registry;
822    }
823
824    #[test]
825    fn test_select_latest_matching_not_default_none() {
826        use deps_core::{Registry, VersionReq};
827
828        let cache = Arc::new(HttpCache::new());
829        let registry = CratesIoRegistry::new(cache);
830        let versions: Vec<Box<dyn deps_core::Version>> = vec![
831            Box::new(CargoVersion {
832                num: "2.0.0".into(),
833                yanked: true,
834                features: HashMap::new(),
835                published_at: None,
836            }),
837            Box::new(CargoVersion {
838                num: "1.0.0".into(),
839                yanked: false,
840                features: HashMap::new(),
841                published_at: None,
842            }),
843        ];
844        let req = VersionReq::new("*");
845        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
846    }
847
848    #[test]
849    fn test_select_latest_matching_all_yanked_returns_newest_yanked() {
850        use deps_core::{Registry, VersionReq};
851
852        let cache = Arc::new(HttpCache::new());
853        let registry = CratesIoRegistry::new(cache);
854        let versions: Vec<Box<dyn deps_core::Version>> = vec![
855            Box::new(CargoVersion {
856                num: "2.0.0".into(),
857                yanked: true,
858                features: HashMap::new(),
859                published_at: None,
860            }),
861            Box::new(CargoVersion {
862                num: "1.0.0".into(),
863                yanked: true,
864                features: HashMap::new(),
865                published_at: None,
866            }),
867        ];
868        let req = VersionReq::new("*");
869        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
870    }
871
872    #[test]
873    fn test_select_latest_matching_all_prerelease_returns_newest_prerelease() {
874        use deps_core::{Registry, VersionReq};
875
876        let cache = Arc::new(HttpCache::new());
877        let registry = CratesIoRegistry::new(cache);
878        let versions: Vec<Box<dyn deps_core::Version>> = vec![
879            Box::new(CargoVersion {
880                num: "2.0.0-beta.1".into(),
881                yanked: false,
882                features: HashMap::new(),
883                published_at: None,
884            }),
885            Box::new(CargoVersion {
886                num: "1.0.0-alpha.1".into(),
887                yanked: false,
888                features: HashMap::new(),
889                published_at: None,
890            }),
891        ];
892        let req = VersionReq::new("*");
893        assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
894    }
895
896    #[tokio::test]
897    async fn test_cargo_registry_creation() {
898        let cache = Arc::new(HttpCache::new());
899        let _registry = CargoRegistry::new(cache);
900    }
901
902    #[tokio::test]
903    async fn test_cargo_registry_unregistered_alternate_returns_not_found() {
904        use deps_core::Registry;
905
906        let cache = Arc::new(HttpCache::new());
907        let registry = CargoRegistry::new(cache);
908        let source = DependencySource::AlternateRegistry {
909            index: "https://index.mycorp.dev".to_string(),
910            mirrors_crates_io: false,
911        };
912        let name = PackageName::new("internal-crate");
913        let result = registry
914            .get_versions_from(
915                &name,
916                &source,
917                deps_core::freshness::FreshnessSettings::default(),
918            )
919            .await;
920        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
921    }
922
923    /// Builds a `CargoRegistry` whose `crates_io` field points at `mockito_url` instead of
924    /// the real crates.io index — direct struct-literal construction (both `CratesIoRegistry`
925    /// and `CargoRegistry`'s fields are private, but this test module is a descendant of
926    /// their defining module) is the only way to make the M2 fallback observable without a
927    /// real network request.
928    fn cargo_registry_with_mocked_crates_io(
929        mockito_url: &str,
930        cache: Arc<HttpCache>,
931    ) -> CargoRegistry {
932        let sparse = SparseIndexClient::new(test_index(mockito_url), Arc::clone(&cache));
933        CargoRegistry {
934            crates_io: CratesIoRegistry {
935                sparse,
936                cache: Arc::clone(&cache),
937            },
938            alternates: dashmap::DashMap::new(),
939            cache,
940        }
941    }
942
943    /// M2 (plan-1b §6, N4), dispatch site 1 of 2: an unregistered `mirrors_crates_io: true`
944    /// source must fall back to crates.io through `get_versions_for_source` — a missed
945    /// registration degrades gracefully instead of blanking the whole manifest.
946    #[tokio::test]
947    async fn test_get_versions_for_source_unregistered_mirror_falls_back_to_crates_io() {
948        let mut server = mockito::Server::new_async().await;
949        let mock = server
950            .mock("GET", "/se/rd/serde")
951            .with_status(200)
952            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
953            .create_async()
954            .await;
955
956        let cache = Arc::new(HttpCache::new());
957        let registry = cargo_registry_with_mocked_crates_io(&server.url(), cache);
958
959        let source = DependencySource::AlternateRegistry {
960            index: "https://index.never-registered.example".to_string(),
961            mirrors_crates_io: true,
962        };
963        let name = PackageName::new("serde");
964        let versions = registry
965            .get_versions_for_source(
966                &name,
967                &source,
968                deps_core::freshness::FreshnessSettings::default(),
969            )
970            .await
971            .expect("an unregistered mirror must fall back to crates.io, not error");
972        assert_eq!(versions.len(), 1);
973        assert_eq!(versions[0].num, "1.0.0");
974        mock.assert_async().await;
975    }
976
977    /// Issue #588 critic M10: `get_versions_from` must not silently drop `freshness` for a
978    /// dependency routed through the default (plain crates.io) arm — a stub-registry test
979    /// that ignores `source` (the plan's originally proposed shape) is precisely the class
980    /// that would never catch this, since it never observes freshness at all. This asserts
981    /// against the real `CargoRegistry`/`CratesIoRegistry` call chain: `get_versions_from`
982    /// with a plain `Registry` source must reach the identical underlying fetch as
983    /// `get_versions_with` for the same package and freshness setting.
984    #[tokio::test]
985    async fn test_get_versions_from_threads_freshness_through_default_arm() {
986        use deps_core::Registry as _;
987
988        let mut server = mockito::Server::new_async().await;
989        let mock = server
990            .mock("GET", "/se/rd/serde")
991            .with_status(200)
992            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
993            .expect(2)
994            .create_async()
995            .await;
996
997        let cache = Arc::new(HttpCache::new());
998        let registry = cargo_registry_with_mocked_crates_io(&server.url(), cache);
999        let name = PackageName::new("serde");
1000        let freshness = deps_core::freshness::FreshnessSettings::default();
1001
1002        let via_from = registry
1003            .get_versions_from(&name, &DependencySource::Registry, freshness)
1004            .await
1005            .unwrap();
1006        let via_with = registry.get_versions_with(&name, freshness).await.unwrap();
1007
1008        assert_eq!(via_from.len(), via_with.len());
1009        assert_eq!(
1010            via_from[0].version_string().as_str(),
1011            via_with[0].version_string().as_str()
1012        );
1013        mock.assert_async().await;
1014    }
1015
1016    /// M2, dispatch site 2 of 2: the hover-fallback/background-fetch-mirror path
1017    /// (`get_latest_matching_for_source`) needs the identical arm — this exact enumeration
1018    /// has been wrong twice during design review, so both sites are asserted independently.
1019    #[tokio::test]
1020    async fn test_get_latest_matching_for_source_unregistered_mirror_falls_back_to_crates_io() {
1021        let mut server = mockito::Server::new_async().await;
1022        let mock = server
1023            .mock("GET", "/se/rd/serde")
1024            .with_status(200)
1025            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
1026            .create_async()
1027            .await;
1028
1029        let cache = Arc::new(HttpCache::new());
1030        let registry = cargo_registry_with_mocked_crates_io(&server.url(), cache);
1031
1032        let source = DependencySource::AlternateRegistry {
1033            index: "https://index.never-registered.example".to_string(),
1034            mirrors_crates_io: true,
1035        };
1036        let name = PackageName::new("serde");
1037        let req = deps_core::VersionReq::new("^1.0");
1038        let latest = registry
1039            .get_latest_matching_for_source(&name, &source, &req)
1040            .await
1041            .expect("an unregistered mirror must fall back to crates.io, not error");
1042        assert_eq!(latest.expect("a matching version").num, "1.0.0");
1043        mock.assert_async().await;
1044    }
1045
1046    /// The non-mirror counterpart: an unregistered, genuinely private (`mirrors_crates_io:
1047    /// false`) alternate index must keep failing `PackageNotFound` — the M2 fallback is
1048    /// scoped strictly to verified crates.io mirrors.
1049    #[tokio::test]
1050    async fn test_get_latest_matching_for_source_unregistered_non_mirror_stays_not_found() {
1051        let cache = Arc::new(HttpCache::new());
1052        let registry = CargoRegistry::new(cache);
1053        let source = DependencySource::AlternateRegistry {
1054            index: "https://index.never-registered.example".to_string(),
1055            mirrors_crates_io: false,
1056        };
1057        let name = PackageName::new("internal-crate");
1058        let req = deps_core::VersionReq::new("^1.0");
1059        let result = registry
1060            .get_latest_matching_for_source(&name, &source, &req)
1061            .await;
1062        assert_matches!(result, Err(DepsError::PackageNotFound { .. }));
1063    }
1064
1065    #[tokio::test]
1066    async fn test_cargo_registry_register_alternate_is_idempotent() {
1067        let cache = Arc::new(HttpCache::new());
1068        let registry = CargoRegistry::new(cache);
1069        let index = test_index("https://index.mycorp.dev");
1070
1071        registry.register_alternate(index.clone(), None);
1072        assert!(
1073            registry
1074                .alternate_client("https://index.mycorp.dev/")
1075                .is_some()
1076        );
1077
1078        // Re-registering the same index with a different auth must not replace the
1079        // already-registered client (documented limitation).
1080        registry.register_alternate(index, Some(AuthToken::new("token".to_string())));
1081        assert!(
1082            registry
1083                .alternate_client("https://index.mycorp.dev/")
1084                .is_some()
1085        );
1086    }
1087
1088    // Issue #455, test-plan item 10 (C3 fold): a `Trusted`+token registration of index X,
1089    // followed by a `WorkspaceDeclared` re-registration of the same X, folds the stored client
1090    // to `WorkspaceDeclared` and drops the credential — closing the shape where the old
1091    // `contains_key` no-op left the workspace alias fetching through the looser Trusted-tier
1092    // client.
1093    #[tokio::test]
1094    async fn test_cargo_registry_register_alternate_folds_to_stricter_trust_trusted_then_workspace()
1095    {
1096        let cache = Arc::new(HttpCache::new());
1097        let registry = CargoRegistry::new(cache);
1098        let url = "https://index.mycorp.dev";
1099
1100        registry.register_alternate(
1101            test_index_with_trust(url, IndexTrust::Trusted),
1102            Some(AuthToken::new("token".to_string())),
1103        );
1104        let client = registry
1105            .alternate_client("https://index.mycorp.dev/")
1106            .expect("registered");
1107        assert_eq!(client.trust(), IndexTrust::Trusted);
1108        assert!(client.has_auth());
1109
1110        registry.register_alternate(
1111            test_index_with_trust(url, IndexTrust::WorkspaceDeclared),
1112            None,
1113        );
1114        let client = registry
1115            .alternate_client("https://index.mycorp.dev/")
1116            .expect("still registered");
1117        assert_eq!(client.trust(), IndexTrust::WorkspaceDeclared);
1118        assert!(
1119            !client.has_auth(),
1120            "the fold must drop the stored credential"
1121        );
1122    }
1123
1124    // Reverse order: a `WorkspaceDeclared` registration first must not be loosened by a later
1125    // `Trusted` re-registration attempt for the same URL — the fold only ever moves toward
1126    // `WorkspaceDeclared`, never away from it.
1127    #[tokio::test]
1128    async fn test_cargo_registry_register_alternate_folds_to_stricter_trust_workspace_then_trusted()
1129    {
1130        let cache = Arc::new(HttpCache::new());
1131        let registry = CargoRegistry::new(cache);
1132        let url = "https://index.mycorp.dev";
1133
1134        registry.register_alternate(
1135            test_index_with_trust(url, IndexTrust::WorkspaceDeclared),
1136            None,
1137        );
1138        registry.register_alternate(
1139            test_index_with_trust(url, IndexTrust::Trusted),
1140            Some(AuthToken::new("token".to_string())),
1141        );
1142
1143        let client = registry
1144            .alternate_client("https://index.mycorp.dev/")
1145            .expect("still registered");
1146        assert_eq!(client.trust(), IndexTrust::WorkspaceDeclared);
1147        assert!(
1148            !client.has_auth(),
1149            "a WorkspaceDeclared registration must not gain a credential from a later Trusted \
1150             re-registration attempt"
1151        );
1152    }
1153
1154    // The cap regression guard (D2a): the hoisted capacity check must still see each of the
1155    // first MAX_ALTERNATE_REGISTRIES registrations as under capacity and the overflow one as
1156    // at capacity.
1157    #[tokio::test]
1158    async fn test_cargo_registry_alternate_cap_skips_registration() {
1159        let cache = Arc::new(HttpCache::new());
1160        let registry = CargoRegistry::new(cache);
1161        for i in 0..MAX_ALTERNATE_REGISTRIES {
1162            let index = test_index(&format!("https://index{i}.example"));
1163            registry.register_alternate(index, None);
1164        }
1165        assert_eq!(registry.alternates.len(), MAX_ALTERNATE_REGISTRIES);
1166
1167        let overflow = test_index("https://overflow.example");
1168        registry.register_alternate(overflow, None);
1169        assert_eq!(registry.alternates.len(), MAX_ALTERNATE_REGISTRIES);
1170        assert!(
1171            registry
1172                .alternate_client("https://overflow.example/")
1173                .is_none()
1174        );
1175    }
1176}