1use crate::config::{PypiIndexUrl, ResolvedChain};
13use crate::types::{PypiPackage, PypiVersion};
14use dashmap::DashMap;
15use deps_core::parser::DependencySource;
16use deps_core::{
17 DepsError, FreshnessSettings, HttpCache, Result, lsp_helpers::warn_rejected_value,
18};
19use pep440_rs::{Version, VersionSpecifiers};
20use serde::Deserialize;
21use std::any::Any;
22use std::future::Future;
23use std::str::FromStr;
24use std::sync::Arc;
25
26const PYPI_BASE: &str = "https://pypi.org/pypi";
27
28const PYPI_SIMPLE_BASE: &str = "https://pypi.org/simple";
30
31pub(crate) const SIMPLE_API_ACCEPT: &str = "application/vnd.pypi.simple.v1+json";
38
39pub const REGISTRY: &str = "PyPI";
41
42const MAX_ALTERNATE_REGISTRIES: usize = 256;
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum PypiRegistryTier {
59 Public,
62 WorkspaceDeclared,
67}
68
69pub const PYPI_URL: &str = "https://pypi.org/project";
71
72pub fn package_url(name: &str) -> String {
80 let normalized = crate::name::normalize(name);
81 if normalized.is_empty() {
82 warn_rejected_value(
83 "pypi_normalized_name_empty",
84 "PyPI package display URL",
85 name,
86 );
87 return String::new();
88 }
89 format!("{}/{}", PYPI_URL, urlencoding::encode(&normalized))
90}
91
92fn simple_api_url(base: &str, normalized: &str) -> String {
104 format!("{base}/{}/", urlencoding::encode(normalized))
105}
106
107fn metadata_url(normalized: &str) -> String {
116 format!("{PYPI_BASE}/{}/json", urlencoding::encode(normalized))
117}
118
119fn not_found_or(err: DepsError, name: &str) -> DepsError {
122 if matches!(err, DepsError::HttpStatus { status: 404, .. }) {
123 DepsError::PackageNotFound {
124 package: name.to_string(),
125 registry: REGISTRY,
126 }
127 } else {
128 err
129 }
130}
131
132fn package_stub(name: &str) -> PypiPackage {
143 PypiPackage {
144 name: deps_core::PackageName::new(name),
145 summary: None,
146 project_urls: Vec::new(),
147 latest_version: deps_core::ConcreteVersion::new(""),
148 }
149}
150
151#[derive(Clone)]
172pub struct PypiRegistry {
173 cache: Arc<HttpCache>,
174 index_url: String,
181 simple_base: String,
192 index: Arc<crate::search::IndexCell>,
197 tier: PypiRegistryTier,
199 alternates: Arc<DashMap<String, Arc<Self>>>,
208 fallback_chain: Vec<Arc<Self>>,
214}
215
216impl PypiRegistry {
217 pub fn new(cache: Arc<HttpCache>) -> Self {
219 Self::with_index_url(cache, crate::search::SIMPLE_INDEX_URL.to_string())
220 }
221
222 pub(crate) fn with_index_url(cache: Arc<HttpCache>, index_url: String) -> Self {
233 Self {
234 cache,
235 index_url,
236 simple_base: PYPI_SIMPLE_BASE.to_string(),
237 index: Arc::new(crate::search::IndexCell::new()),
238 tier: PypiRegistryTier::Public,
239 alternates: Arc::new(DashMap::new()),
240 fallback_chain: Vec::new(),
241 }
242 }
243
244 #[cfg(any(test, feature = "test-util"))]
255 #[must_use]
256 pub fn with_public_base_for_test(cache: Arc<HttpCache>, simple_base: String) -> Self {
257 Self {
258 cache,
259 index_url: crate::search::SIMPLE_INDEX_URL.to_string(),
260 simple_base,
261 index: Arc::new(crate::search::IndexCell::new()),
262 tier: PypiRegistryTier::Public,
263 alternates: Arc::new(DashMap::new()),
264 fallback_chain: Vec::new(),
265 }
266 }
267
268 #[must_use]
281 pub fn with_base(
282 cache: Arc<HttpCache>,
283 simple_base: &PypiIndexUrl,
284 fallback_chain: Vec<Arc<Self>>,
285 ) -> Self {
286 Self {
287 cache,
288 index_url: crate::search::SIMPLE_INDEX_URL.to_string(),
289 simple_base: simple_base.as_str().to_string(),
290 index: Arc::new(crate::search::IndexCell::new()),
291 tier: PypiRegistryTier::WorkspaceDeclared,
292 alternates: Arc::new(DashMap::new()),
293 fallback_chain,
294 }
295 }
296
297 pub fn register_chain(root: &Arc<Self>, chain: &ResolvedChain) {
313 let Some((first_hop, rest_hops)) = chain.hops.split_first() else {
314 return;
318 };
319
320 let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
324
325 if let dashmap::mapref::entry::Entry::Vacant(slot) =
326 root.alternates.entry(chain.key.clone())
327 {
328 if at_capacity {
329 tracing::warn!(
330 key = %chain.key,
331 cap = MAX_ALTERNATE_REGISTRIES,
332 "PyPI alternate registry cap reached; not registering a new chain"
333 );
334 return;
335 }
336
337 let mut fallback_chain: Vec<Arc<Self>> = rest_hops
338 .iter()
339 .map(|hop| Arc::new(Self::with_base(Arc::clone(&root.cache), hop, Vec::new())))
340 .collect();
341 if chain.implicit_public_fallback {
342 fallback_chain.push(Arc::new(Self {
349 cache: Arc::clone(&root.cache),
350 index_url: root.index_url.clone(),
351 simple_base: root.simple_base.clone(),
352 index: Arc::new(crate::search::IndexCell::new()),
353 tier: PypiRegistryTier::Public,
354 alternates: Arc::new(DashMap::new()),
355 fallback_chain: Vec::new(),
356 }));
357 }
358
359 let head = Self::with_base(Arc::clone(&root.cache), first_hop, fallback_chain);
360 slot.insert(Arc::new(head));
361 }
362 }
363
364 pub fn register_named_source(root: &Arc<Self>, index: &PypiIndexUrl) {
369 let key = index.as_str().to_string();
370 let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
371
372 if let dashmap::mapref::entry::Entry::Vacant(slot) = root.alternates.entry(key.clone()) {
373 if at_capacity {
374 tracing::warn!(
375 key = %key,
376 cap = MAX_ALTERNATE_REGISTRIES,
377 "PyPI alternate registry cap reached; not registering a new named source"
378 );
379 return;
380 }
381 slot.insert(Arc::new(Self::with_base(
382 Arc::clone(&root.cache),
383 index,
384 Vec::new(),
385 )));
386 }
387 }
388
389 #[must_use]
398 pub fn alternate_client(&self, index: &str) -> Option<Arc<Self>> {
399 self.alternates.get(index).map(|entry| Arc::clone(&entry))
400 }
401
402 async fn get_versions_chained(&self, name: &str) -> Result<Vec<PypiVersion>> {
423 let mut last_miss: Result<Vec<PypiVersion>> = Err(DepsError::PackageNotFound {
424 package: name.to_string(),
425 registry: REGISTRY,
426 });
427
428 for hop in std::iter::once(self).chain(self.fallback_chain.iter().map(Arc::as_ref)) {
429 match hop.get_versions(name).await {
430 Ok(versions) if !versions.is_empty() => return Ok(versions),
431 Ok(empty) => last_miss = Ok(empty),
432 Err(DepsError::PackageNotFound { .. }) => {
433 last_miss = Err(DepsError::PackageNotFound {
434 package: name.to_string(),
435 registry: REGISTRY,
436 });
437 }
438 Err(other) => {
439 tracing::warn!(
440 package = name,
441 error = %other,
442 "PyPI alternate-index chain resolution halted on a transport error \
443 — not falling back to pypi.org or the next configured index"
444 );
445 return Err(DepsError::ChainResolutionHalted);
450 }
451 }
452 }
453
454 last_miss
455 }
456
457 pub async fn get_versions(&self, name: &str) -> Result<Vec<PypiVersion>> {
490 let normalized = crate::name::normalize(name);
491 if normalized.is_empty() {
492 warn_rejected_value(
493 "pypi_normalized_name_empty",
494 "PyPI simple API request URL",
495 name,
496 );
497 return Err(DepsError::PackageNotFound {
498 package: name.to_string(),
499 registry: REGISTRY,
500 });
501 }
502 let url = simple_api_url(&self.simple_base, &normalized);
503 let headers = [(reqwest::header::ACCEPT, SIMPLE_API_ACCEPT)];
504 let data = match self.tier {
505 PypiRegistryTier::Public => self.cache.get_cached_with_headers(&url, &headers).await,
506 PypiRegistryTier::WorkspaceDeclared => {
507 self.cache
508 .get_cached_workspace_with_headers(&url, &headers)
509 .await
510 }
511 }
512 .map_err(|e| not_found_or(e, name))?;
513
514 parse_simple_api_response(name, &data)
515 }
516
517 pub async fn get_latest_matching(
544 &self,
545 name: &str,
546 req_str: &str,
547 ) -> Result<Option<PypiVersion>> {
548 let versions = self.get_versions(name).await?;
549
550 let normalized_req = if req_str == "*" { "" } else { req_str };
552
553 let specs = VersionSpecifiers::from_str(normalized_req)
554 .map_err(|e| DepsError::InvalidVersionReq(format!("{req_str}: {e}")))?;
555
556 Ok(versions.into_iter().find(|v| {
557 if let Ok(version) = Version::from_str(v.version.as_str()) {
558 specs.contains(&version) && !v.yanked && !v.is_prerelease()
559 } else {
560 false
561 }
562 }))
563 }
564
565 pub fn search(
605 &self,
606 query: &str,
607 limit: usize,
608 ) -> impl Future<Output = Result<Vec<PypiPackage>>> + use<> {
609 let normalized = crate::name::normalize(query);
610 let cache = Arc::clone(&self.cache);
611 let index_url = self.index_url.clone();
612 let index = Arc::clone(&self.index);
613 let tier = self.tier;
614 async move {
615 if tier == PypiRegistryTier::WorkspaceDeclared {
621 return Ok(Vec::new());
622 }
623 if normalized.is_empty() {
624 return Ok(Vec::new());
625 }
626 if let Some(ready) = index.ready() {
627 return Ok(ready
628 .prefix_matches(&normalized, limit)
629 .into_iter()
630 .map(package_stub)
631 .collect());
632 }
633 crate::search::trigger_index_build(cache, index_url, index);
634 Ok(Vec::new())
635 }
636 }
637
638 pub fn warm_search_index(&self) {
648 if self.tier == PypiRegistryTier::WorkspaceDeclared {
651 return;
652 }
653 crate::search::trigger_index_build(
654 Arc::clone(&self.cache),
655 self.index_url.clone(),
656 Arc::clone(&self.index),
657 );
658 }
659
660 pub async fn get_package_metadata(&self, name: &str) -> Result<PypiPackage> {
675 if self.tier == PypiRegistryTier::WorkspaceDeclared {
676 return Err(DepsError::PackageNotFound {
677 package: name.to_string(),
678 registry: REGISTRY,
679 });
680 }
681 let normalized = crate::name::normalize(name);
682 if normalized.is_empty() {
683 warn_rejected_value(
684 "pypi_normalized_name_empty",
685 "PyPI package metadata request URL",
686 name,
687 );
688 return Err(DepsError::PackageNotFound {
689 package: name.to_string(),
690 registry: REGISTRY,
691 });
692 }
693 let url = metadata_url(&normalized);
694 let data = self
695 .cache
696 .get_cached(&url)
697 .await
698 .map_err(|e| not_found_or(e, name))?;
699
700 parse_package_info(name, &data)
701 }
702}
703
704impl deps_core::Registry for PypiRegistry {
706 fn get_versions<'a>(
707 &'a self,
708 name: &'a deps_core::PackageName,
709 ) -> deps_core::ecosystem::BoxFuture<
710 'a,
711 deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
712 > {
713 Box::pin(async move {
714 let versions = Self::get_versions(self, name.as_str()).await?;
715 Ok(versions
716 .into_iter()
717 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
718 .collect())
719 })
720 }
721
722 fn get_latest_matching<'a>(
723 &'a self,
724 name: &'a deps_core::PackageName,
725 req: &'a deps_core::VersionReq,
726 ) -> deps_core::ecosystem::BoxFuture<
727 'a,
728 deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
729 > {
730 Box::pin(async move {
731 let version = Self::get_latest_matching(self, name.as_str(), req.as_str()).await?;
732 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
733 })
734 }
735
736 fn get_versions_from<'a>(
744 &'a self,
745 name: &'a deps_core::PackageName,
746 source: &'a DependencySource,
747 freshness: FreshnessSettings,
748 ) -> deps_core::ecosystem::BoxFuture<
749 'a,
750 deps_core::error::Result<Vec<Box<dyn deps_core::Version>>>,
751 > {
752 Box::pin(async move {
753 match source {
754 DependencySource::AlternateRegistry { index, .. } => {
755 match self.alternate_client(index) {
756 Some(client) => {
757 let versions = client.get_versions_chained(name.as_str()).await?;
758 Ok(versions
759 .into_iter()
760 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
761 .collect())
762 }
763 None => Err(DepsError::PackageNotFound {
764 package: name.to_string(),
765 registry: "alternate registry (not registered)",
766 }),
767 }
768 }
769 _ => deps_core::Registry::get_versions_with(self, name, freshness).await,
770 }
771 })
772 }
773
774 fn get_latest_matching_from<'a>(
787 &'a self,
788 name: &'a deps_core::PackageName,
789 source: &'a DependencySource,
790 req: &'a deps_core::VersionReq,
791 _minimum_stability: Option<&'a str>,
792 ) -> deps_core::ecosystem::BoxFuture<
793 'a,
794 deps_core::error::Result<Option<Box<dyn deps_core::Version>>>,
795 > {
796 Box::pin(async move {
797 match source {
798 DependencySource::AlternateRegistry { index, .. } => {
799 match self.alternate_client(index) {
800 Some(client) => {
801 let versions: Vec<Box<dyn deps_core::Version>> = client
802 .get_versions_chained(name.as_str())
803 .await?
804 .into_iter()
805 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
806 .collect();
807 let idx = client.select_latest_matching(&versions, req);
808 Ok(idx.and_then(|i| versions.into_iter().nth(i)))
809 }
810 None => Err(DepsError::PackageNotFound {
811 package: name.to_string(),
812 registry: "alternate registry (not registered)",
813 }),
814 }
815 }
816 _ => {
817 let version =
818 Self::get_latest_matching(self, name.as_str(), req.as_str()).await?;
819 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
820 }
821 }
822 })
823 }
824
825 fn search<'a>(
826 &'a self,
827 query: &'a str,
828 limit: usize,
829 ) -> deps_core::ecosystem::BoxFuture<
830 'a,
831 deps_core::error::Result<Vec<Box<dyn deps_core::Metadata>>>,
832 > {
833 Box::pin(async move {
834 let packages = Self::search(self, query, limit).await?;
835 Ok(packages
836 .into_iter()
837 .map(|p| Box::new(p) as Box<dyn deps_core::Metadata>)
838 .collect())
839 })
840 }
841
842 fn select_latest_matching(
843 &self,
844 versions: &[Box<dyn deps_core::Version>],
845 req: &deps_core::VersionReq,
846 ) -> Option<usize> {
847 if deps_core::is_existence_wildcard(req) {
848 return deps_core::select_latest_for_existence(versions, |v| v.as_ref());
849 }
850 let specs = VersionSpecifiers::from_str(req.as_str()).ok()?;
853
854 versions.iter().position(|v| {
855 Version::from_str(v.version_string().as_str()).is_ok_and(|ver| {
860 specs.contains(&ver) && !v.removal_status().blocks_resolution() && !ver.is_pre()
861 })
862 })
863 }
864
865 fn as_any(&self) -> &dyn Any {
866 self
867 }
868}
869
870#[derive(Debug, Deserialize)]
873struct PypiResponse {
874 info: PypiInfo,
875}
876
877#[derive(Debug, Deserialize)]
878struct PypiInfo {
879 name: String,
880 summary: Option<String>,
881 project_urls: Option<std::collections::HashMap<String, String>>,
882 version: String,
883}
884
885#[derive(Debug, Deserialize)]
888struct SimpleApiResponse {
889 versions: Vec<String>,
890 files: Vec<SimpleFile>,
891}
892
893#[derive(Debug, Deserialize)]
894struct SimpleFile {
895 filename: String,
896 #[serde(default)]
897 yanked: Yanked,
898 #[serde(rename = "upload-time", default)]
903 upload_time: Option<String>,
904}
905
906#[derive(Debug, Deserialize)]
910#[serde(untagged)]
911enum Yanked {
912 Flag(bool),
913 Reason(#[expect(dead_code, reason = "reason text not surfaced by get_versions")] String),
914}
915
916impl Default for Yanked {
917 fn default() -> Self {
918 Self::Flag(false)
919 }
920}
921
922impl Yanked {
923 const fn is_yanked(&self) -> bool {
924 match self {
925 Self::Flag(b) => *b,
926 Self::Reason(_) => true,
927 }
928 }
929}
930
931const KNOWN_ARCHIVE_EXTENSIONS: &[&str] = &[
935 ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.lz", ".tar.Z", ".zip", ".whl", ".egg", ".tar",
936];
937
938fn parse_version_from_filename<'a>(filename: &'a str, normalized_name: &str) -> Option<&'a str> {
955 let bytes = filename.as_bytes();
956 let mut fi = 0usize;
957 for nb in normalized_name.bytes() {
958 if nb == b'-' {
959 let start = fi;
960 while matches!(bytes.get(fi), Some(b'-' | b'_' | b'.')) {
961 fi += 1;
962 }
963 if fi == start {
964 return None;
965 }
966 } else {
967 match bytes.get(fi) {
968 Some(&fb) if fb.to_ascii_lowercase() == nb => fi += 1,
969 _ => return None,
970 }
971 }
972 }
973 if bytes.get(fi) != Some(&b'-') {
974 return None;
975 }
976 let rest = &filename[fi + 1..];
977 match rest.find('-') {
978 Some(end) => Some(&rest[..end]),
979 None => KNOWN_ARCHIVE_EXTENSIONS
980 .iter()
981 .find_map(|ext| rest.strip_suffix(ext)),
982 }
983}
984
985#[derive(Debug, Default, PartialEq, Eq)]
987struct VersionMetadata {
988 yanked: bool,
992 published_at: Option<deps_core::PublishTime>,
996}
997
998fn build_version_metadata(
1018 files: &[SimpleFile],
1019 versions: &[String],
1020 normalized_name: &str,
1021) -> std::collections::HashMap<String, VersionMetadata> {
1022 let version_set: std::collections::HashSet<&str> =
1023 versions.iter().map(String::as_str).collect();
1024
1025 let mut parsed_versions: Option<std::collections::HashMap<Version, &str>> = None;
1028
1029 let mut metadata: std::collections::HashMap<String, VersionMetadata> =
1030 std::collections::HashMap::new();
1031 for file in files {
1032 let matched =
1033 parse_version_from_filename(&file.filename, normalized_name).and_then(|candidate| {
1034 version_set.get(candidate).copied().or_else(|| {
1035 let parsed = Version::from_str(candidate).ok()?;
1036 let map = parsed_versions.get_or_insert_with(|| {
1037 versions
1038 .iter()
1039 .filter_map(|v| Some((Version::from_str(v).ok()?, v.as_str())))
1040 .collect()
1041 });
1042 map.get(&parsed).copied()
1043 })
1044 });
1045
1046 let Some(version) = matched else {
1047 continue;
1048 };
1049 let entry = metadata.entry(version.to_string()).or_default();
1050 entry.yanked |= file.yanked.is_yanked();
1051 if let Some(uploaded) = file
1052 .upload_time
1053 .as_deref()
1054 .and_then(deps_core::PublishTime::parse_rfc3339)
1055 {
1056 entry.published_at = Some(
1057 entry
1058 .published_at
1059 .map_or(uploaded, |existing| existing.max(uploaded)),
1060 );
1061 }
1062 }
1063 metadata
1064}
1065
1066fn parse_simple_api_response(package_name: &str, data: &[u8]) -> Result<Vec<PypiVersion>> {
1068 let response: SimpleApiResponse =
1069 deps_core::parse_json_checked(data).map_err(|e| DepsError::ApiResponse {
1070 package: package_name.to_string(),
1071 registry: REGISTRY,
1072 source: e,
1073 })?;
1074
1075 let normalized_name = crate::name::normalize(package_name);
1076 let metadata_map =
1077 build_version_metadata(&response.files, &response.versions, &normalized_name);
1078
1079 let mut versions_with_parsed: Vec<(PypiVersion, Version)> = response
1080 .versions
1081 .into_iter()
1082 .filter_map(|version_str| {
1083 let parsed = Version::from_str(&version_str).ok()?;
1084 let meta = metadata_map.get(&version_str);
1085 let yanked = meta.is_some_and(|m| m.yanked);
1086 let published_at = meta.and_then(|m| m.published_at);
1087 Some((
1088 PypiVersion {
1089 version: version_str.into(),
1090 yanked,
1091 published_at,
1092 },
1093 parsed,
1094 ))
1095 })
1096 .collect();
1097
1098 versions_with_parsed.sort_by(|a, b| b.1.cmp(&a.1));
1100
1101 Ok(versions_with_parsed.into_iter().map(|(v, _)| v).collect())
1102}
1103
1104fn parse_package_info(package_name: &str, data: &[u8]) -> Result<PypiPackage> {
1106 let response: PypiResponse =
1107 deps_core::parse_json_checked(data).map_err(|e| DepsError::ApiResponse {
1108 package: package_name.to_string(),
1109 registry: REGISTRY,
1110 source: e,
1111 })?;
1112
1113 let project_urls = response
1114 .info
1115 .project_urls
1116 .unwrap_or_default()
1117 .into_iter()
1118 .collect();
1119
1120 Ok(PypiPackage {
1121 name: response.info.name.into(),
1122 summary: response.info.summary,
1123 project_urls,
1124 latest_version: response.info.version.into(),
1125 })
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131
1132 use deps_core::test_util::{capture_tracing_output, capture_tracing_output_async};
1133 use std::assert_matches;
1134
1135 #[test]
1136 fn test_package_url() {
1137 assert_eq!(package_url("requests"), "https://pypi.org/project/requests");
1138 assert_eq!(package_url("flask"), "https://pypi.org/project/flask");
1139 }
1140
1141 #[test]
1142 fn test_package_url_normalization() {
1143 assert_eq!(package_url("Flask"), "https://pypi.org/project/flask");
1144 assert_eq!(
1145 package_url("django_rest_framework"),
1146 "https://pypi.org/project/django-rest-framework"
1147 );
1148 }
1149
1150 #[test]
1151 fn test_package_url_encoding() {
1152 let url = package_url("my-package");
1153 assert!(url.starts_with("https://pypi.org/project/"));
1154 assert!(url.contains("my-package"));
1155 }
1156
1157 #[test]
1158 fn test_package_url_encodes_malicious_name() {
1159 let url = package_url("evil](https://evil.example)[pkg");
1160 assert!(!url.contains('('));
1161 assert!(!url.contains(')'));
1162 assert!(!url.contains('['));
1163 assert!(!url.contains(']'));
1164 }
1165
1166 #[test]
1167 fn test_package_url_encodes_newline_autolink_and_percent() {
1168 let url = package_url("evil\n<https://evil%zz.example>");
1169 assert!(!url.contains('\n'));
1170 assert!(!url.contains('<'));
1171 assert!(!url.contains('>'));
1172 assert!(url.contains("%25"));
1175 }
1176
1177 #[test]
1178 fn test_package_url_empty_name() {
1179 assert_eq!(package_url(""), "");
1180 }
1181
1182 #[test]
1183 fn test_package_url_normalizes_to_empty() {
1184 assert_eq!(package_url("---"), "");
1187 }
1188
1189 #[test]
1190 fn test_parse_simple_api_response() {
1191 let json = r#"{
1194 "meta": {"api-version": "1.4"},
1195 "name": "requests",
1196 "versions": ["2.27.0", "2.28.0", "2.28.1", "2.28.2"],
1197 "files": [
1198 {"filename": "requests-2.27.0.tar.gz", "yanked": false},
1199 {"filename": "requests-2.28.0.tar.gz", "yanked": true},
1200 {"filename": "requests-2.28.0-py3-none-any.whl", "yanked": true},
1201 {"filename": "requests-2.28.1.tar.gz", "yanked": false},
1202 {"filename": "requests-2.28.2.tar.gz", "yanked": false},
1203 {"filename": "requests-2.28.2-py3-none-any.whl", "yanked": false}
1204 ]
1205 }"#;
1206
1207 let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1208
1209 assert_eq!(versions.len(), 4);
1210 assert_eq!(versions[0].version, "2.28.2");
1211 assert!(!versions[0].yanked);
1212 assert!(
1213 versions
1214 .iter()
1215 .find(|v| v.version == "2.28.0")
1216 .unwrap()
1217 .yanked
1218 );
1219 }
1220
1221 #[test]
1222 fn test_parse_simple_api_response_nesting_at_max_depth_accepted() {
1223 let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1224 let json = format!(
1225 r#"{{"versions": [], "files": [], "extra": {}1{}}}"#,
1226 "[".repeat(depth - 1),
1227 "]".repeat(depth - 1)
1228 );
1229 assert!(parse_simple_api_response("pkg", json.as_bytes()).is_ok());
1230 }
1231
1232 #[test]
1233 fn test_parse_simple_api_response_nesting_over_max_depth_rejected() {
1234 let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1235 let json = format!(
1236 r#"{{"versions": [], "files": [], "extra": {}1{}}}"#,
1237 "[".repeat(depth),
1238 "]".repeat(depth)
1239 );
1240 assert!(parse_simple_api_response("pkg", json.as_bytes()).is_err());
1241 }
1242
1243 #[test]
1244 fn test_parse_simple_api_response_with_upload_time() {
1245 let json = r#"{
1246 "meta": {"api-version": "1.4"},
1247 "name": "requests",
1248 "versions": ["2.28.2"],
1249 "files": [
1250 {"filename": "requests-2.28.2.tar.gz", "yanked": false, "upload-time": "2026-05-14T19:25:27.735762Z"}
1251 ]
1252 }"#;
1253
1254 let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1255 assert_eq!(versions.len(), 1);
1256 assert_eq!(
1257 versions[0].published_at,
1258 deps_core::PublishTime::parse_rfc3339("2026-05-14T19:25:27.735762Z")
1259 );
1260 }
1261
1262 #[test]
1263 fn test_parse_simple_api_response_without_upload_time() {
1264 let json = r#"{
1265 "meta": {"api-version": "1.4"},
1266 "name": "requests",
1267 "versions": ["2.28.2"],
1268 "files": [
1269 {"filename": "requests-2.28.2.tar.gz", "yanked": false}
1270 ]
1271 }"#;
1272
1273 let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1274 assert_eq!(versions.len(), 1);
1275 assert!(versions[0].published_at.is_none());
1276 }
1277
1278 #[test]
1279 fn test_parse_simple_api_response_with_malformed_upload_time() {
1280 let json = r#"{
1281 "meta": {"api-version": "1.4"},
1282 "name": "requests",
1283 "versions": ["2.28.2"],
1284 "files": [
1285 {"filename": "requests-2.28.2.tar.gz", "yanked": false, "upload-time": "not-a-timestamp"}
1286 ]
1287 }"#;
1288
1289 let versions = parse_simple_api_response("requests", json.as_bytes()).unwrap();
1290 assert_eq!(versions.len(), 1);
1291 assert!(
1292 versions[0].published_at.is_none(),
1293 "malformed upload-time degrades to None, not an error"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_parse_simple_api_response_yanked_with_reason_string() {
1299 let json = r#"{
1302 "meta": {"api-version": "1.4"},
1303 "name": "urllib3",
1304 "versions": ["1.25"],
1305 "files": [
1306 {"filename": "urllib3-1.25-py2.py3-none-any.whl", "yanked": "Broken release"},
1307 {"filename": "urllib3-1.25.tar.gz", "yanked": "Broken release"}
1308 ]
1309 }"#;
1310
1311 let versions = parse_simple_api_response("urllib3", json.as_bytes()).unwrap();
1312 assert_eq!(versions.len(), 1);
1313 assert!(versions[0].yanked);
1314 }
1315
1316 #[test]
1317 fn test_build_version_metadata_disambiguates_version_prefixes() {
1318 let files = vec![SimpleFile {
1322 filename: "pkg-1.0.0.tar.gz".to_string(),
1323 yanked: Yanked::Flag(true),
1324 upload_time: None,
1325 }];
1326 let versions = vec!["1.0".to_string(), "1.0.0".to_string()];
1327 let map = build_version_metadata(&files, &versions, "pkg");
1328 assert!(map.get("1.0.0").unwrap().yanked);
1329 assert!(!map.contains_key("1.0"));
1330 }
1331
1332 #[test]
1333 fn test_build_version_metadata_any_file_yanked_marks_version_yanked() {
1334 let files = vec![
1335 SimpleFile {
1336 filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1337 yanked: Yanked::Flag(false),
1338 upload_time: None,
1339 },
1340 SimpleFile {
1341 filename: "pkg-1.0.0.tar.gz".to_string(),
1342 yanked: Yanked::Flag(true),
1343 upload_time: None,
1344 },
1345 ];
1346 let versions = vec!["1.0.0".to_string()];
1347 let map = build_version_metadata(&files, &versions, "pkg");
1348 assert!(map.get("1.0.0").unwrap().yanked);
1349 }
1350
1351 #[test]
1352 fn test_build_version_metadata_disambiguates_in_both_directions() {
1353 let files = vec![
1357 SimpleFile {
1358 filename: "pkg-1.0.tar.gz".to_string(),
1359 yanked: Yanked::Flag(true),
1360 upload_time: None,
1361 },
1362 SimpleFile {
1363 filename: "pkg-1.0.0.tar.gz".to_string(),
1364 yanked: Yanked::Flag(false),
1365 upload_time: None,
1366 },
1367 ];
1368 let versions = vec!["1.0".to_string(), "1.0.0".to_string()];
1369 let map = build_version_metadata(&files, &versions, "pkg");
1370 assert!(map.get("1.0").unwrap().yanked);
1371 assert!(!map.get("1.0.0").unwrap().yanked);
1372 }
1373
1374 #[test]
1375 fn test_build_version_metadata_pre_post_dev_suffixes() {
1376 let files = vec![
1379 SimpleFile {
1380 filename: "pkg-1.0.0.tar.gz".to_string(),
1381 yanked: Yanked::Flag(false),
1382 upload_time: None,
1383 },
1384 SimpleFile {
1385 filename: "pkg-1.0.0rc1.tar.gz".to_string(),
1386 yanked: Yanked::Flag(true),
1387 upload_time: None,
1388 },
1389 SimpleFile {
1390 filename: "pkg-1.0.0.post1.tar.gz".to_string(),
1391 yanked: Yanked::Flag(false),
1392 upload_time: None,
1393 },
1394 SimpleFile {
1395 filename: "pkg-1.0.0.dev1.tar.gz".to_string(),
1396 yanked: Yanked::Flag(true),
1397 upload_time: None,
1398 },
1399 ];
1400 let versions = vec![
1401 "1.0.0".to_string(),
1402 "1.0.0rc1".to_string(),
1403 "1.0.0.post1".to_string(),
1404 "1.0.0.dev1".to_string(),
1405 ];
1406 let map = build_version_metadata(&files, &versions, "pkg");
1407 assert!(!map.get("1.0.0").unwrap().yanked);
1408 assert!(map.get("1.0.0rc1").unwrap().yanked);
1409 assert!(!map.get("1.0.0.post1").unwrap().yanked);
1410 assert!(map.get("1.0.0.dev1").unwrap().yanked);
1411 }
1412
1413 #[test]
1414 fn test_build_version_metadata_takes_maximum_upload_time() {
1415 let files = vec![
1421 SimpleFile {
1422 filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1423 yanked: Yanked::Flag(false),
1424 upload_time: Some("2026-05-14T19:25:27Z".to_string()),
1425 },
1426 SimpleFile {
1427 filename: "pkg-1.0.0.tar.gz".to_string(),
1428 yanked: Yanked::Flag(false),
1429 upload_time: Some("2026-05-14T10:00:00Z".to_string()),
1430 },
1431 ];
1432 let versions = vec!["1.0.0".to_string()];
1433 let map = build_version_metadata(&files, &versions, "pkg");
1434 assert_eq!(
1435 map.get("1.0.0").unwrap().published_at,
1436 deps_core::PublishTime::parse_rfc3339("2026-05-14T19:25:27Z")
1437 );
1438 }
1439
1440 #[test]
1441 fn test_build_version_metadata_absent_upload_time_is_none() {
1442 let files = vec![SimpleFile {
1443 filename: "pkg-1.0.0.tar.gz".to_string(),
1444 yanked: Yanked::Flag(false),
1445 upload_time: None,
1446 }];
1447 let versions = vec!["1.0.0".to_string()];
1448 let map = build_version_metadata(&files, &versions, "pkg");
1449 assert!(map.get("1.0.0").unwrap().published_at.is_none());
1450 }
1451
1452 #[test]
1453 fn test_build_version_metadata_malformed_upload_time_is_none() {
1454 let files = vec![SimpleFile {
1455 filename: "pkg-1.0.0.tar.gz".to_string(),
1456 yanked: Yanked::Flag(false),
1457 upload_time: Some("not-a-timestamp".to_string()),
1458 }];
1459 let versions = vec!["1.0.0".to_string()];
1460 let map = build_version_metadata(&files, &versions, "pkg");
1461 assert!(
1462 map.get("1.0.0").unwrap().published_at.is_none(),
1463 "malformed upload-time degrades to None, not an error"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_parse_version_from_filename_wheel_and_sdist() {
1469 assert_eq!(
1470 parse_version_from_filename("requests-2.28.2.tar.gz", "requests"),
1471 Some("2.28.2")
1472 );
1473 assert_eq!(
1474 parse_version_from_filename("requests-2.28.2-py3-none-any.whl", "requests"),
1475 Some("2.28.2")
1476 );
1477 }
1478
1479 #[test]
1480 fn test_parse_version_from_filename_dotted_and_underscored_project_name() {
1481 assert_eq!(
1484 parse_version_from_filename("zope.interface-3.3.0b1.tar.gz", "zope-interface"),
1485 Some("3.3.0b1")
1486 );
1487 assert_eq!(
1488 parse_version_from_filename(
1489 "typing_extensions-3.6.2-py3-none-any.whl",
1490 "typing-extensions"
1491 ),
1492 Some("3.6.2")
1493 );
1494 }
1495
1496 #[test]
1497 fn test_parse_version_from_filename_rejects_non_conforming() {
1498 assert_eq!(
1500 parse_version_from_filename("other-1.0.0.tar.gz", "pkg"),
1501 None
1502 );
1503 assert_eq!(parse_version_from_filename("pkg1.0.0.tar.gz", "pkg"), None);
1505 }
1506
1507 #[test]
1508 fn test_parse_version_from_filename_no_digit_run_confusion() {
1509 assert_eq!(
1513 parse_version_from_filename("pkg-1.10.0.tar.gz", "pkg"),
1514 Some("1.10.0")
1515 );
1516 }
1517
1518 #[test]
1519 fn test_build_version_metadata_uses_fast_path_not_quadratic_fallback() {
1520 let mut versions: Vec<String> = (0..2000).map(|i| format!("0.0.{i}")).collect();
1524 versions.push("9.9.9".to_string());
1525 let files = vec![SimpleFile {
1526 filename: "pkg-9.9.9.tar.gz".to_string(),
1527 yanked: Yanked::Flag(true),
1528 upload_time: None,
1529 }];
1530 assert_eq!(
1531 parse_version_from_filename(&files[0].filename, "pkg"),
1532 Some("9.9.9"),
1533 "fast path must derive the version directly from filename structure"
1534 );
1535 let map = build_version_metadata(&files, &versions, "pkg");
1536 assert!(map.get("9.9.9").unwrap().yanked);
1537 }
1538
1539 #[test]
1540 fn test_build_version_metadata_ignores_platform_tag_false_match() {
1541 let files = vec![SimpleFile {
1548 filename: "pyobjc_core-2.2-py2.6-macosx-10.3-fat.egg".to_string(),
1549 yanked: Yanked::Flag(true),
1550 upload_time: None,
1551 }];
1552 let versions = vec!["2.2".to_string(), "10.3".to_string()];
1553 let map = build_version_metadata(&files, &versions, "pyobjc-core");
1554 assert!(map.get("2.2").unwrap().yanked);
1555 assert!(!map.contains_key("10.3"));
1556 }
1557
1558 #[test]
1559 fn test_build_version_metadata_pep440_underscore_normalization() {
1560 let files = vec![SimpleFile {
1567 filename: "protobuf-4.21.0_rc_1-cp310-abi3-win_amd64.whl".to_string(),
1568 yanked: Yanked::Flag(false),
1569 upload_time: None,
1570 }];
1571 let versions = vec!["4.21.0rc1".to_string()];
1572 let map = build_version_metadata(&files, &versions, "protobuf");
1573 assert!(!map.get("4.21.0rc1").unwrap().yanked);
1574 }
1575
1576 #[test]
1577 fn test_build_version_metadata_skips_non_conforming_filename_instead_of_guessing() {
1578 let files = vec![
1586 SimpleFile {
1587 filename: "unrelated-file-1.0.0.zip".to_string(),
1588 yanked: Yanked::Flag(true),
1589 upload_time: None,
1590 },
1591 SimpleFile {
1592 filename: "pkg-1.0.0-py3-none-any.whl".to_string(),
1593 yanked: Yanked::Flag(true),
1594 upload_time: None,
1595 },
1596 ];
1597 let versions = vec!["1.0.0".to_string(), "2.0.0".to_string()];
1598 assert_eq!(
1599 parse_version_from_filename(&files[0].filename, "pkg"),
1600 None,
1601 "filename doesn't start with the package name, must be skipped"
1602 );
1603 let map = build_version_metadata(&files, &versions, "pkg");
1604 assert!(map.get("1.0.0").unwrap().yanked);
1605 assert!(!map.contains_key("2.0.0"));
1606 }
1607
1608 #[test]
1609 fn test_simple_api_url_encodes_malicious_name() {
1610 let url = simple_api_url(PYPI_SIMPLE_BASE, "evil/../secret");
1614 assert!(url.starts_with(PYPI_SIMPLE_BASE));
1615 assert!(!url.contains("/../"));
1616 assert_eq!(url, format!("{PYPI_SIMPLE_BASE}/evil%2F..%2Fsecret/"));
1617 }
1618
1619 #[test]
1620 fn test_metadata_url_encodes_malicious_name() {
1621 let url = metadata_url("pkg?x=1#frag");
1622 assert!(!url.contains('?'));
1623 assert!(!url.contains('#'));
1624 }
1625
1626 #[test]
1634 fn test_simple_api_url_dot_segment_sweep() {
1635 deps_core::test_util::assert_dot_segment_gated_or_contained_transformed(
1636 |seg| {
1637 let normalized = crate::name::normalize(seg);
1638 (!normalized.is_empty()).then(|| simple_api_url(PYPI_SIMPLE_BASE, &normalized))
1639 },
1640 crate::name::normalize,
1641 "pypi.org",
1642 "/simple/",
1643 );
1644 }
1645
1646 #[test]
1650 fn test_metadata_url_dot_segment_sweep() {
1651 deps_core::test_util::assert_dot_segment_gated_or_contained_transformed(
1652 |seg| {
1653 let normalized = crate::name::normalize(seg);
1654 (!normalized.is_empty()).then(|| metadata_url(&normalized))
1655 },
1656 crate::name::normalize,
1657 "pypi.org",
1658 "/pypi/",
1659 );
1660 }
1661
1662 #[test]
1663 fn test_simple_api_url_normal_names() {
1664 assert_eq!(
1665 simple_api_url(PYPI_SIMPLE_BASE, "requests"),
1666 "https://pypi.org/simple/requests/"
1667 );
1668 assert_eq!(
1669 simple_api_url(PYPI_SIMPLE_BASE, "zope-interface"),
1670 "https://pypi.org/simple/zope-interface/"
1671 );
1672 }
1673
1674 #[test]
1675 fn test_parse_package_info() {
1676 let json = r#"{
1677 "info": {
1678 "name": "flask",
1679 "summary": "A micro web framework",
1680 "version": "3.0.0",
1681 "project_urls": {
1682 "Documentation": "https://flask.palletsprojects.com/",
1683 "Repository": "https://github.com/pallets/flask"
1684 }
1685 }
1686 }"#;
1687
1688 let pkg = parse_package_info("flask", json.as_bytes()).unwrap();
1689
1690 assert_eq!(pkg.name, "flask");
1691 assert_eq!(pkg.summary, Some("A micro web framework".to_string()));
1692 assert_eq!(pkg.latest_version, "3.0.0");
1693 assert_eq!(pkg.project_urls.len(), 2);
1694 }
1695
1696 #[test]
1697 fn test_parse_package_info_nesting_at_max_depth_accepted() {
1698 let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1699 let json = format!(
1700 r#"{{"info": {{"name": "pkg", "version": "1.0.0"}}, "extra": {}1{}}}"#,
1701 "[".repeat(depth - 1),
1702 "]".repeat(depth - 1)
1703 );
1704 assert!(parse_package_info("pkg", json.as_bytes()).is_ok());
1705 }
1706
1707 #[test]
1708 fn test_parse_package_info_nesting_over_max_depth_rejected() {
1709 let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1710 let json = format!(
1711 r#"{{"info": {{"name": "pkg", "version": "1.0.0"}}, "extra": {}1{}}}"#,
1712 "[".repeat(depth),
1713 "]".repeat(depth)
1714 );
1715 assert!(parse_package_info("pkg", json.as_bytes()).is_err());
1716 }
1717
1718 #[test]
1719 fn test_wildcard_specifier_normalization() {
1720 let normalized = if "*" == "*" { "" } else { "*" };
1723 assert_eq!(normalized, "");
1724
1725 let specs = VersionSpecifiers::from_str("").unwrap();
1727 assert!(specs.contains(&Version::from_str("1.0.0").unwrap()));
1728 assert!(specs.contains(&Version::from_str("2.5.3").unwrap()));
1729 assert!(specs.contains(&Version::from_str("0.0.1").unwrap()));
1730 }
1731
1732 #[test]
1733 fn test_not_found_or_maps_404_to_package_not_found() {
1734 let err = DepsError::HttpStatus {
1735 url: "https://pypi.org/pypi/flask/json".into(),
1736 status: 404,
1737 };
1738 let result = not_found_or(err, "flask");
1739 assert_matches!(
1740 result,
1741 DepsError::PackageNotFound { package, registry }
1742 if package == "flask" && registry == REGISTRY
1743 );
1744 }
1745
1746 #[test]
1747 fn test_not_found_or_passes_through_non_404() {
1748 let err = DepsError::HttpStatus {
1753 url: "https://pypi.org/pypi/pytest-404/json".into(),
1754 status: 500,
1755 };
1756 let result = not_found_or(err, "pytest-404");
1757 assert_matches!(result, DepsError::HttpStatus { status: 500, .. });
1758 }
1759
1760 #[test]
1761 fn test_prerelease_detection() {
1762 let json = r#"{
1763 "meta": {"api-version": "1.4"},
1764 "name": "test",
1765 "versions": ["1.0.0", "1.0.0a1", "1.0.0b2", "1.0.0rc1"],
1766 "files": [
1767 {"filename": "test-1.0.0.tar.gz", "yanked": false},
1768 {"filename": "test-1.0.0a1.tar.gz", "yanked": false},
1769 {"filename": "test-1.0.0b2.tar.gz", "yanked": false},
1770 {"filename": "test-1.0.0rc1.tar.gz", "yanked": false}
1771 ]
1772 }"#;
1773
1774 let versions = parse_simple_api_response("test", json.as_bytes()).unwrap();
1775
1776 let stable: Vec<_> = versions.iter().filter(|v| !v.is_prerelease()).collect();
1777 let prerelease: Vec<_> = versions.iter().filter(|v| v.is_prerelease()).collect();
1778
1779 assert_eq!(stable.len(), 1);
1780 assert_eq!(prerelease.len(), 3);
1781 }
1782
1783 #[tokio::test]
1784 async fn test_get_versions_empty_normalized_name_short_circuits() {
1785 let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1789 let registry = PypiRegistry::new(cache);
1790 let err = registry.get_versions("---").await.unwrap_err();
1791 assert_matches!(
1792 err,
1793 DepsError::PackageNotFound { package, registry }
1794 if package == "---" && registry == REGISTRY
1795 );
1796 }
1797
1798 #[tokio::test]
1799 async fn test_get_package_metadata_empty_normalized_name_short_circuits() {
1800 let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1801 let registry = PypiRegistry::new(cache);
1802 let err = registry.get_package_metadata("...").await.unwrap_err();
1803 assert_matches!(err, DepsError::PackageNotFound { .. });
1804 }
1805
1806 #[tokio::test]
1807 async fn test_get_versions_empty_normalized_name_logs_warn_rejected_value() {
1808 let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1811 let registry = PypiRegistry::new(cache);
1812 let output = capture_tracing_output_async(async {
1813 let _ = registry.get_versions("---").await;
1814 })
1815 .await;
1816 assert!(
1817 output.contains("pypi_normalized_name_empty"),
1818 "output was: {output}"
1819 );
1820 assert!(
1821 output.contains("PyPI simple API request URL"),
1822 "output was: {output}"
1823 );
1824 }
1825
1826 #[tokio::test]
1827 async fn test_get_package_metadata_empty_normalized_name_logs_warn_rejected_value() {
1828 let cache = std::sync::Arc::new(deps_core::HttpCache::new());
1829 let registry = PypiRegistry::new(cache);
1830 let output = capture_tracing_output_async(async {
1831 let _ = registry.get_package_metadata("...").await;
1832 })
1833 .await;
1834 assert!(
1835 output.contains("pypi_normalized_name_empty"),
1836 "output was: {output}"
1837 );
1838 assert!(
1839 output.contains("PyPI package metadata request URL"),
1840 "output was: {output}"
1841 );
1842 }
1843
1844 #[test]
1845 fn test_package_url_empty_normalized_name_logs_warn_rejected_value() {
1846 let output = capture_tracing_output(|| {
1847 let _ = package_url("---");
1848 });
1849 assert!(
1850 output.contains("pypi_normalized_name_empty"),
1851 "output was: {output}"
1852 );
1853 assert!(
1854 output.contains("PyPI package display URL"),
1855 "output was: {output}"
1856 );
1857 assert!(
1858 !output.contains("---"),
1859 "raw rejected value must not be logged: {output}"
1860 );
1861 }
1862
1863 #[test]
1864 fn test_package_url_accepted_logs_no_warn() {
1865 let output = capture_tracing_output(|| {
1866 let _ = package_url("requests");
1867 });
1868 assert!(output.is_empty(), "output was: {output}");
1869 }
1870
1871 #[test]
1872 fn test_select_latest_matching_not_default_none() {
1873 use deps_core::{Registry, VersionReq};
1874
1875 let cache = Arc::new(HttpCache::new());
1876 let registry = PypiRegistry::new(cache);
1877 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1878 Box::new(PypiVersion {
1879 version: "2.0.0".into(),
1880 yanked: true,
1881 published_at: None,
1882 }),
1883 Box::new(PypiVersion {
1884 version: "1.0.0".into(),
1885 yanked: false,
1886 published_at: None,
1887 }),
1888 ];
1889 let req = VersionReq::new("*");
1890 assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1891 }
1892
1893 #[test]
1894 fn test_select_latest_matching_excludes_unhyphenated_prerelease() {
1895 use deps_core::{Registry, VersionReq};
1900
1901 let cache = Arc::new(HttpCache::new());
1902 let registry = PypiRegistry::new(cache);
1903 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1904 Box::new(PypiVersion {
1905 version: "2.0.0rc1".into(),
1906 yanked: false,
1907 published_at: None,
1908 }),
1909 Box::new(PypiVersion {
1910 version: "1.0.0".into(),
1911 yanked: false,
1912 published_at: None,
1913 }),
1914 ];
1915 let req = VersionReq::new("*");
1916 assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1917 }
1918
1919 #[test]
1920 fn test_select_latest_matching_all_yanked_returns_newest_yanked() {
1921 use deps_core::{Registry, VersionReq};
1922
1923 let cache = Arc::new(HttpCache::new());
1924 let registry = PypiRegistry::new(cache);
1925 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1926 Box::new(PypiVersion {
1927 version: "2.0.0".into(),
1928 yanked: true,
1929 published_at: None,
1930 }),
1931 Box::new(PypiVersion {
1932 version: "1.0.0".into(),
1933 yanked: true,
1934 published_at: None,
1935 }),
1936 ];
1937 let req = VersionReq::new("*");
1938 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1939 }
1940
1941 #[test]
1942 fn test_select_latest_matching_all_prerelease_returns_newest_prerelease() {
1943 use deps_core::{Registry, VersionReq};
1944
1945 let cache = Arc::new(HttpCache::new());
1946 let registry = PypiRegistry::new(cache);
1947 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1948 Box::new(PypiVersion {
1949 version: "2.0.0rc1".into(),
1950 yanked: false,
1951 published_at: None,
1952 }),
1953 Box::new(PypiVersion {
1954 version: "1.0.0rc1".into(),
1955 yanked: false,
1956 published_at: None,
1957 }),
1958 ];
1959 let req = VersionReq::new("*");
1960 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1961 }
1962
1963 #[tokio::test]
1964 async fn test_search_empty_query_returns_empty_without_building_index() {
1965 let mut server = mockito::Server::new_async().await;
1969 let mock = server
1970 .mock("GET", "/simple/")
1971 .expect(0)
1972 .create_async()
1973 .await;
1974
1975 let cache = Arc::new(HttpCache::new());
1976 let index_url = format!("{}/simple/", server.url());
1977 let registry = PypiRegistry::with_index_url(cache, index_url);
1978
1979 assert!(registry.search("", 10).await.unwrap().is_empty());
1980 assert!(registry.search("---", 10).await.unwrap().is_empty());
1981 mock.assert_async().await;
1982 }
1983
1984 #[tokio::test]
1985 async fn test_search_cold_start_returns_empty_immediately() {
1986 let mut server = mockito::Server::new_async().await;
1987 let mock = server
1988 .mock("GET", "/simple/")
1989 .with_status(200)
1990 .with_body(crate::search::sample_index_body(&["requests"]))
1991 .expect(1)
1992 .create_async()
1993 .await;
1994
1995 let cache = Arc::new(HttpCache::new());
1996 let index_url = format!("{}/simple/", server.url());
1997 let registry = PypiRegistry::with_index_url(cache, index_url);
1998
1999 let results = registry.search("reque", 10).await.unwrap();
2001 assert!(
2002 results.is_empty(),
2003 "cold start must return empty immediately"
2004 );
2005
2006 for _ in 0..100 {
2012 if mock.matched_async().await {
2013 break;
2014 }
2015 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2016 }
2017 mock.assert_async().await;
2018 }
2019
2020 #[tokio::test]
2021 async fn test_search_finds_match_after_index_builds() {
2022 let mut server = mockito::Server::new_async().await;
2023 let mock = server
2024 .mock("GET", "/simple/")
2025 .with_status(200)
2026 .with_body(crate::search::sample_index_body(&[
2027 "requests",
2028 "requests-oauthlib",
2029 ]))
2030 .expect(1)
2031 .create_async()
2032 .await;
2033
2034 let cache = Arc::new(HttpCache::new());
2035 let index_url = format!("{}/simple/", server.url());
2036 let registry = PypiRegistry::with_index_url(cache, index_url);
2037
2038 let mut results = registry.search("reque", 10).await.unwrap();
2039 for _ in 0..100 {
2040 if !results.is_empty() {
2041 break;
2042 }
2043 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2044 results = registry.search("reque", 10).await.unwrap();
2045 }
2046
2047 let names: Vec<String> = results.iter().map(|p| p.name.to_string()).collect();
2048 assert!(names.contains(&"requests".to_string()));
2049 assert!(names.contains(&"requests-oauthlib".to_string()));
2050 let _ = registry.search("req", 10).await.unwrap();
2053 mock.assert_async().await;
2054 }
2055
2056 #[tokio::test]
2057 async fn test_search_no_match_returns_empty_once_index_is_ready() {
2058 let mut server = mockito::Server::new_async().await;
2059 let _mock = server
2060 .mock("GET", "/simple/")
2061 .with_status(200)
2062 .with_body(crate::search::sample_index_body(&["requests"]))
2063 .create_async()
2064 .await;
2065
2066 let cache = Arc::new(HttpCache::new());
2067 let index_url = format!("{}/simple/", server.url());
2068 let registry = PypiRegistry::with_index_url(cache, index_url);
2069
2070 let mut became_ready = false;
2074 for _ in 0..100 {
2075 if !registry.search("reque", 10).await.unwrap().is_empty() {
2076 became_ready = true;
2077 break;
2078 }
2079 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2080 }
2081 assert!(
2086 became_ready,
2087 "index never became ready within the poll budget"
2088 );
2089
2090 let no_match = registry
2091 .search("this-prefix-matches-nothing-zzz", 10)
2092 .await
2093 .unwrap();
2094 assert!(no_match.is_empty());
2095 }
2096
2097 fn all_policy() -> deps_core::net_policy::RegistryAccessPolicy {
2100 deps_core::net_policy::RegistryAccessPolicy::new(
2101 deps_core::net_policy::WorkspaceRegistryAccess::All,
2102 )
2103 }
2104
2105 fn index_url(raw: &str) -> PypiIndexUrl {
2108 PypiIndexUrl::new(raw, &all_policy()).unwrap()
2109 }
2110
2111 #[tokio::test]
2116 async fn test_with_base_fetches_configured_host_not_pypi_org() {
2117 let mut server = mockito::Server::new_async().await;
2118 let mock = server
2119 .mock("GET", "/simple/flask/")
2120 .with_status(200)
2121 .with_body(r#"{"versions": ["3.0.0"], "files": []}"#)
2122 .create_async()
2123 .await;
2124
2125 let cache = Arc::new(HttpCache::new());
2126 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2127 let base = index_url(&format!("{}/simple", server.url()));
2128 let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2129
2130 let versions = client.get_versions("flask").await.unwrap();
2131 assert_eq!(versions.len(), 1);
2132 assert_eq!(versions[0].version.as_str(), "3.0.0");
2133 mock.assert_async().await;
2134 }
2135
2136 #[tokio::test]
2139 async fn test_get_versions_from_case_a_primary_wins() {
2140 use deps_core::PackageName;
2141
2142 let mut primary_server = mockito::Server::new_async().await;
2143 let primary_mock = primary_server
2144 .mock("GET", "/simple/pkg/")
2145 .with_status(200)
2146 .with_body(r#"{"versions": ["1.0.0"], "files": []}"#)
2147 .create_async()
2148 .await;
2149
2150 let mut extra_server = mockito::Server::new_async().await;
2151 let extra_mock = extra_server
2152 .mock("GET", "/simple/pkg/")
2153 .with_status(200)
2154 .with_body(r#"{"versions": ["9.9.9"], "files": []}"#)
2155 .expect(0)
2156 .create_async()
2157 .await;
2158
2159 let cache = Arc::new(HttpCache::new());
2160 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2161 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2162
2163 let primary = index_url(&format!("{}/simple", primary_server.url()));
2164 let extra = index_url(&format!("{}/simple", extra_server.url()));
2165 let chain = crate::config::ResolvedChain {
2166 key: "test-chain-a".to_string(),
2167 hops: vec![primary, extra],
2168 implicit_public_fallback: false,
2169 };
2170 PypiRegistry::register_chain(&root, &chain);
2171
2172 let source = DependencySource::AlternateRegistry {
2173 index: chain.key.clone(),
2174 mirrors_crates_io: false,
2175 };
2176 let versions = deps_core::Registry::get_versions_from(
2177 root.as_ref(),
2178 &PackageName::new("pkg"),
2179 &source,
2180 deps_core::FreshnessSettings::default(),
2181 )
2182 .await
2183 .unwrap();
2184 assert_eq!(versions.len(), 1);
2185 assert_eq!(versions[0].version_string().as_str(), "1.0.0");
2186
2187 primary_mock.assert_async().await;
2188 extra_mock.assert_async().await;
2189 }
2190
2191 #[tokio::test]
2193 async fn test_get_versions_chained_falls_through_on_package_not_found() {
2194 let mut hop0_server = mockito::Server::new_async().await;
2195 let hop0_mock = hop0_server
2196 .mock("GET", "/simple/pkg/")
2197 .with_status(404)
2198 .create_async()
2199 .await;
2200
2201 let mut hop1_server = mockito::Server::new_async().await;
2202 let hop1_mock = hop1_server
2203 .mock("GET", "/simple/pkg/")
2204 .with_status(200)
2205 .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2206 .create_async()
2207 .await;
2208
2209 let cache = Arc::new(HttpCache::new());
2210 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2211 let hop1 = Arc::new(PypiRegistry::with_base(
2212 Arc::clone(&cache),
2213 &index_url(&format!("{}/simple", hop1_server.url())),
2214 Vec::new(),
2215 ));
2216 let head = PypiRegistry::with_base(
2217 Arc::clone(&cache),
2218 &index_url(&format!("{}/simple", hop0_server.url())),
2219 vec![hop1],
2220 );
2221
2222 let versions = head.get_versions_chained("pkg").await.unwrap();
2223 assert_eq!(versions.len(), 1);
2224 assert_eq!(versions[0].version.as_str(), "2.0.0");
2225
2226 hop0_mock.assert_async().await;
2227 hop1_mock.assert_async().await;
2228 }
2229
2230 #[tokio::test]
2233 async fn test_get_versions_chained_falls_through_on_empty_listing() {
2234 let mut hop0_server = mockito::Server::new_async().await;
2235 let hop0_mock = hop0_server
2236 .mock("GET", "/simple/pkg/")
2237 .with_status(200)
2238 .with_body(r#"{"versions": [], "files": []}"#)
2239 .create_async()
2240 .await;
2241
2242 let mut hop1_server = mockito::Server::new_async().await;
2243 let hop1_mock = hop1_server
2244 .mock("GET", "/simple/pkg/")
2245 .with_status(200)
2246 .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2247 .create_async()
2248 .await;
2249
2250 let cache = Arc::new(HttpCache::new());
2251 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2252 let hop1 = Arc::new(PypiRegistry::with_base(
2253 Arc::clone(&cache),
2254 &index_url(&format!("{}/simple", hop1_server.url())),
2255 Vec::new(),
2256 ));
2257 let head = PypiRegistry::with_base(
2258 Arc::clone(&cache),
2259 &index_url(&format!("{}/simple", hop0_server.url())),
2260 vec![hop1],
2261 );
2262
2263 let versions = head.get_versions_chained("pkg").await.unwrap();
2264 assert_eq!(versions.len(), 1);
2265 assert_eq!(versions[0].version.as_str(), "2.0.0");
2266
2267 hop0_mock.assert_async().await;
2268 hop1_mock.assert_async().await;
2269 }
2270
2271 #[tokio::test]
2276 async fn test_get_versions_chained_terminates_on_transport_error_never_tries_next_hop() {
2277 let mut hop0_server = mockito::Server::new_async().await;
2278 let hop0_mock = hop0_server
2279 .mock("GET", "/simple/pkg/")
2280 .with_status(503)
2281 .create_async()
2282 .await;
2283
2284 let mut hop1_server = mockito::Server::new_async().await;
2285 let hop1_mock = hop1_server
2286 .mock("GET", "/simple/pkg/")
2287 .with_status(200)
2288 .with_body(r#"{"versions": ["2.0.0"], "files": []}"#)
2289 .expect(0)
2290 .create_async()
2291 .await;
2292
2293 let cache = Arc::new(HttpCache::new());
2294 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2295 let hop1 = Arc::new(PypiRegistry::with_base(
2296 Arc::clone(&cache),
2297 &index_url(&format!("{}/simple", hop1_server.url())),
2298 Vec::new(),
2299 ));
2300 let head = PypiRegistry::with_base(
2301 Arc::clone(&cache),
2302 &index_url(&format!("{}/simple", hop0_server.url())),
2303 vec![hop1],
2304 );
2305
2306 let err = head.get_versions_chained("pkg").await.unwrap_err();
2307 assert!(
2313 matches!(err, DepsError::ChainResolutionHalted),
2314 "expected ChainResolutionHalted, got: {err:?}"
2315 );
2316 assert_eq!(
2320 err.fetch_failure(),
2321 deps_core::error::FetchFailure::Actionable(
2322 "index unreachable — resolution halted, not falling back to a less-trusted \
2323 index"
2324 .to_string()
2325 )
2326 );
2327
2328 hop0_mock.assert_async().await;
2329 hop1_mock.assert_async().await;
2330 }
2331
2332 #[tokio::test]
2335 async fn test_transport_error_logs_distinguishable_diagnostic() {
2336 let mut hop0_server = mockito::Server::new_async().await;
2337 let hop0_mock = hop0_server
2338 .mock("GET", "/simple/pkg/")
2339 .with_status(503)
2340 .create_async()
2341 .await;
2342
2343 let cache = Arc::new(HttpCache::new());
2344 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2345 let head = PypiRegistry::with_base(
2346 Arc::clone(&cache),
2347 &index_url(&format!("{}/simple", hop0_server.url())),
2348 Vec::new(),
2349 );
2350
2351 let log = deps_core::test_util::capture_tracing_output_async(async {
2352 head.get_versions_chained("pkg").await.unwrap_err();
2353 })
2354 .await;
2355 assert!(
2356 log.contains("not falling back to pypi.org"),
2357 "expected a distinguishable halted-chain diagnostic, got: {log:?}"
2358 );
2359
2360 hop0_mock.assert_async().await;
2361 }
2362
2363 #[test]
2366 fn test_register_chain_empty_hops_is_noop() {
2367 let cache = Arc::new(HttpCache::new());
2368 let root = Arc::new(PypiRegistry::new(cache));
2369 let chain = crate::config::ResolvedChain {
2370 key: "empty".to_string(),
2371 hops: Vec::new(),
2372 implicit_public_fallback: false,
2373 };
2374 PypiRegistry::register_chain(&root, &chain);
2375 assert!(root.alternate_client("empty").is_none());
2376 }
2377
2378 #[test]
2381 fn test_register_chain_idempotent() {
2382 let cache = Arc::new(HttpCache::new());
2383 let root = Arc::new(PypiRegistry::new(cache));
2384 let chain = crate::config::ResolvedChain {
2385 key: "dup".to_string(),
2386 hops: vec![index_url("https://a.example/simple")],
2387 implicit_public_fallback: false,
2388 };
2389 PypiRegistry::register_chain(&root, &chain);
2390 let first = root.alternate_client("dup").unwrap();
2391 PypiRegistry::register_chain(&root, &chain);
2392 let second = root.alternate_client("dup").unwrap();
2393 assert!(Arc::ptr_eq(&first, &second));
2394 }
2395
2396 #[test]
2399 fn test_register_chain_capacity_cap() {
2400 let cache = Arc::new(HttpCache::new());
2401 let root = Arc::new(PypiRegistry::new(cache));
2402 for i in 0..MAX_ALTERNATE_REGISTRIES {
2403 let chain = crate::config::ResolvedChain {
2404 key: format!("chain-{i}"),
2405 hops: vec![index_url("https://a.example/simple")],
2406 implicit_public_fallback: false,
2407 };
2408 PypiRegistry::register_chain(&root, &chain);
2409 }
2410 let overflow = crate::config::ResolvedChain {
2411 key: "overflow".to_string(),
2412 hops: vec![index_url("https://b.example/simple")],
2413 implicit_public_fallback: false,
2414 };
2415 PypiRegistry::register_chain(&root, &overflow);
2416 assert!(root.alternate_client("overflow").is_none());
2417 }
2418
2419 #[test]
2423 fn test_alternate_client_only_meaningful_on_root() {
2424 let cache = Arc::new(HttpCache::new());
2425 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2426 let chain = crate::config::ResolvedChain {
2427 key: "chain".to_string(),
2428 hops: vec![index_url("https://a.example/simple")],
2429 implicit_public_fallback: false,
2430 };
2431 PypiRegistry::register_chain(&root, &chain);
2432 let head = root.alternate_client("chain").unwrap();
2433 assert!(head.alternate_client("chain").is_none());
2434 }
2435
2436 #[test]
2441 fn test_implicit_public_hop_does_not_create_reference_cycle() {
2442 let cache = Arc::new(HttpCache::new());
2443 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2444 let root_weak = Arc::downgrade(&root);
2445
2446 let chain = crate::config::ResolvedChain {
2447 key: "implicit".to_string(),
2448 hops: vec![index_url("https://a.example/simple")],
2449 implicit_public_fallback: true,
2450 };
2451 PypiRegistry::register_chain(&root, &chain);
2452
2453 drop(root);
2454 assert!(
2455 root_weak.upgrade().is_none(),
2456 "root must deallocate once its only strong reference is dropped — a cycle would \
2457 keep it alive"
2458 );
2459 }
2460
2461 #[test]
2467 fn test_register_chain_implicit_public_fallback_hop_shape() {
2468 let cache = Arc::new(HttpCache::new());
2469 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2470 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2471
2472 let extra = index_url("https://extra.example/simple");
2473 let chain = crate::config::ResolvedChain {
2474 key: "case-b".to_string(),
2475 hops: vec![extra],
2476 implicit_public_fallback: true,
2477 };
2478 PypiRegistry::register_chain(&root, &chain);
2479
2480 let head = root.alternate_client("case-b").unwrap();
2481 assert_eq!(head.simple_base, "https://extra.example/simple");
2482 assert_eq!(head.fallback_chain.len(), 1);
2483 assert_eq!(head.fallback_chain[0].tier, PypiRegistryTier::Public);
2484 assert_eq!(head.fallback_chain[0].simple_base, PYPI_SIMPLE_BASE);
2485 }
2486
2487 #[tokio::test]
2493 async fn test_case_b_extra_wins_over_implicit_public_request_count_asserted() {
2494 use deps_core::PackageName;
2495
2496 let mut extra_server = mockito::Server::new_async().await;
2497 let extra_mock = extra_server
2498 .mock("GET", "/simple/mypkg/")
2499 .with_status(200)
2500 .with_body(r#"{"versions": ["1.0.0"], "files": []}"#)
2501 .expect(1)
2502 .create_async()
2503 .await;
2504
2505 let mut public_server = mockito::Server::new_async().await;
2506 let public_mock = public_server
2507 .mock("GET", mockito::Matcher::Any)
2508 .expect(0)
2509 .create_async()
2510 .await;
2511
2512 let cache = Arc::new(HttpCache::new());
2513 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2514 let root = Arc::new(PypiRegistry::with_public_base_for_test(
2515 Arc::clone(&cache),
2516 format!("{}/simple", public_server.url()),
2517 ));
2518
2519 let extra = index_url(&format!("{}/simple", extra_server.url()));
2520 let chain = crate::config::ResolvedChain {
2521 key: "case-b-request-count".to_string(),
2522 hops: vec![extra],
2523 implicit_public_fallback: true,
2524 };
2525 PypiRegistry::register_chain(&root, &chain);
2526
2527 let source = DependencySource::AlternateRegistry {
2528 index: chain.key.clone(),
2529 mirrors_crates_io: false,
2530 };
2531 let versions = deps_core::Registry::get_versions_from(
2532 root.as_ref(),
2533 &PackageName::new("mypkg"),
2534 &source,
2535 deps_core::FreshnessSettings::default(),
2536 )
2537 .await
2538 .unwrap();
2539 assert_eq!(versions.len(), 1);
2540
2541 extra_mock.assert_async().await;
2542 public_mock.assert_async().await;
2543 }
2544
2545 #[tokio::test]
2549 async fn test_case_b_falls_through_to_implicit_public_when_extra_misses() {
2550 use deps_core::PackageName;
2551
2552 let mut extra_server = mockito::Server::new_async().await;
2553 let extra_mock = extra_server
2554 .mock("GET", "/simple/mypkg/")
2555 .with_status(404)
2556 .create_async()
2557 .await;
2558
2559 let mut public_server = mockito::Server::new_async().await;
2560 let public_mock = public_server
2561 .mock("GET", "/simple/mypkg/")
2562 .with_status(200)
2563 .with_body(r#"{"versions": ["9.9.9"], "files": []}"#)
2564 .create_async()
2565 .await;
2566
2567 let cache = Arc::new(HttpCache::new());
2568 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2569 let root = Arc::new(PypiRegistry::with_public_base_for_test(
2570 Arc::clone(&cache),
2571 format!("{}/simple", public_server.url()),
2572 ));
2573
2574 let extra = index_url(&format!("{}/simple", extra_server.url()));
2575 let chain = crate::config::ResolvedChain {
2576 key: "case-b-fallthrough".to_string(),
2577 hops: vec![extra],
2578 implicit_public_fallback: true,
2579 };
2580 PypiRegistry::register_chain(&root, &chain);
2581
2582 let source = DependencySource::AlternateRegistry {
2583 index: chain.key.clone(),
2584 mirrors_crates_io: false,
2585 };
2586 let versions = deps_core::Registry::get_versions_from(
2587 root.as_ref(),
2588 &PackageName::new("mypkg"),
2589 &source,
2590 deps_core::FreshnessSettings::default(),
2591 )
2592 .await
2593 .unwrap();
2594 assert_eq!(versions.len(), 1);
2595 assert_eq!(versions[0].version_string().as_str(), "9.9.9");
2596
2597 extra_mock.assert_async().await;
2598 public_mock.assert_async().await;
2599 }
2600
2601 #[tokio::test]
2606 async fn test_get_latest_matching_from_alternate_registry_happy_path() {
2607 use deps_core::PackageName;
2608
2609 let mut server = mockito::Server::new_async().await;
2610 let mock = server
2611 .mock("GET", "/simple/pkg/")
2612 .with_status(200)
2613 .with_body(r#"{"versions": ["1.0.0", "1.5.0", "2.0.0"], "files": []}"#)
2614 .create_async()
2615 .await;
2616
2617 let cache = Arc::new(HttpCache::new());
2618 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2619 let root = Arc::new(PypiRegistry::new(Arc::clone(&cache)));
2620
2621 let chain = crate::config::ResolvedChain {
2622 key: "latest-matching-happy-path".to_string(),
2623 hops: vec![index_url(&format!("{}/simple", server.url()))],
2624 implicit_public_fallback: false,
2625 };
2626 PypiRegistry::register_chain(&root, &chain);
2627
2628 let source = DependencySource::AlternateRegistry {
2629 index: chain.key.clone(),
2630 mirrors_crates_io: false,
2631 };
2632 let latest = deps_core::Registry::get_latest_matching_from(
2633 root.as_ref(),
2634 &PackageName::new("pkg"),
2635 &source,
2636 &deps_core::VersionReq::new(">=1.0.0,<2.0.0"),
2637 None,
2638 )
2639 .await
2640 .unwrap();
2641 assert_eq!(
2642 latest.map(|v| v.version_string().to_string()),
2643 Some("1.5.0".to_string())
2644 );
2645 mock.assert_async().await;
2646 }
2647
2648 #[tokio::test]
2652 async fn test_three_hop_chain_falls_through_to_third_hop() {
2653 let mut hop0_server = mockito::Server::new_async().await;
2654 let hop0_mock = hop0_server
2655 .mock("GET", "/simple/pkg/")
2656 .with_status(404)
2657 .create_async()
2658 .await;
2659
2660 let mut hop1_server = mockito::Server::new_async().await;
2661 let hop1_mock = hop1_server
2662 .mock("GET", "/simple/pkg/")
2663 .with_status(200)
2664 .with_body(r#"{"versions": [], "files": []}"#)
2665 .create_async()
2666 .await;
2667
2668 let mut hop2_server = mockito::Server::new_async().await;
2669 let hop2_mock = hop2_server
2670 .mock("GET", "/simple/pkg/")
2671 .with_status(200)
2672 .with_body(r#"{"versions": ["3.0.0"], "files": []}"#)
2673 .create_async()
2674 .await;
2675
2676 let cache = Arc::new(HttpCache::new());
2677 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2678 let hop1 = Arc::new(PypiRegistry::with_base(
2683 Arc::clone(&cache),
2684 &index_url(&format!("{}/simple", hop1_server.url())),
2685 Vec::new(),
2686 ));
2687 let hop2 = Arc::new(PypiRegistry::with_base(
2688 Arc::clone(&cache),
2689 &index_url(&format!("{}/simple", hop2_server.url())),
2690 Vec::new(),
2691 ));
2692 let head = PypiRegistry::with_base(
2693 Arc::clone(&cache),
2694 &index_url(&format!("{}/simple", hop0_server.url())),
2695 vec![hop1, hop2],
2696 );
2697
2698 let versions = head.get_versions_chained("pkg").await.unwrap();
2699 assert_eq!(versions.len(), 1);
2700 assert_eq!(versions[0].version.as_str(), "3.0.0");
2701
2702 hop0_mock.assert_async().await;
2703 hop1_mock.assert_async().await;
2704 hop2_mock.assert_async().await;
2705 }
2706
2707 #[tokio::test]
2710 async fn test_get_versions_from_unregistered_alternate_never_falls_back() {
2711 use deps_core::PackageName;
2712
2713 let cache = Arc::new(HttpCache::new());
2714 let registry = PypiRegistry::new(cache);
2715 let source = DependencySource::AlternateRegistry {
2716 index: "pypi-chain:never-registered".to_string(),
2717 mirrors_crates_io: false,
2718 };
2719 let result = deps_core::Registry::get_versions_from(
2720 ®istry,
2721 &PackageName::new("pkg"),
2722 &source,
2723 deps_core::FreshnessSettings::default(),
2724 )
2725 .await;
2726 assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
2727
2728 let result = deps_core::Registry::get_latest_matching_from(
2729 ®istry,
2730 &PackageName::new("pkg"),
2731 &source,
2732 &deps_core::VersionReq::new("*"),
2733 None,
2734 )
2735 .await;
2736 assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
2737 }
2738
2739 #[tokio::test]
2742 async fn test_search_on_workspace_declared_tier_issues_no_request() {
2743 let mut server = mockito::Server::new_async().await;
2744 let mock = server
2745 .mock("GET", mockito::Matcher::Any)
2746 .expect(0)
2747 .create_async()
2748 .await;
2749
2750 let cache = Arc::new(HttpCache::new());
2751 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2752 let base = index_url(&format!("{}/simple", server.url()));
2753 let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2754
2755 assert!(client.search("flask", 10).await.unwrap().is_empty());
2756 mock.assert_async().await;
2757 }
2758
2759 #[tokio::test]
2760 async fn test_warm_search_index_on_workspace_declared_tier_issues_no_request() {
2761 let mut server = mockito::Server::new_async().await;
2762 let mock = server
2763 .mock("GET", mockito::Matcher::Any)
2764 .expect(0)
2765 .create_async()
2766 .await;
2767
2768 let cache = Arc::new(HttpCache::new());
2769 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2770 let base = index_url(&format!("{}/simple", server.url()));
2771 let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2772
2773 client.warm_search_index();
2774 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
2775 mock.assert_async().await;
2776 }
2777
2778 #[tokio::test]
2779 async fn test_get_package_metadata_on_workspace_declared_tier_issues_no_request() {
2780 let mut server = mockito::Server::new_async().await;
2781 let mock = server
2782 .mock("GET", mockito::Matcher::Any)
2783 .expect(0)
2784 .create_async()
2785 .await;
2786
2787 let cache = Arc::new(HttpCache::new());
2788 cache.set_registry_policy(deps_core::net_policy::WorkspaceRegistryAccess::All);
2789 let base = index_url(&format!("{}/simple", server.url()));
2790 let client = PypiRegistry::with_base(Arc::clone(&cache), &base, Vec::new());
2791
2792 let err = client.get_package_metadata("flask").await.unwrap_err();
2793 assert_matches!(err, DepsError::PackageNotFound { .. });
2794 mock.assert_async().await;
2795 }
2796}