Skip to main content

deps_cargo/
sparse.rs

1//! Generic sparse-index registry client.
2//!
3//! Implements Cargo's sparse index wire protocol (RFC 2789), shared by crates.io itself
4//! and any alternate/private registry declared through `.cargo/config.toml`. Extracted
5//! from `registry.rs` so [`crate::registry::CratesIoRegistry`] and the alternate-registry
6//! router (`CargoRegistry`) both delegate to one implementation instead of duplicating the
7//! index-path computation, JSON-lines parsing, and crate-name safety gate.
8//!
9//! # Examples
10//!
11//! ```no_run
12//! use deps_cargo::config::{IndexTrust, RegistryIndex};
13//! use deps_cargo::sparse::SparseIndexClient;
14//! use deps_core::HttpCache;
15//! use deps_core::net_policy::RegistryAccessPolicy;
16//! use std::sync::Arc;
17//!
18//! #[tokio::main]
19//! async fn main() {
20//!     let cache = Arc::new(HttpCache::new());
21//!     let policy = RegistryAccessPolicy::default();
22//!     let index = RegistryIndex::new("https://index.crates.io", IndexTrust::Trusted, &policy).unwrap();
23//!     let client = SparseIndexClient::new(index, cache);
24//!
25//!     let versions = client.get_versions("serde").await.unwrap();
26//!     println!("Latest serde: {}", versions[0].num);
27//! }
28//! ```
29
30use crate::config::{AuthToken, IndexTrust, RegistryIndex};
31use crate::types::CargoVersion;
32use deps_core::{DepsError, HttpCache, Result, lsp_helpers::warn_rejected_value};
33use semver::{Version, VersionReq};
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::sync::Arc;
37
38/// Whether `name`'s character set matches crates.io's crate-name charset (ASCII
39/// alphanumeric, `-`, `_`), non-empty — stricter than
40/// [`deps_core::is_safe_package_name`], which permits `/`, `@`, `.`, `:`, `~` to
41/// accommodate other ecosystems' scoped/namespaced names. Deliberately carries no
42/// length bound of its own: [`is_safe_crate_name`] layers this crate's 128-byte
43/// URL-safety cap on top, while `deps_cargo::formatter::CargoFormatter`'s
44/// `validate_package_name` layers its own, stricter, diagnostic-accuracy length
45/// check on top instead — bundling a length bound into this predicate would make
46/// the latter unable to distinguish "bad charset" from "too long" for a name that
47/// is both charset-valid and longer than crates.io's real limit (#382 follow-up).
48pub(crate) fn is_safe_crate_name_charset(name: &str) -> bool {
49    !name.is_empty()
50        && name
51            .chars()
52            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_'))
53}
54
55/// Whether `name` is safe to splice into a sparse-index request URL:
56/// [`is_safe_crate_name_charset`] plus a 128-byte length bound. [`sparse_index_path`]
57/// performs no per-character encoding at all before splicing `name` into the request
58/// URL (unlike every other ecosystem crate, which `urlencoding::encode`s each
59/// segment): a `name` containing `/` or `.` is used as-is to build directory
60/// components, so a crafted name like `../../etc/passwd` can inject arbitrary path
61/// segments, not just the narrower exact-`.`/`..` case #341/#349/#357/#361 covered
62/// (#365 S1). The 128-byte bound exists only to keep a pathological request URL
63/// bounded — it is not crates.io's real publish-time length limit, so callers that
64/// need an accurate "too long" diagnostic (see `is_safe_crate_name_charset`'s docs)
65/// must not reuse this function for that purpose.
66pub(crate) fn is_safe_crate_name(name: &str) -> bool {
67    is_safe_crate_name_charset(name) && name.len() <= 128
68}
69
70/// Rejects a `name` outside crates.io's crate-name charset before it would reach
71/// [`sparse_index_path`], as `DepsError::PackageNotFound`.
72fn reject_unsafe_crate_name(name: &str, registry_display_name: &'static str) -> Result<()> {
73    if !is_safe_crate_name(name) {
74        warn_rejected_value("is_safe_crate_name", "sparse index request URL", name);
75        return Err(DepsError::PackageNotFound {
76            package: name.to_string(),
77            registry: registry_display_name,
78        });
79    }
80    Ok(())
81}
82
83/// Converts a crate name to its sparse index path.
84///
85/// Based on Cargo RFC 2789 specification:
86/// - 1 char: "1/{name}"
87/// - 2 chars: "2/{name}"
88/// - 3 chars: "3/{first_char}/{name}"
89/// - 4+ chars: "{first_2}/{next_2}/{name}"
90///
91/// Path segments are computed from the crate name's **character** count and
92/// positions, not byte length/offsets — crate names may contain multi-byte
93/// UTF-8 characters, and byte-index slicing could land mid-character and panic.
94/// An empty name has no length-based segment and returns the empty string.
95///
96/// Callers must additionally run [`reject_unsafe_crate_name`] first: this function performs
97/// no charset/encoding validation of `name` at all, so an unchecked `name` (any character,
98/// including `.`/`..` or an embedded `/`) reaches this unfiltered and can inject arbitrary
99/// path segments once spliced into the request URL (#365 S1) — the char-safe indexing above
100/// only prevents a panic (#376), it does not validate `name`.
101fn sparse_index_path(name: &str) -> String {
102    let name_lower = name.to_lowercase();
103    let chars: Vec<char> = name_lower.chars().collect();
104
105    match chars.len() {
106        0 => name_lower,
107        1 => {
108            let mut path = String::with_capacity(2 + name_lower.len());
109            path.push_str("1/");
110            path.push_str(&name_lower);
111            path
112        }
113        2 => {
114            let mut path = String::with_capacity(2 + name_lower.len());
115            path.push_str("2/");
116            path.push_str(&name_lower);
117            path
118        }
119        3 => {
120            let mut path = String::with_capacity(4 + name_lower.len());
121            path.push_str("3/");
122            path.push(chars[0]);
123            path.push('/');
124            path.push_str(&name_lower);
125            path
126        }
127        _ => {
128            let mut path = String::with_capacity(6 + name_lower.len());
129            path.extend(chars[0..2].iter());
130            path.push('/');
131            path.extend(chars[2..4].iter());
132            path.push('/');
133            path.push_str(&name_lower);
134            path
135        }
136    }
137}
138
139/// Builds the sparse index request URL for a crate's version metadata, against
140/// `base_url` (no trailing slash assumed either way). Callers must run
141/// [`reject_unsafe_crate_name`] first — [`sparse_index_path`] performs no encoding or
142/// rejection of `name` at all, so an unchecked `name` (any character, including `.`/`..`
143/// or an embedded `/`) reaches this unfiltered.
144fn sparse_index_url(base_url: &str, name: &str) -> String {
145    let path = sparse_index_path(name);
146    let base = base_url.trim_end_matches('/');
147    let mut url = String::with_capacity(base.len() + 1 + path.len());
148    url.push_str(base);
149    url.push('/');
150    url.push_str(&path);
151    url
152}
153
154/// Entry in the sparse index (one line of newline-delimited JSON).
155#[derive(Deserialize)]
156struct IndexEntry {
157    #[serde(rename = "vers")]
158    version: String,
159    #[serde(default)]
160    yanked: bool,
161    #[serde(default)]
162    features: HashMap<String, Vec<String>>,
163    /// Publish timestamp (RFC 3339, e.g. `"2026-07-18T23:05:13Z"`).
164    ///
165    /// Absent on index entries older than the sparse index's rollout of this
166    /// field; `#[serde(default)]` keeps such lines parseable.
167    #[serde(default)]
168    pubtime: Option<String>,
169}
170
171/// Parses newline-delimited JSON from a sparse index.
172fn parse_index_json(data: &[u8]) -> Result<Vec<CargoVersion>> {
173    let content = std::str::from_utf8(data)
174        .map_err(|e| DepsError::CacheError(format!("Invalid UTF-8: {e}")))?;
175
176    // Parse versions once and cache the parsed Version for sorting
177    let mut versions_with_parsed: Vec<(CargoVersion, Version)> = content
178        .lines()
179        .filter(|line| !line.trim().is_empty())
180        .filter_map(|line| {
181            let entry: IndexEntry = deps_core::parse_json_checked(line.as_bytes()).ok()?;
182            let parsed = entry.version.parse::<Version>().ok()?;
183            let published_at = entry
184                .pubtime
185                .as_deref()
186                .and_then(deps_core::PublishTime::parse_rfc3339);
187            Some((
188                CargoVersion {
189                    num: entry.version.into(),
190                    yanked: entry.yanked,
191                    features: entry.features,
192                    published_at,
193                },
194                parsed,
195            ))
196        })
197        .collect();
198
199    // Sort using already-parsed versions (newest first)
200    versions_with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
201
202    // Extract sorted versions
203    Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
204}
205
206/// Client for one sparse-index registry — crates.io's own index or an alternate/private
207/// one resolved from `.cargo/config.toml`.
208///
209/// Owns `base_url`/`sparse_index_path`/`parse_index_json` (moved from the pre-existing
210/// crates.io-only client, not reimplemented), so every registry speaking this protocol
211/// shares one parser and one crate-name safety gate.
212#[derive(Clone)]
213pub struct SparseIndexClient {
214    base_url: String,
215    cache: Arc<HttpCache>,
216    /// Bearer token attached to every request, when present. See
217    /// [`crate::config::ResolvedRegistryEntry::auth`] for the security invariant on how
218    /// this is populated — this client has no opinion on that; it just attaches whatever
219    /// it is given, over an origin-pinned transport ([`deps_core::HttpCache::get_cached_trusted_origin_with_headers`])
220    /// so the header cannot survive a cross-origin redirect.
221    auth: Option<AuthToken>,
222    /// The [`IndexTrust`] tier `index` was validated under (issue #455, C2): governs which
223    /// transport [`Self::fetch`] routes through — a `WorkspaceDeclared` index always goes
224    /// through [`deps_core::HttpCache::get_cached_workspace`], regardless of whether `auth` is
225    /// set, so its connect-time address is scrutinized by the live workspace-registry policy.
226    trust: IndexTrust,
227    /// Display name used in [`deps_core::DepsError::PackageNotFound`] messages.
228    registry_display_name: &'static str,
229}
230
231impl SparseIndexClient {
232    /// Creates a new unauthenticated sparse-index client for `index`.
233    ///
234    /// Takes a validated [`RegistryIndex`], not a bare `String` (plan-1b §1.2, critic S2):
235    /// `RegistryIndex::new`'s [`crate::config::IndexTrust`]/policy gate is the *only* public
236    /// constructor of a fetchable index URL, so this client cannot be built with one that
237    /// skipped it.
238    pub fn new(index: RegistryIndex, cache: Arc<HttpCache>) -> Self {
239        Self {
240            trust: index.trust(),
241            base_url: index.as_str().to_string(),
242            cache,
243            auth: None,
244            registry_display_name: "sparse index",
245        }
246    }
247
248    /// Creates a new sparse-index client for `index`, attaching `auth` (if any) to
249    /// every request as a `Bearer` `Authorization` header, and using
250    /// `registry_display_name` in not-found error messages.
251    pub fn with_auth(
252        index: RegistryIndex,
253        cache: Arc<HttpCache>,
254        auth: Option<AuthToken>,
255        registry_display_name: &'static str,
256    ) -> Self {
257        Self {
258            trust: index.trust(),
259            base_url: index.as_str().to_string(),
260            cache,
261            auth,
262            registry_display_name,
263        }
264    }
265
266    /// The [`IndexTrust`] tier this client's index was validated under.
267    #[must_use]
268    pub(crate) const fn trust(&self) -> IndexTrust {
269        self.trust
270    }
271
272    /// Whether this client attaches a credential to its requests. Test/diagnostic-only: private
273    /// fields are not visible from `registry.rs`, a sibling module, so `crate::registry`'s C3
274    /// fold test needs this accessor to assert a credential was dropped, alongside
275    /// [`Self::trust`].
276    #[cfg(test)]
277    pub(crate) fn has_auth(&self) -> bool {
278        self.auth.is_some()
279    }
280
281    /// Fetches all versions for a crate from the sparse index.
282    ///
283    /// Returns versions sorted newest-first. Includes yanked versions.
284    ///
285    /// # Errors
286    ///
287    /// Returns an error if:
288    /// - HTTP request fails
289    /// - Response body is invalid UTF-8
290    /// - JSON parsing fails
291    ///
292    /// # Examples
293    ///
294    /// ```no_run
295    /// # use deps_cargo::sparse::SparseIndexClient;
296    /// # use deps_core::HttpCache;
297    /// # use std::sync::Arc;
298    /// # #[tokio::main]
299    /// # async fn main() {
300    /// let cache = Arc::new(HttpCache::new());
301    /// let policy = deps_core::net_policy::RegistryAccessPolicy::default();
302    /// let index = deps_cargo::config::RegistryIndex::new(
303    ///     "https://index.crates.io",
304    ///     deps_cargo::config::IndexTrust::Trusted,
305    ///     &policy,
306    /// ).unwrap();
307    /// let client = SparseIndexClient::new(index, cache);
308    ///
309    /// let versions = client.get_versions("serde").await.unwrap();
310    /// assert!(!versions.is_empty());
311    /// # }
312    /// ```
313    pub async fn get_versions(&self, name: &str) -> Result<Vec<CargoVersion>> {
314        reject_unsafe_crate_name(name, self.registry_display_name)?;
315        let url = sparse_index_url(&self.base_url, name);
316        let data = self.fetch(&url).await?;
317        parse_index_json(&data)
318    }
319
320    /// Finds the latest version matching the given semver requirement.
321    ///
322    /// Only returns non-yanked versions.
323    ///
324    /// # Errors
325    ///
326    /// Returns an error if:
327    /// - Version requirement string is invalid semver
328    /// - HTTP request fails
329    pub async fn get_latest_matching(
330        &self,
331        name: &str,
332        req_str: &str,
333    ) -> Result<Option<CargoVersion>> {
334        let versions = self.get_versions(name).await?;
335
336        let req = req_str
337            .parse::<VersionReq>()
338            .map_err(|e| DepsError::InvalidVersionReq(e.to_string()))?;
339
340        Ok(versions.into_iter().find(|v| {
341            let version = v.num.as_str().parse::<Version>().ok();
342            version.is_some_and(|ver| req.matches(&ver) && !v.yanked)
343        }))
344    }
345
346    /// Routes the request through the transport matching [`Self::auth`] and [`Self::trust`]
347    /// — the sole call site deciding between [`deps_core::HttpCache::get_cached`],
348    /// [`deps_core::HttpCache::get_cached_trusted_origin_with_headers`], and (issue #455)
349    /// [`deps_core::HttpCache::get_cached_workspace`], so the two
350    /// [`Self::get_versions`]/pagination-free shape of this client never duplicates that
351    /// branch.
352    ///
353    /// `(Some(_), WorkspaceDeclared)` is a fail-closed arm, not a routed request: every current
354    /// [`RegistryIndex`] producer already prevents a `WorkspaceDeclared` index from carrying a
355    /// credential (`config::finalize_source_replacement` drops it on a folded chain,
356    /// `resolve_cargo_home_tier` only ever produces `Trusted`, and a plain workspace
357    /// `[registries]`/`registry-index` resolution never attaches one) — this arm is
358    /// defense-in-depth against a future producer regression, not a currently reachable path.
359    ///
360    /// # Errors
361    ///
362    /// Same as [`Self::get_versions`], plus `DepsError::CacheError` for the fail-closed arm.
363    async fn fetch(&self, url: &str) -> Result<bytes::Bytes> {
364        match (&self.auth, self.trust) {
365            (Some(token), IndexTrust::Trusted) => {
366                let header_value = format!("Bearer {}", token.expose_secret());
367                self.cache
368                    .get_cached_trusted_origin_with_headers(
369                        url,
370                        &self.base_url,
371                        &[(reqwest::header::AUTHORIZATION, header_value.as_str())],
372                    )
373                    .await
374            }
375            (None, IndexTrust::Trusted) => self.cache.get_cached(url).await,
376            (None, IndexTrust::WorkspaceDeclared) => self.cache.get_cached_workspace(url).await,
377            (Some(_), IndexTrust::WorkspaceDeclared) => {
378                tracing::error!(
379                    url = %self.base_url,
380                    "refusing to attach a credential to a workspace-declared registry index request"
381                );
382                Err(DepsError::CacheError(format!(
383                    "refusing authenticated request to workspace-declared index {}",
384                    self.base_url
385                )))
386            }
387        }
388    }
389}
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use deps_core::net_policy::RegistryAccessPolicy;
395    use std::assert_matches;
396
397    /// Wraps `raw` into a [`RegistryIndex`] for test call sites, using an all-allow policy
398    /// so a test's own choice of URL (including a loopback mockito URL) is never blocked —
399    /// the policy gate itself is unit-tested directly in `config.rs`, not re-exercised here.
400    fn test_index(raw: &str) -> RegistryIndex {
401        let policy = RegistryAccessPolicy::default();
402        RegistryIndex::new(raw, IndexTrust::Trusted, &policy).unwrap()
403    }
404
405    /// Like [`test_index`], but [`IndexTrust::WorkspaceDeclared`] — for issue #455's C2
406    /// fail-closed/routing tests, which need a `WorkspaceDeclared` index specifically.
407    fn test_workspace_index(raw: &str) -> RegistryIndex {
408        let policy = RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::All);
409        RegistryIndex::new(raw, IndexTrust::WorkspaceDeclared, &policy).unwrap()
410    }
411
412    /// Live-network smoke test against the real crates.io sparse index. Restored
413    /// (review finding #8) after being dropped, undisclosed, during the extraction of
414    /// this module out of `registry.rs`. `#[ignore]`d: not run in CI, only on demand.
415    #[tokio::test]
416    #[ignore]
417    async fn test_fetch_real_serde_versions() {
418        let cache = Arc::new(HttpCache::new());
419        let client = SparseIndexClient::new(test_index("https://index.crates.io"), cache);
420        let versions = client.get_versions("serde").await.unwrap();
421
422        assert!(!versions.is_empty());
423        assert!(versions.iter().any(|v| v.num.as_str().starts_with("1.")));
424    }
425
426    /// Live-network smoke test against the real crates.io sparse index. Restored
427    /// (review finding #8), same provenance as `test_fetch_real_serde_versions` above.
428    #[tokio::test]
429    #[ignore]
430    async fn test_get_latest_matching_real() {
431        let cache = Arc::new(HttpCache::new());
432        let client = SparseIndexClient::new(test_index("https://index.crates.io"), cache);
433        let latest = client.get_latest_matching("serde", "^1.0").await.unwrap();
434
435        assert!(latest.is_some());
436        let version = latest.unwrap();
437        assert!(version.num.as_str().starts_with("1."));
438        assert!(!version.yanked);
439    }
440
441    #[test]
442    fn test_sparse_index_path() {
443        assert_eq!(sparse_index_path("a"), "1/a");
444        assert_eq!(sparse_index_path("ab"), "2/ab");
445        assert_eq!(sparse_index_path("abc"), "3/a/abc");
446        assert_eq!(sparse_index_path("serde"), "se/rd/serde");
447        assert_eq!(sparse_index_path("tokio"), "to/ki/tokio");
448    }
449
450    #[test]
451    fn test_sparse_index_path_uppercase() {
452        assert_eq!(sparse_index_path("SERDE"), "se/rd/serde");
453    }
454
455    #[test]
456    fn test_reject_unsafe_crate_name_rejects_bare_dot_dot() {
457        assert!(reject_unsafe_crate_name("..", "crates.io").is_err());
458    }
459
460    #[test]
461    fn test_reject_unsafe_crate_name_rejects_bare_dot() {
462        assert!(reject_unsafe_crate_name(".", "crates.io").is_err());
463    }
464
465    #[test]
466    fn test_reject_unsafe_crate_name_rejects_embedded_slash() {
467        // S1 (impl-critic): `sparse_index_path` performs no per-character encoding, so a
468        // `/` anywhere in `name` (not just a bare `.`/`..`) can inject path segments once
469        // spliced into the request URL.
470        assert!(reject_unsafe_crate_name("../../etc/passwd", "crates.io").is_err());
471    }
472
473    #[test]
474    fn test_reject_unsafe_crate_name_accepts_normal_names() {
475        assert!(reject_unsafe_crate_name("serde", "crates.io").is_ok());
476        assert!(reject_unsafe_crate_name("serde_derive", "crates.io").is_ok());
477        assert!(reject_unsafe_crate_name("actix-web", "crates.io").is_ok());
478    }
479
480    /// #376: `sparse_index_path`'s byte-index slicing (`name_lower[0..2]` etc.) panics on a
481    /// non-ASCII crate name, since those indices can land mid-codepoint. `is_safe_crate_name`'s
482    /// ASCII-alphanumeric-only allowlist blocks any non-ASCII input before it reaches that
483    /// code, closing the panic as a side effect of the charset check — asserted directly here
484    /// so a future narrowing of the allowlist's *intent* (without touching this exact
485    /// assertion) can't silently reopen the panic with nothing to catch it.
486    #[test]
487    fn test_reject_unsafe_crate_name_rejects_non_ascii() {
488        assert!(reject_unsafe_crate_name("日本", "crates.io").is_err());
489    }
490
491    /// Demonstrates the vulnerability `reject_unsafe_crate_name` exists to prevent:
492    /// `sparse_index_url` alone (with no caller-side guard) builds a URL that, once parsed,
493    /// has escaped the sparse index root entirely.
494    #[test]
495    fn test_sparse_index_url_bare_dot_dot_normalizes_above_root() {
496        let url = sparse_index_url("https://index.crates.io", "..");
497        let parsed = url::Url::parse(&url).unwrap();
498        assert_eq!(parsed.path(), "/", "parsed path: {}", parsed.path());
499    }
500
501    /// Demonstrates the broader S1 vulnerability: an embedded `/` (not just a bare
502    /// `.`/`..`) lets `sparse_index_url` alone build a URL whose path escapes to an
503    /// attacker-influenced location, since no character in `name` is encoded.
504    #[test]
505    fn test_sparse_index_url_embedded_slash_escapes_root() {
506        let url = sparse_index_url("https://index.crates.io", "../../etc/passwd");
507        let parsed = url::Url::parse(&url).unwrap();
508        assert_eq!(
509            parsed.path(),
510            "/etc/passwd",
511            "parsed path: {}",
512            parsed.path()
513        );
514    }
515
516    /// #365 regression sweep: exercises the real production `reject_unsafe_crate_name`
517    /// gate and `sparse_index_url` sink together against the shared adversarial input set.
518    /// Every entry is rejected by the charset-only gate before reaching the sink (none of
519    /// `ADVERSARIAL_URL_SEGMENTS` is pure ASCII-alphanumeric/`-`/`_`), so this is a vacuous
520    /// but still forward-looking regression guard: it would start exercising the
521    /// host/prefix/survival assertions the moment the gate's charset is ever loosened.
522    #[test]
523    fn test_sparse_index_url_dot_segment_sweep() {
524        deps_core::test_util::assert_dot_segment_gated_or_contained(
525            |seg| {
526                reject_unsafe_crate_name(seg, "crates.io")
527                    .ok()
528                    .map(|()| sparse_index_url("https://index.crates.io", seg))
529            },
530            "index.crates.io",
531            "/",
532        );
533    }
534
535    #[test]
536    fn test_sparse_index_url_trims_trailing_slash_on_base() {
537        let with_slash = sparse_index_url("https://index.mycorp.dev/", "serde");
538        let without_slash = sparse_index_url("https://index.mycorp.dev", "serde");
539        assert_eq!(with_slash, without_slash);
540        assert_eq!(with_slash, "https://index.mycorp.dev/se/rd/serde");
541    }
542
543    #[test]
544    fn test_parse_index_json() {
545        let json = r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}
546{"name":"serde","vers":"1.0.1","yanked":false,"features":{"derive":["serde_derive"]},"deps":[]}"#;
547
548        let versions = parse_index_json(json.as_bytes()).unwrap();
549        assert_eq!(versions.len(), 2);
550        assert_eq!(versions[0].num, "1.0.1");
551        assert_eq!(versions[1].num, "1.0.0");
552        assert!(!versions[0].yanked);
553    }
554
555    #[test]
556    fn test_parse_index_json_with_yanked() {
557        let json = r#"{"name":"test","vers":"0.1.0","yanked":true,"features":{},"deps":[]}
558{"name":"test","vers":"0.2.0","yanked":false,"features":{},"deps":[]}"#;
559
560        let versions = parse_index_json(json.as_bytes()).unwrap();
561        assert_eq!(versions.len(), 2);
562        assert!(versions[1].yanked);
563        assert!(!versions[0].yanked);
564    }
565
566    #[test]
567    fn test_parse_index_json_empty() {
568        let json = "";
569        let versions = parse_index_json(json.as_bytes()).unwrap();
570        assert_eq!(versions.len(), 0);
571    }
572
573    #[test]
574    fn test_parse_index_json_blank_lines() {
575        let json = "\n\n\n";
576        let versions = parse_index_json(json.as_bytes()).unwrap();
577        assert_eq!(versions.len(), 0);
578    }
579
580    #[test]
581    fn test_parse_index_json_invalid_version() {
582        let json = r#"{"name":"test","vers":"invalid","yanked":false,"features":{},"deps":[]}"#;
583        let versions = parse_index_json(json.as_bytes()).unwrap();
584        assert_eq!(versions.len(), 0);
585    }
586
587    #[test]
588    fn test_parse_index_json_mixed_valid_invalid() {
589        let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{},"deps":[]}
590{"name":"test","vers":"invalid","yanked":false,"features":{},"deps":[]}
591{"name":"test","vers":"2.0.0","yanked":false,"features":{},"deps":[]}"#;
592
593        let versions = parse_index_json(json.as_bytes()).unwrap();
594        assert_eq!(versions.len(), 2);
595        assert_eq!(versions[0].num, "2.0.0");
596        assert_eq!(versions[1].num, "1.0.0");
597    }
598
599    #[test]
600    fn test_parse_index_json_with_pubtime() {
601        let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{},"deps":[],"pubtime":"2026-07-18T23:05:13Z"}"#;
602
603        let versions = parse_index_json(json.as_bytes()).unwrap();
604        assert_eq!(versions.len(), 1);
605        assert_eq!(
606            versions[0].published_at,
607            Some(deps_core::PublishTime::parse_rfc3339("2026-07-18T23:05:13Z").unwrap())
608        );
609    }
610
611    #[test]
612    fn test_parse_index_json_without_pubtime() {
613        let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#;
614
615        let versions = parse_index_json(json.as_bytes()).unwrap();
616        assert_eq!(versions.len(), 1);
617        assert!(versions[0].published_at.is_none());
618    }
619
620    #[test]
621    fn test_parse_index_json_with_malformed_pubtime() {
622        let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{},"deps":[],"pubtime":"not-a-timestamp"}"#;
623
624        let versions = parse_index_json(json.as_bytes()).unwrap();
625        assert_eq!(versions.len(), 1);
626        assert!(
627            versions[0].published_at.is_none(),
628            "malformed pubtime degrades to None, not an error"
629        );
630    }
631
632    #[test]
633    fn test_parse_index_json_with_features() {
634        let json = r#"{"name":"test","vers":"1.0.0","yanked":false,"features":{"default":["std"],"std":[]},"deps":[]}"#;
635
636        let versions = parse_index_json(json.as_bytes()).unwrap();
637        assert_eq!(versions.len(), 1);
638        assert_eq!(versions[0].features.len(), 2);
639        assert!(versions[0].features.contains_key("default"));
640        assert!(versions[0].features.contains_key("std"));
641    }
642
643    #[test]
644    fn test_parse_index_json_nesting_at_max_depth_accepted() {
645        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
646        let json = format!(
647            r#"{{"vers":"1.0.0","extra":{}1{}}}"#,
648            "[".repeat(depth - 1),
649            "]".repeat(depth - 1)
650        );
651        let versions = parse_index_json(json.as_bytes()).unwrap();
652        assert_eq!(versions.len(), 1);
653    }
654
655    #[test]
656    fn test_parse_index_json_nesting_over_max_depth_line_skipped() {
657        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
658        let json = format!(
659            r#"{{"vers":"1.0.0","extra":{}1{}}}"#,
660            "[".repeat(depth),
661            "]".repeat(depth)
662        );
663        let versions = parse_index_json(json.as_bytes()).unwrap();
664        assert_eq!(versions.len(), 0);
665    }
666
667    #[test]
668    fn test_sparse_index_path_single_char() {
669        assert_eq!(sparse_index_path("x"), "1/x");
670        assert_eq!(sparse_index_path("z"), "1/z");
671    }
672
673    #[test]
674    fn test_sparse_index_path_two_chars() {
675        assert_eq!(sparse_index_path("xy"), "2/xy");
676        assert_eq!(sparse_index_path("ab"), "2/ab");
677    }
678
679    #[test]
680    fn test_sparse_index_path_three_chars() {
681        assert_eq!(sparse_index_path("xyz"), "3/x/xyz");
682        assert_eq!(sparse_index_path("foo"), "3/f/foo");
683    }
684
685    #[test]
686    fn test_sparse_index_path_long_name() {
687        assert_eq!(
688            sparse_index_path("very-long-crate-name"),
689            "ve/ry/very-long-crate-name"
690        );
691    }
692
693    #[test]
694    fn test_sparse_index_path_numbers() {
695        assert_eq!(sparse_index_path("1234"), "12/34/1234");
696    }
697
698    #[test]
699    fn test_sparse_index_path_mixed_case() {
700        assert_eq!(sparse_index_path("MyPackage"), "my/pa/mypackage");
701        assert_eq!(sparse_index_path("UPPERCASE"), "up/pe/uppercase");
702    }
703
704    #[test]
705    fn test_sparse_index_path_multibyte_one_char() {
706        // "本" is 1 char / 3 bytes: byte-index slicing would panic here.
707        assert_eq!(sparse_index_path("本"), "1/本");
708    }
709
710    #[test]
711    fn test_sparse_index_path_multibyte_two_chars() {
712        // "日本" is 2 chars / 6 bytes.
713        assert_eq!(sparse_index_path("日本"), "2/日本");
714    }
715
716    #[test]
717    fn test_sparse_index_path_multibyte_three_chars() {
718        // "日本語" is 3 chars / 9 bytes. The old byte-length-keyed match would
719        // have routed this to the 4+ arm's `name_lower[0..2]`, panicking at
720        // byte index 2 (mid-character), not the 3-char arm.
721        assert_eq!(sparse_index_path("日本語"), "3/日/日本語");
722    }
723
724    #[test]
725    fn test_sparse_index_path_multibyte_four_plus_chars() {
726        // "日本ab" is 4 chars with multi-byte characters in the first two.
727        assert_eq!(sparse_index_path("日本ab"), "日本/ab/日本ab");
728    }
729
730    #[test]
731    fn test_sparse_index_path_empty_name() {
732        // 0 chars falls into the "4+" arm's slicing range; must not panic.
733        assert_eq!(sparse_index_path(""), "");
734    }
735
736    #[tokio::test]
737    async fn test_get_versions_rejects_bare_dot_dot_as_not_found() {
738        let client = SparseIndexClient::new(
739            test_index("https://index.crates.io"),
740            Arc::new(HttpCache::new()),
741        );
742        let err = client.get_versions("..").await.unwrap_err();
743        assert_matches!(err, DepsError::PackageNotFound { .. });
744    }
745
746    #[tokio::test]
747    async fn test_get_versions_rejects_embedded_slash_as_not_found() {
748        let client = SparseIndexClient::new(
749            test_index("https://index.crates.io"),
750            Arc::new(HttpCache::new()),
751        );
752        let err = client.get_versions("../../etc/passwd").await.unwrap_err();
753        assert_matches!(err, DepsError::PackageNotFound { .. });
754    }
755
756    #[tokio::test]
757    async fn test_get_versions_from_mocked_sparse_index() {
758        let mut server = mockito::Server::new_async().await;
759        let _m = server
760            .mock("GET", "/se/rd/serde")
761            .with_status(200)
762            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
763            .create_async()
764            .await;
765
766        let client = SparseIndexClient::new(test_index(&server.url()), Arc::new(HttpCache::new()));
767        let versions = client.get_versions("serde").await.unwrap();
768        assert_eq!(versions.len(), 1);
769        assert_eq!(versions[0].num, "1.0.0");
770    }
771
772    #[tokio::test]
773    async fn test_get_versions_with_auth_sends_authorization_header() {
774        let mut server = mockito::Server::new_async().await;
775        let _m = server
776            .mock("GET", "/se/rd/serde")
777            .match_header("authorization", "Bearer secret-token")
778            .with_status(200)
779            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
780            .create_async()
781            .await;
782
783        let client = SparseIndexClient::with_auth(
784            test_index(&server.url()),
785            Arc::new(HttpCache::new()),
786            Some(AuthToken::new("secret-token".to_string())),
787            "my-corp",
788        );
789        let versions = client.get_versions("serde").await.unwrap();
790        assert_eq!(versions.len(), 1);
791    }
792
793    // Issue #455, test-plan item 8 (C2 fail-closed): `(Some(auth), WorkspaceDeclared)` must
794    // never send a request at all — the mockito mock asserting `expect(0)` proves no request
795    // reached the network, not just that `get_versions` returned an error.
796    #[tokio::test]
797    async fn test_fetch_refuses_authenticated_workspace_declared_request() {
798        let mut server = mockito::Server::new_async().await;
799        let mock = server
800            .mock("GET", "/se/rd/serde")
801            .with_status(200)
802            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
803            .expect(0)
804            .create_async()
805            .await;
806
807        let client = SparseIndexClient::with_auth(
808            test_workspace_index(&server.url()),
809            Arc::new(HttpCache::new()),
810            Some(AuthToken::new("secret-token".to_string())),
811            "workspace index",
812        );
813        let err = client.get_versions("serde").await.unwrap_err();
814        assert_matches!(err, DepsError::CacheError(_));
815        mock.assert_async().await;
816    }
817
818    // Issue #455, test-plan item 9 (C2 routing): a successful `(None, WorkspaceDeclared)` fetch
819    // lands under the workspace cache-key namespace, not the baseline one — proven via the
820    // public `peek_cached` API (which only ever reads the baseline namespace) rather than by
821    // reaching into `HttpCache`'s private fields.
822    #[tokio::test]
823    async fn test_fetch_routes_workspace_declared_through_workspace_cache_namespace() {
824        let mut server = mockito::Server::new_async().await;
825        let _m = server
826            .mock("GET", "/se/rd/serde")
827            .with_status(200)
828            .with_body(r#"{"name":"serde","vers":"1.0.0","yanked":false,"features":{},"deps":[]}"#)
829            .create_async()
830            .await;
831
832        let cache = Arc::new(HttpCache::new());
833        let client =
834            SparseIndexClient::new(test_workspace_index(&server.url()), Arc::clone(&cache));
835        let versions = client.get_versions("serde").await.unwrap();
836        assert_eq!(versions.len(), 1);
837
838        let index_url = format!("{}/se/rd/serde", server.url());
839        assert!(
840            cache.peek_cached(&index_url).is_none(),
841            "a WorkspaceDeclared fetch must not land under the baseline (peek_cached-visible) \
842             cache-key namespace"
843        );
844    }
845
846    #[tokio::test]
847    async fn test_get_latest_matching_via_sparse_client() {
848        let mut server = mockito::Server::new_async().await;
849        let _m = server
850            .mock("GET", "/se/rd/serde")
851            .with_status(200)
852            .with_body(
853                "{\"name\":\"serde\",\"vers\":\"1.0.0\",\"yanked\":false,\"features\":{},\"deps\":[]}\n\
854                 {\"name\":\"serde\",\"vers\":\"2.0.0\",\"yanked\":false,\"features\":{},\"deps\":[]}",
855            )
856            .create_async()
857            .await;
858
859        let client = SparseIndexClient::new(test_index(&server.url()), Arc::new(HttpCache::new()));
860        let latest = client.get_latest_matching("serde", "^1.0").await.unwrap();
861        assert_eq!(latest.unwrap().num, "1.0.0");
862    }
863}