1use 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
38pub(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
55pub(crate) fn is_safe_crate_name(name: &str) -> bool {
67 is_safe_crate_name_charset(name) && name.len() <= 128
68}
69
70fn 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
83fn 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
139fn 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#[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 #[serde(default)]
168 pubtime: Option<String>,
169}
170
171fn 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 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 versions_with_parsed.sort_unstable_by(|a, b| b.1.cmp(&a.1));
201
202 Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
204}
205
206#[derive(Clone)]
213pub struct SparseIndexClient {
214 base_url: String,
215 cache: Arc<HttpCache>,
216 auth: Option<AuthToken>,
222 trust: IndexTrust,
227 registry_display_name: &'static str,
229}
230
231impl SparseIndexClient {
232 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 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 #[must_use]
268 pub(crate) const fn trust(&self) -> IndexTrust {
269 self.trust
270 }
271
272 #[cfg(test)]
277 pub(crate) fn has_auth(&self) -> bool {
278 self.auth.is_some()
279 }
280
281 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 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 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 fn test_index(raw: &str) -> RegistryIndex {
401 let policy = RegistryAccessPolicy::default();
402 RegistryIndex::new(raw, IndexTrust::Trusted, &policy).unwrap()
403 }
404
405 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 #[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 #[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 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 #[test]
487 fn test_reject_unsafe_crate_name_rejects_non_ascii() {
488 assert!(reject_unsafe_crate_name("日本", "crates.io").is_err());
489 }
490
491 #[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 #[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 #[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 assert_eq!(sparse_index_path("本"), "1/本");
708 }
709
710 #[test]
711 fn test_sparse_index_path_multibyte_two_chars() {
712 assert_eq!(sparse_index_path("日本"), "2/日本");
714 }
715
716 #[test]
717 fn test_sparse_index_path_multibyte_three_chars() {
718 assert_eq!(sparse_index_path("日本語"), "3/日/日本語");
722 }
723
724 #[test]
725 fn test_sparse_index_path_multibyte_four_plus_chars() {
726 assert_eq!(sparse_index_path("日本ab"), "日本/ab/日本ab");
728 }
729
730 #[test]
731 fn test_sparse_index_path_empty_name() {
732 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 #[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 #[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}