1use crate::types::{ArtifactInfo, MavenVersion};
7use crate::version::compare_versions;
8use bytes::Bytes;
9use dashmap::DashMap;
10use deps_core::{
11 DepsError, HttpCache, PublishTime, Result, is_safe_maven_coordinate_segment,
12 lsp_helpers::warn_rejected_value,
13};
14use quick_xml::events::Event;
15use quick_xml::reader::Reader;
16use serde::Deserialize;
17use std::any::Any;
18use std::collections::HashMap;
19use std::sync::Arc;
20use std::time::Duration;
21
22const MAVEN_REPO_BASE: &str = "https://repo1.maven.org/maven2";
23
24pub const REGISTRY: &str = "Maven Central";
27const GOOGLE_MAVEN_BASE: &str = "https://dl.google.com/dl/android/maven2";
28const GRADLE_PLUGIN_PORTAL_BASE: &str = "https://plugins.gradle.org/m2";
29const MAVEN_SEARCH_BASE: &str = "https://search.maven.org/solrsearch/select";
30
31const SEARCH_ATTEMPT_TIMEOUTS: [Duration; 2] =
63 [Duration::from_millis(1000), Duration::from_millis(1200)];
64
65const SEARCH_RETRY_DELAY: Duration = Duration::from_millis(100);
67
68const SEARCH_CACHE_ROWS: usize = 50;
82
83const RECENT_FAILURE_TTL: Duration = deps_core::completion::COMPLETION_SEARCH_TIMEOUT;
103
104const GOOGLE_PREFIXES: &[&str] = &[
105 "androidx.",
106 "com.google.firebase.",
107 "com.google.android.",
108 "com.google.gms.",
109 "com.android.",
110];
111
112fn is_google_group(group_id: &str) -> bool {
113 GOOGLE_PREFIXES.iter().any(|p| group_id.starts_with(p))
114}
115
116fn repo_base_for_group(group_id: &str) -> &'static str {
117 if is_google_group(group_id) {
118 GOOGLE_MAVEN_BASE
119 } else {
120 MAVEN_REPO_BASE
121 }
122}
123
124pub fn package_url(name: &str) -> String {
132 let parts: Vec<&str> = name.splitn(2, ':').collect();
133 if parts.len() == 2 {
134 let group_id = parts[0];
135 let artifact_id = parts[1];
136 if is_google_group(group_id) {
137 format!(
138 "https://maven.google.com/web/index.html#{}:{}",
139 urlencoding::encode(group_id),
140 urlencoding::encode(artifact_id)
141 )
142 } else {
143 format!(
144 "https://central.sonatype.com/artifact/{}/{}",
145 urlencoding::encode(group_id),
146 urlencoding::encode(artifact_id)
147 )
148 }
149 } else {
150 format!(
151 "https://central.sonatype.com/search?q={}",
152 urlencoding::encode(name)
153 )
154 }
155}
156
157fn move_release_to_front(versions: &mut Vec<MavenVersion>, release: Option<&str>) {
180 let target = match release {
181 Some(release) => versions.iter().position(|v| v.version == release),
182 None => versions
183 .iter()
184 .position(|v| !crate::version::is_prerelease(v.version.as_str())),
185 };
186 let Some(pos) = target else { return };
187 if pos != 0 {
188 let entry = versions.remove(pos);
189 versions.insert(0, entry);
190 }
191}
192
193fn pick_wildcard_latest(versions: &[MavenVersion], release: Option<&str>) -> Option<MavenVersion> {
221 if let Some(rel) = release {
222 if let Some(found) = versions.iter().find(|v| v.version == rel) {
223 return Some(found.clone());
224 }
225 if crate::version::is_prerelease(rel) {
226 return None;
227 }
228 return Some(MavenVersion {
229 version: rel.into(),
230 published_at: None,
231 });
232 }
233 versions
234 .iter()
235 .find(|v| !crate::version::is_prerelease(v.version.as_str()))
236 .or_else(|| versions.first())
237 .cloned()
238}
239
240fn should_fetch_listing(base: &str) -> bool {
249 base.starts_with(MAVEN_REPO_BASE)
250}
251
252fn attach_publish_times(versions: &mut [MavenVersion], times: &HashMap<String, PublishTime>) {
258 for v in versions {
259 v.published_at = times.get(v.version.as_str()).copied();
260 }
261}
262
263#[derive(Clone)]
264pub struct MavenCentralRegistry {
265 cache: Arc<HttpCache>,
266 recent_search_failures: Arc<DashMap<String, tokio::time::Instant>>,
276}
277
278impl MavenCentralRegistry {
279 pub fn new(cache: Arc<HttpCache>) -> Self {
280 Self {
281 cache,
282 recent_search_failures: Arc::new(DashMap::new()),
283 }
284 }
285
286 async fn get_metadata(
291 &self,
292 name: &str,
293 ) -> Result<(Vec<MavenVersion>, Option<String>, Option<String>)> {
294 let urls = metadata_urls(name)?;
295 if urls.is_empty() {
296 tracing::debug!(package = %name, "skipping: invalid groupId:artifactId format");
297 return Ok((vec![], None, None));
298 }
299
300 let mut last_err = None;
301 for url in &urls {
302 match self.cache.get_cached(url).await {
303 Ok(data) => {
304 let (versions, release) = parse_metadata_xml(&data)?;
305 let base = url.strip_suffix("maven-metadata.xml").map(str::to_string);
306 return Ok((versions, release, base));
307 }
308 Err(e) => {
309 tracing::debug!(package = %name, url = %url, error = %e, "metadata fetch failed, trying next");
310 last_err = Some(e);
311 }
312 }
313 }
314
315 let e = last_err.expect("urls is non-empty");
316 tracing::warn!(package = %name, error = %e, "all metadata URLs failed");
317 Err(e)
318 }
319
320 async fn fetch_publish_times(&self, base: &str) -> HashMap<String, PublishTime> {
327 match self.cache.get_cached(base).await {
328 Ok(data) => parse_publish_times(&data),
329 Err(e) => {
330 tracing::debug!(url = %base, error = %e, "listing fetch failed, publish times unavailable");
331 HashMap::new()
332 }
333 }
334 }
335
336 pub async fn get_versions_typed_with(
346 &self,
347 name: &str,
348 freshness_enabled: bool,
349 ) -> Result<Vec<MavenVersion>> {
350 let (mut versions, release, base) = self.get_metadata(name).await?;
351 if freshness_enabled && let Some(base) = base.as_deref().filter(|b| should_fetch_listing(b))
352 {
353 let times = self.fetch_publish_times(base).await;
354 attach_publish_times(&mut versions, ×);
355 }
356 move_release_to_front(&mut versions, release.as_deref());
357 Ok(versions)
358 }
359
360 pub async fn get_versions_typed(&self, name: &str) -> Result<Vec<MavenVersion>> {
365 self.get_versions_typed_with(name, false).await
366 }
367
368 pub async fn get_latest_matching_typed(
369 &self,
370 name: &str,
371 req: &str,
372 ) -> Result<Option<MavenVersion>> {
373 let (versions, release, _base) = self.get_metadata(name).await?;
374 if req.is_empty() || req == "*" {
376 return Ok(pick_wildcard_latest(&versions, release.as_deref()));
377 }
378 Ok(versions.into_iter().find(|v| v.version == req))
379 }
380
381 pub async fn search_typed(&self, query: &str, limit: usize) -> Result<Vec<ArtifactInfo>> {
405 debug_assert!(
406 limit <= SEARCH_CACHE_ROWS,
407 "search_typed's limit ({limit}) exceeds SEARCH_CACHE_ROWS ({SEARCH_CACHE_ROWS}); \
408 raise SEARCH_CACHE_ROWS to cover every caller's requested limit"
409 );
410
411 if is_recently_failed(&self.recent_search_failures, query) {
412 return Err(DepsError::CacheError(format!(
413 "solrsearch recently failed for query {query:?}, \
414 skipping duplicate live attempt"
415 )));
416 }
417
418 let url = search_url(query);
419 let data = search_with_retry(
420 || self.cache.get_cached(&url),
421 || self.cache.peek_cached(&url),
422 )
423 .await;
424
425 let data = match data {
426 Ok(data) => data,
427 Err(e) => {
428 if !e.is_offline() {
434 record_search_failure(&self.recent_search_failures, query);
435 }
436 return Err(e);
437 }
438 };
439
440 match parse_search_response(&data, limit) {
446 Ok(results) => {
447 record_search_success(&self.recent_search_failures, query);
448 Ok(results)
449 }
450 Err(e) => {
451 record_search_failure(&self.recent_search_failures, query);
452 Err(e)
453 }
454 }
455 }
456}
457
458fn search_url(query: &str) -> String {
464 format!(
465 "{MAVEN_SEARCH_BASE}?q={q}&rows={SEARCH_CACHE_ROWS}&wt=json",
466 q = urlencoding::encode(query),
467 )
468}
469
470fn is_recently_failed(failures: &DashMap<String, tokio::time::Instant>, query: &str) -> bool {
472 failures
473 .get(query)
474 .is_some_and(|failed_at| failed_at.elapsed() < RECENT_FAILURE_TTL)
475}
476
477fn record_search_failure(failures: &DashMap<String, tokio::time::Instant>, query: &str) {
483 failures.retain(|_, failed_at| failed_at.elapsed() < RECENT_FAILURE_TTL);
484 failures.insert(query.to_string(), tokio::time::Instant::now());
485}
486
487fn record_search_success(failures: &DashMap<String, tokio::time::Instant>, query: &str) {
490 failures.remove(query);
491}
492
493fn is_retryable_error(e: &DepsError) -> bool {
508 !matches!(e, DepsError::HttpStatus { status, .. } if (400..500).contains(status))
509 && !e.is_offline()
510}
511
512async fn search_with_retry<F, Fut, S>(mut fetch: F, stale: S) -> Result<Bytes>
535where
536 F: FnMut() -> Fut,
537 Fut: std::future::Future<Output = Result<Bytes>>,
538 S: Fn() -> Option<Bytes>,
539{
540 let mut last_err = None;
541 for (i, attempt_timeout) in SEARCH_ATTEMPT_TIMEOUTS.iter().enumerate() {
542 let retryable = match tokio::time::timeout(*attempt_timeout, fetch()).await {
543 Ok(Ok(body)) => return Ok(body),
544 Ok(Err(e)) => {
545 let retryable = is_retryable_error(&e);
546 tracing::debug!(attempt = i + 1, error = %e, retryable, "solrsearch attempt failed");
547 last_err = Some(e);
548 retryable
549 }
550 Err(_) => {
551 tracing::debug!(attempt = i + 1, timeout = ?attempt_timeout, "solrsearch attempt timed out");
552 last_err = Some(DepsError::CacheError(format!(
553 "search request timed out after {attempt_timeout:?}"
554 )));
555 true
556 }
557 };
558
559 if let Some(body) = stale() {
560 tracing::warn!("solrsearch: serving stale cached result after a failed live attempt");
561 return Ok(body);
562 }
563
564 if !retryable || i + 1 == SEARCH_ATTEMPT_TIMEOUTS.len() {
565 break;
566 }
567 tokio::time::sleep(SEARCH_RETRY_DELAY).await;
568 }
569 Err(last_err
570 .expect("SEARCH_ATTEMPT_TIMEOUTS is non-empty, so at least one attempt always runs"))
571}
572
573fn metadata_urls(name: &str) -> Result<Vec<String>> {
590 let Some((group_id, artifact_id)) = name.split_once(':') else {
591 return Ok(vec![]);
592 };
593 if !is_safe_maven_coordinate_segment(group_id) {
594 warn_rejected_value(
595 "is_safe_maven_coordinate_segment",
596 "maven metadata URL groupId",
597 group_id,
598 );
599 return Err(DepsError::PackageNotFound {
600 package: name.to_string(),
601 registry: REGISTRY,
602 });
603 }
604 if !is_safe_maven_coordinate_segment(artifact_id) {
605 warn_rejected_value(
606 "is_safe_maven_coordinate_segment",
607 "maven metadata URL artifactId",
608 artifact_id,
609 );
610 return Err(DepsError::PackageNotFound {
611 package: name.to_string(),
612 registry: REGISTRY,
613 });
614 }
615 let group_path = group_id.replace('.', "/");
616 let primary_base = repo_base_for_group(group_id);
617 let primary = format!("{primary_base}/{group_path}/{artifact_id}/maven-metadata.xml");
618
619 Ok(if is_google_group(group_id) {
620 vec![primary]
621 } else {
622 vec![
623 primary,
624 format!("{GRADLE_PLUGIN_PORTAL_BASE}/{group_path}/{artifact_id}/maven-metadata.xml"),
625 ]
626 })
627}
628
629fn parse_metadata_xml(data: &[u8]) -> Result<(Vec<MavenVersion>, Option<String>)> {
642 let mut reader = Reader::from_reader(data);
643 let mut versions = Vec::new();
644 let mut release: Option<String> = None;
645 let mut in_versions = false;
646 let mut in_version = false;
647 let mut in_release = false;
648 let mut buf = Vec::new();
649
650 loop {
651 match reader.read_event_into(&mut buf) {
652 Ok(Event::Start(e)) => match e.name().as_ref() {
653 "versions" => in_versions = true,
654 "version" if in_versions => in_version = true,
655 "release" if !in_versions => in_release = true,
656 _ => {}
657 },
658 Ok(Event::End(e)) => match e.name().as_ref() {
659 "versions" => in_versions = false,
660 "version" => in_version = false,
661 "release" => in_release = false,
662 _ => {}
663 },
664 Ok(Event::Text(e)) => {
665 let text = quick_xml::escape::unescape(&e).unwrap_or_default();
666 let s = text.trim().to_string();
667 if s.is_empty() {
668 buf.clear();
669 continue;
670 }
671 if in_version {
672 versions.push(MavenVersion {
673 version: s.into(),
674 published_at: None,
675 });
676 } else if in_release {
677 release = Some(s);
678 }
679 }
680 Ok(Event::Eof) => break,
681 Err(e) => {
682 return Err(DepsError::CacheError(format!(
683 "malformed maven-metadata.xml: {e}"
684 )));
685 }
686 _ => {}
687 }
688 buf.clear();
689 }
690
691 versions.sort_by(|a, b| compare_versions(b.version.as_str(), a.version.as_str()));
692 Ok((versions, release))
693}
694
695fn parse_publish_times(html: &[u8]) -> HashMap<String, PublishTime> {
709 let mut map = HashMap::new();
710 let text = String::from_utf8_lossy(html);
711 let Some(pre) = extract_pre_block(&text) else {
712 return map;
713 };
714
715 for line in pre.lines() {
716 let Some(href) = extract_href(line) else {
717 continue;
718 };
719 let Some(version) = href.strip_suffix('/') else {
724 continue;
725 };
726 if version.is_empty() || version == ".." {
727 continue;
728 }
729 let Some(date_str) = find_date_time(line) else {
730 continue;
731 };
732 let rfc3339 = format!("{}T{}:00Z", &date_str[..10], &date_str[11..16]);
733 if let Some(published) = PublishTime::parse_rfc3339(&rfc3339) {
734 map.insert(version.to_string(), published);
735 }
736 }
737
738 map
739}
740
741fn extract_pre_block(html: &str) -> Option<&str> {
746 let open = html.find("<pre")?;
747 let content_start = html[open..].find('>')? + open + 1;
748 let close = html[content_start..].find("</pre")?;
749 Some(&html[content_start..content_start + close])
750}
751
752fn extract_href(line: &str) -> Option<&str> {
754 let idx = line.find("href=\"")?;
755 let rest = &line[idx + 6..];
756 let end = rest.find('"')?;
757 Some(&rest[..end])
758}
759
760fn find_date_time(line: &str) -> Option<&str> {
764 let bytes = line.as_bytes();
765 let window = 16; if bytes.len() < window {
767 return None;
768 }
769 for start in 0..=(bytes.len() - window) {
770 let candidate = &bytes[start..start + window];
771 if is_date_time_shape(candidate) {
772 return Some(&line[start..start + window]);
773 }
774 }
775 None
776}
777
778fn is_date_time_shape(b: &[u8]) -> bool {
779 let digit = u8::is_ascii_digit;
780 digit(&b[0])
781 && digit(&b[1])
782 && digit(&b[2])
783 && digit(&b[3])
784 && b[4] == b'-'
785 && digit(&b[5])
786 && digit(&b[6])
787 && b[7] == b'-'
788 && digit(&b[8])
789 && digit(&b[9])
790 && b[10] == b' '
791 && digit(&b[11])
792 && digit(&b[12])
793 && b[13] == b':'
794 && digit(&b[14])
795 && digit(&b[15])
796}
797
798#[derive(Deserialize)]
799struct SolrSearchResponse {
800 response: SolrSearchBody,
801}
802
803#[derive(Deserialize)]
804struct SolrSearchBody {
805 #[serde(default)]
806 docs: Vec<SearchDoc>,
807}
808
809#[derive(Deserialize)]
810struct SearchDoc {
811 g: String,
812 a: String,
813 #[serde(rename = "latestVersion")]
814 latest_version: Option<String>,
815}
816
817fn parse_search_response(data: &[u8], limit: usize) -> Result<Vec<ArtifactInfo>> {
818 let response: SolrSearchResponse = deps_core::parse_json_checked(data)?;
819
820 let results = response
821 .response
822 .docs
823 .into_iter()
824 .take(limit)
825 .map(|d| {
826 let name = format!("{}:{}", d.g, d.a);
827 ArtifactInfo {
828 group_id: d.g,
829 artifact_id: d.a,
830 name: name.into(),
831 description: None,
832 latest_version: d.latest_version.unwrap_or_default().into(),
833 repository: None,
834 }
835 })
836 .collect();
837
838 Ok(results)
839}
840
841impl deps_core::Registry for MavenCentralRegistry {
842 fn get_versions<'a>(
843 &'a self,
844 name: &'a deps_core::PackageName,
845 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
846 Box::pin(async move {
847 let versions = self.get_versions_typed(name.as_str()).await?;
848 Ok(versions
849 .into_iter()
850 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
851 .collect())
852 })
853 }
854
855 fn get_versions_with<'a>(
856 &'a self,
857 name: &'a deps_core::PackageName,
858 freshness: deps_core::FreshnessSettings,
859 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
860 Box::pin(async move {
861 let versions = self
862 .get_versions_typed_with(name.as_str(), freshness.enabled)
863 .await?;
864 Ok(versions
865 .into_iter()
866 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
867 .collect())
868 })
869 }
870
871 fn get_latest_matching<'a>(
872 &'a self,
873 name: &'a deps_core::PackageName,
874 req: &'a deps_core::VersionReq,
875 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
876 Box::pin(async move {
877 let version = self
878 .get_latest_matching_typed(name.as_str(), req.as_str())
879 .await?;
880 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
881 })
882 }
883
884 fn search<'a>(
885 &'a self,
886 query: &'a str,
887 limit: usize,
888 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
889 Box::pin(async move {
890 let results = self.search_typed(query, limit).await?;
891 Ok(results
892 .into_iter()
893 .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
894 .collect())
895 })
896 }
897
898 fn select_latest_matching(
899 &self,
900 versions: &[Box<dyn deps_core::Version>],
901 req: &deps_core::VersionReq,
902 ) -> Option<usize> {
903 let req_str = req.as_str();
915 if req_str.is_empty() || req_str == "*" {
916 if versions.is_empty() {
917 return None;
918 }
919 if let Some(idx) = versions.iter().position(|v| !v.is_prerelease()) {
920 return Some(idx);
921 }
922 return versions
927 .iter()
928 .enumerate()
929 .max_by(|(_, a), (_, b)| {
930 crate::version::compare_versions(
931 a.version_string().as_str(),
932 b.version_string().as_str(),
933 )
934 })
935 .map(|(idx, _)| idx);
936 }
937 versions.iter().position(|v| v.version_string() == req_str)
938 }
939
940 fn reports_yanked(&self) -> bool {
944 false
945 }
946
947 fn as_any(&self) -> &dyn Any {
948 self
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use super::*;
955
956 use std::assert_matches;
957
958 #[test]
959 fn test_repo_base_for_group_central() {
960 assert_eq!(repo_base_for_group("org.apache.commons"), MAVEN_REPO_BASE);
961 assert_eq!(repo_base_for_group("com.example"), MAVEN_REPO_BASE);
962 assert_eq!(repo_base_for_group("com.google.protobuf"), MAVEN_REPO_BASE);
964 }
965
966 #[test]
967 fn test_repo_base_for_group_google() {
968 assert_eq!(repo_base_for_group("androidx.core"), GOOGLE_MAVEN_BASE);
969 assert_eq!(
970 repo_base_for_group("com.google.firebase.crashlytics"),
971 GOOGLE_MAVEN_BASE
972 );
973 assert_eq!(
974 repo_base_for_group("com.google.android.gms"),
975 GOOGLE_MAVEN_BASE
976 );
977 assert_eq!(
978 repo_base_for_group("com.google.gms.google-services"),
979 GOOGLE_MAVEN_BASE
980 );
981 assert_eq!(repo_base_for_group("com.android.tools"), GOOGLE_MAVEN_BASE);
982 }
983
984 #[test]
985 fn test_package_url_central() {
986 assert_eq!(
987 package_url("org.apache.commons:commons-lang3"),
988 "https://central.sonatype.com/artifact/org.apache.commons/commons-lang3"
989 );
990 }
991
992 #[test]
993 fn test_package_url_google() {
994 assert_eq!(
995 package_url("androidx.core:core-ktx"),
996 "https://maven.google.com/web/index.html#androidx.core:core-ktx"
997 );
998 assert_eq!(
999 package_url("com.google.firebase.crashlytics:firebase-crashlytics"),
1000 "https://maven.google.com/web/index.html#com.google.firebase.crashlytics:firebase-crashlytics"
1001 );
1002 }
1003
1004 #[test]
1005 fn test_package_url_no_colon() {
1006 let url = package_url("bad");
1007 assert!(url.contains("search.maven") || url.contains("sonatype.com"));
1008 }
1009
1010 #[test]
1011 fn test_package_url_encodes_malicious_group_and_artifact() {
1012 let url = package_url("evil)[:pkg](x");
1013 assert!(!url.contains('('));
1014 assert!(!url.contains(')'));
1015 assert!(!url.contains('['));
1016 assert!(!url.contains(']'));
1017 }
1018
1019 #[test]
1020 fn test_package_url_google_encodes_malicious_group_and_artifact() {
1021 let url = package_url("androidx.evil)[:pkg](x");
1022 assert!(url.contains("maven.google.com"));
1023 assert!(!url.contains('('));
1024 assert!(!url.contains(')'));
1025 assert!(!url.contains('['));
1026 assert!(!url.contains(']'));
1027 }
1028
1029 #[test]
1030 fn test_package_url_encodes_newline_autolink_and_percent() {
1031 let url = package_url("evil\n<%:pkg>");
1032 assert!(!url.contains('\n'));
1033 assert!(!url.contains('<'));
1034 assert!(!url.contains('>'));
1035 assert!(url.contains("%25"));
1036 }
1037
1038 #[test]
1039 fn test_metadata_urls_central_has_two_urls() {
1040 let urls = metadata_urls("org.apache.commons:commons-lang3").unwrap();
1041 assert_eq!(urls.len(), 2);
1042 assert_eq!(
1043 urls[0],
1044 "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/maven-metadata.xml"
1045 );
1046 assert_eq!(
1047 urls[1],
1048 "https://plugins.gradle.org/m2/org/apache/commons/commons-lang3/maven-metadata.xml"
1049 );
1050 }
1051
1052 #[test]
1053 fn test_metadata_urls_google_has_one_url() {
1054 let urls = metadata_urls("androidx.core:core-ktx").unwrap();
1055 assert_eq!(urls.len(), 1);
1056 assert_eq!(
1057 urls[0],
1058 "https://dl.google.com/dl/android/maven2/androidx/core/core-ktx/maven-metadata.xml"
1059 );
1060
1061 let urls = metadata_urls("com.google.firebase.crashlytics:firebase-crashlytics").unwrap();
1062 assert_eq!(urls.len(), 1);
1063 assert_eq!(
1064 urls[0],
1065 "https://dl.google.com/dl/android/maven2/com/google/firebase/crashlytics/firebase-crashlytics/maven-metadata.xml"
1066 );
1067 }
1068
1069 #[test]
1070 fn test_metadata_urls_no_colon() {
1071 assert!(metadata_urls("bad").unwrap().is_empty());
1072 }
1073
1074 fn assert_rejected_as_not_found(result: Result<Vec<String>>) {
1081 let err = result.expect_err("rejected coordinate must be Err");
1082 assert!(
1083 matches!(err, DepsError::PackageNotFound { .. }),
1084 "expected PackageNotFound, got {err:?}"
1085 );
1086 assert!(err.is_not_found());
1087 }
1088
1089 #[test]
1100 fn test_metadata_urls_rejects_path_traversal_artifact_id() {
1101 assert_rejected_as_not_found(metadata_urls("com.example:../../../admin"));
1102 }
1103
1104 #[test]
1106 fn test_metadata_urls_rejects_path_traversal_group_id() {
1107 assert_rejected_as_not_found(metadata_urls("../../../admin:artifact"));
1108 }
1109
1110 #[test]
1118 fn test_metadata_urls_rejects_literal_dot_dot_artifact_id() {
1119 assert_rejected_as_not_found(metadata_urls("com.example:.."));
1120 assert_rejected_as_not_found(metadata_urls("a:.."));
1121 }
1122
1123 #[test]
1125 fn test_metadata_urls_rejects_literal_dot_segment() {
1126 assert_rejected_as_not_found(metadata_urls("com.example:."));
1127 assert_rejected_as_not_found(metadata_urls(".:artifact"));
1128 }
1129
1130 #[test]
1134 fn test_metadata_urls_dot_segment_sweep() {
1135 deps_core::test_util::assert_dot_segment_gated_or_contained(
1136 |seg| {
1137 metadata_urls(&format!("com.example:{seg}"))
1138 .ok()
1139 .and_then(|urls| urls.into_iter().next())
1140 },
1141 "repo1.maven.org",
1142 "/maven2/",
1143 );
1144 deps_core::test_util::assert_dot_segment_gated_or_contained(
1145 |seg| {
1146 metadata_urls(&format!("{seg}:artifact"))
1147 .ok()
1148 .and_then(|urls| urls.into_iter().next())
1149 },
1150 "repo1.maven.org",
1151 "/maven2/",
1152 );
1153 }
1154
1155 #[test]
1161 fn test_metadata_urls_parsed_url_stays_within_maven2_prefix() {
1162 let urls = metadata_urls("org.apache.commons:commons-lang3").unwrap();
1163 let parsed = url::Url::parse(&urls[0]).unwrap();
1164 let segments: Vec<&str> = parsed.path_segments().unwrap().collect();
1165 assert_eq!(
1166 segments,
1167 vec![
1168 "maven2",
1169 "org",
1170 "apache",
1171 "commons",
1172 "commons-lang3",
1173 "maven-metadata.xml"
1174 ]
1175 );
1176 }
1177
1178 #[test]
1179 fn test_parse_metadata_xml() {
1180 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1181<metadata>
1182 <groupId>org.apache.commons</groupId>
1183 <artifactId>commons-lang3</artifactId>
1184 <versioning>
1185 <latest>3.14.0</latest>
1186 <release>3.14.0</release>
1187 <versions>
1188 <version>3.12.0</version>
1189 <version>3.13.0</version>
1190 <version>3.14.0</version>
1191 </versions>
1192 </versioning>
1193</metadata>"#;
1194
1195 let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1196 assert_eq!(versions.len(), 3);
1197 assert_eq!(versions[0].version, "3.14.0");
1198 assert_eq!(versions[1].version, "3.13.0");
1199 assert_eq!(versions[2].version, "3.12.0");
1200 assert_eq!(release.as_deref(), Some("3.14.0"));
1201 }
1202
1203 #[test]
1204 fn test_parse_metadata_xml_empty() {
1205 let xml = r#"<?xml version="1.0"?><metadata><versioning><versions></versions></versioning></metadata>"#;
1206 let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1207 assert!(versions.is_empty());
1208 assert!(release.is_none());
1209 }
1210
1211 #[test]
1212 fn test_parse_metadata_xml_legacy_versions_release_wins() {
1213 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1216<metadata>
1217 <groupId>com.google.guava</groupId>
1218 <artifactId>guava</artifactId>
1219 <versioning>
1220 <latest>33.5.0-jre</latest>
1221 <release>33.5.0-jre</release>
1222 <versions>
1223 <version>r03</version>
1224 <version>r05</version>
1225 <version>r09</version>
1226 <version>14.0</version>
1227 <version>33.4.0-jre</version>
1228 <version>33.5.0-jre</version>
1229 </versions>
1230 </versioning>
1231</metadata>"#;
1232
1233 let (versions, release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1234 assert_eq!(versions.len(), 6);
1235 assert_eq!(release.as_deref(), Some("33.5.0-jre"));
1236
1237 let ordered: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1238 assert_eq!(
1239 ordered,
1240 vec!["33.5.0-jre", "33.4.0-jre", "14.0", "r09", "r05", "r03"],
1241 "numeric releases must sort above legacy bare qualifiers"
1242 );
1243 }
1244
1245 #[test]
1246 fn test_parse_metadata_xml_mixed_segment_count_sort_does_not_panic() {
1247 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
1255<metadata>
1256 <groupId>com.example</groupId>
1257 <artifactId>widget</artifactId>
1258 <versioning>
1259 <versions>
1260 <version>1.0</version>
1261 <version>1.0.0</version>
1262 <version>1.0-jre</version>
1263 </versions>
1264 </versioning>
1265</metadata>"#;
1266
1267 let (versions, _release) = parse_metadata_xml(xml.as_bytes()).unwrap();
1268 let ordered: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1269 assert_eq!(ordered, vec!["1.0.0", "1.0-jre", "1.0"]);
1270 }
1271
1272 #[test]
1276 fn test_parse_metadata_xml_malformed_returns_error_instead_of_silent_truncation() {
1277 let xml = b"<metadata><versioning><versions><version>1.0.0</version></versions></wrong></metadata>";
1278 let result = parse_metadata_xml(xml);
1279 assert!(result.is_err());
1280 }
1281
1282 #[test]
1283 fn test_parse_search_response() {
1284 let json = r#"{
1285 "response": {
1286 "numFound": 2,
1287 "docs": [
1288 {"g": "org.apache.commons", "a": "commons-lang3", "latestVersion": "3.14.0"},
1289 {"g": "org.apache.commons", "a": "commons-math3", "latestVersion": "3.6.1"}
1290 ]
1291 }
1292 }"#;
1293
1294 let results = parse_search_response(json.as_bytes(), 10).unwrap();
1295 assert_eq!(results.len(), 2);
1296 assert_eq!(results[0].name, "org.apache.commons:commons-lang3");
1297 assert_eq!(results[0].latest_version, "3.14.0");
1298 }
1299
1300 #[test]
1301 fn test_parse_search_response_nesting_at_max_depth_accepted() {
1302 let depth = deps_core::MAX_JSON_NESTING_DEPTH;
1303 let json = format!(
1304 r#"{{"response": {{"docs": []}}, "extra": {}1{}}}"#,
1305 "[".repeat(depth - 1),
1306 "]".repeat(depth - 1)
1307 );
1308 assert!(parse_search_response(json.as_bytes(), 10).is_ok());
1309 }
1310
1311 #[test]
1312 fn test_parse_search_response_nesting_over_max_depth_rejected() {
1313 let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
1314 let json = format!(
1315 r#"{{"response": {{"docs": []}}, "extra": {}1{}}}"#,
1316 "[".repeat(depth),
1317 "]".repeat(depth)
1318 );
1319 assert!(parse_search_response(json.as_bytes(), 10).is_err());
1320 }
1321
1322 #[tokio::test]
1331 #[ignore]
1332 async fn test_search_typed_real_guava() {
1333 let cache = Arc::new(HttpCache::new());
1334 let registry = MavenCentralRegistry::new(cache);
1335 let results = registry.search_typed("guava", 20).await.unwrap();
1336
1337 assert!(!results.is_empty());
1338 assert!(
1339 results
1340 .iter()
1341 .any(|r| r.group_id == "com.google.guava" && r.artifact_id == "guava")
1342 );
1343 }
1344
1345 #[test]
1351 fn test_search_attempt_budget_exceeds_completion_search_timeout() {
1352 let attempts_total: Duration = SEARCH_ATTEMPT_TIMEOUTS.iter().sum();
1353 let delays_total = SEARCH_RETRY_DELAY * (SEARCH_ATTEMPT_TIMEOUTS.len() as u32 - 1);
1354 assert!(
1355 attempts_total + delays_total > deps_core::completion::COMPLETION_SEARCH_TIMEOUT,
1356 "search_typed's worst-case retry budget must outlast the completion \
1357 handler's own timeout (#274/S1)"
1358 );
1359 }
1360
1361 #[tokio::test(start_paused = true)]
1368 async fn test_search_with_retry_total_failure_outlasts_completion_search_timeout() {
1369 let start = tokio::time::Instant::now();
1370 let result = search_with_retry(
1371 || async {
1372 tokio::time::sleep(Duration::from_secs(60)).await;
1373 Ok(Bytes::new())
1374 },
1375 || None,
1376 )
1377 .await;
1378
1379 assert!(result.is_err());
1380 assert!(
1381 start.elapsed() >= deps_core::completion::COMPLETION_SEARCH_TIMEOUT,
1382 "a total-failure call must not resolve before the completion handler's own \
1383 timeout does (#274/S1), elapsed={:?}",
1384 start.elapsed()
1385 );
1386 }
1387
1388 #[test]
1392 fn test_search_url_is_limit_independent() {
1393 assert_eq!(
1394 search_url("commons-lang3"),
1395 "https://search.maven.org/solrsearch/select?q=commons-lang3&rows=50&wt=json"
1396 );
1397 }
1398
1399 #[test]
1400 fn test_is_recently_failed_true_within_ttl() {
1401 let failures = DashMap::new();
1402 failures.insert("guava".to_string(), tokio::time::Instant::now());
1403 assert!(is_recently_failed(&failures, "guava"));
1404 }
1405
1406 #[test]
1407 fn test_is_recently_failed_false_for_unknown_query() {
1408 let failures = DashMap::new();
1409 assert!(!is_recently_failed(&failures, "guava"));
1410 }
1411
1412 #[tokio::test(start_paused = true)]
1415 async fn test_is_recently_failed_false_after_ttl_expires() {
1416 let failures = DashMap::new();
1417 failures.insert("guava".to_string(), tokio::time::Instant::now());
1418
1419 tokio::time::advance(RECENT_FAILURE_TTL + Duration::from_millis(1)).await;
1420
1421 assert!(!is_recently_failed(&failures, "guava"));
1422 }
1423
1424 #[tokio::test(start_paused = true)]
1427 async fn test_record_search_failure_prunes_expired_entries() {
1428 let failures = DashMap::new();
1429 record_search_failure(&failures, "old-query");
1430 assert_eq!(failures.len(), 1);
1431
1432 tokio::time::advance(RECENT_FAILURE_TTL + Duration::from_millis(1)).await;
1433 record_search_failure(&failures, "new-query");
1434
1435 assert_eq!(failures.len(), 1);
1436 assert!(failures.contains_key("new-query"));
1437 assert!(!failures.contains_key("old-query"));
1438 }
1439
1440 #[test]
1441 fn test_record_search_success_clears_failure() {
1442 let failures = DashMap::new();
1443 failures.insert("guava".to_string(), tokio::time::Instant::now());
1444
1445 record_search_success(&failures, "guava");
1446
1447 assert!(!failures.contains_key("guava"));
1448 }
1449
1450 #[test]
1451 fn test_record_search_success_on_unknown_query_is_a_no_op() {
1452 let failures = DashMap::new();
1453 record_search_success(&failures, "guava");
1454 assert!(failures.is_empty());
1455 }
1456
1457 #[tokio::test]
1461 async fn test_search_typed_short_circuits_on_recent_failure() {
1462 let cache = Arc::new(HttpCache::new());
1463 let registry = MavenCentralRegistry::new(cache);
1464 registry
1465 .recent_search_failures
1466 .insert("guava".to_string(), tokio::time::Instant::now());
1467
1468 let err = registry.search_typed("guava", 20).await.unwrap_err();
1469
1470 assert!(err.to_string().contains("skipping duplicate live attempt"));
1471 }
1472
1473 #[tokio::test]
1478 async fn test_search_typed_offline_does_not_poison_recent_failures() {
1479 let cache = Arc::new(HttpCache::new());
1480 cache.set_offline(true);
1481 let registry = MavenCentralRegistry::new(cache);
1482
1483 let err = registry.search_typed("guava", 20).await.unwrap_err();
1484
1485 assert!(err.is_offline(), "expected Offline, got {err:?}");
1486 assert!(
1487 registry.recent_search_failures.is_empty(),
1488 "an offline block must not be recorded as a search failure"
1489 );
1490 }
1491
1492 #[test]
1497 fn test_parse_search_response_trims_to_limit() {
1498 let json = r#"{
1499 "response": {
1500 "numFound": 2,
1501 "docs": [
1502 {"g": "org.apache.commons", "a": "commons-lang3", "latestVersion": "3.14.0"},
1503 {"g": "org.apache.commons", "a": "commons-math3", "latestVersion": "3.6.1"}
1504 ]
1505 }
1506 }"#;
1507
1508 let results = parse_search_response(json.as_bytes(), 1).unwrap();
1509 assert_eq!(results.len(), 1);
1510 assert_eq!(results[0].name, "org.apache.commons:commons-lang3");
1511 }
1512
1513 #[tokio::test(start_paused = true)]
1514 async fn test_search_with_retry_first_attempt_fails_second_succeeds() {
1515 use std::sync::atomic::{AtomicUsize, Ordering};
1516
1517 let calls = AtomicUsize::new(0);
1518 let result = search_with_retry(
1519 || {
1520 let n = calls.fetch_add(1, Ordering::SeqCst);
1521 async move {
1522 if n == 0 {
1523 Err(DepsError::CacheError("boom".into()))
1524 } else {
1525 Ok(Bytes::from_static(b"ok"))
1526 }
1527 }
1528 },
1529 || None,
1530 )
1531 .await;
1532
1533 assert_eq!(result.unwrap(), Bytes::from_static(b"ok"));
1534 assert_eq!(calls.load(Ordering::SeqCst), 2);
1535 }
1536
1537 #[tokio::test(start_paused = true)]
1538 async fn test_search_with_retry_all_attempts_timeout_no_cache_returns_error() {
1539 use std::sync::atomic::{AtomicUsize, Ordering};
1540
1541 let calls = AtomicUsize::new(0);
1542 let result = search_with_retry(
1543 || {
1544 calls.fetch_add(1, Ordering::SeqCst);
1545 async {
1546 tokio::time::sleep(Duration::from_secs(60)).await;
1547 Ok(Bytes::new())
1548 }
1549 },
1550 || None,
1551 )
1552 .await;
1553
1554 assert!(result.is_err());
1555 assert_eq!(calls.load(Ordering::SeqCst), 2);
1556 }
1557
1558 #[tokio::test(start_paused = true)]
1562 async fn test_search_with_retry_serves_stale_cache_after_first_timeout() {
1563 use std::sync::atomic::{AtomicUsize, Ordering};
1564
1565 let calls = AtomicUsize::new(0);
1566 let result = search_with_retry(
1567 || {
1568 calls.fetch_add(1, Ordering::SeqCst);
1569 async {
1570 tokio::time::sleep(Duration::from_secs(60)).await;
1571 Ok(Bytes::new())
1572 }
1573 },
1574 || Some(Bytes::from_static(b"stale")),
1575 )
1576 .await;
1577
1578 assert_eq!(result.unwrap(), Bytes::from_static(b"stale"));
1579 assert_eq!(
1580 calls.load(Ordering::SeqCst),
1581 1,
1582 "stale cache should short-circuit after the first failed attempt, not wait \
1583 for both"
1584 );
1585 }
1586
1587 #[tokio::test]
1591 async fn test_search_with_retry_4xx_status_is_not_retried() {
1592 use std::sync::atomic::{AtomicUsize, Ordering};
1593
1594 let calls = AtomicUsize::new(0);
1595 let result = search_with_retry(
1596 || {
1597 calls.fetch_add(1, Ordering::SeqCst);
1598 async {
1599 Err(DepsError::HttpStatus {
1600 url: MAVEN_SEARCH_BASE.to_string(),
1601 status: 400,
1602 })
1603 }
1604 },
1605 || None,
1606 )
1607 .await;
1608
1609 assert!(result.is_err());
1610 assert_eq!(calls.load(Ordering::SeqCst), 1);
1611 }
1612
1613 #[tokio::test(start_paused = true)]
1614 async fn test_search_with_retry_5xx_status_is_retried() {
1615 use std::sync::atomic::{AtomicUsize, Ordering};
1616
1617 let calls = AtomicUsize::new(0);
1618 let result = search_with_retry(
1619 || {
1620 let n = calls.fetch_add(1, Ordering::SeqCst);
1621 async move {
1622 if n == 0 {
1623 Err(DepsError::HttpStatus {
1624 url: MAVEN_SEARCH_BASE.to_string(),
1625 status: 503,
1626 })
1627 } else {
1628 Ok(Bytes::from_static(b"ok"))
1629 }
1630 }
1631 },
1632 || None,
1633 )
1634 .await;
1635
1636 assert_eq!(result.unwrap(), Bytes::from_static(b"ok"));
1637 assert_eq!(calls.load(Ordering::SeqCst), 2);
1638 }
1639
1640 #[test]
1641 fn test_registry_creation() {
1642 let cache = Arc::new(HttpCache::new());
1643 let _registry = MavenCentralRegistry::new(cache);
1644 }
1645
1646 #[test]
1647 fn test_registry_as_any() {
1648 use deps_core::Registry;
1649 let cache = Arc::new(HttpCache::new());
1650 let registry = MavenCentralRegistry::new(cache);
1651 assert!(registry.as_any().is::<MavenCentralRegistry>());
1652 }
1653
1654 #[test]
1655 fn test_select_latest_matching_not_default_none() {
1656 use deps_core::{Registry, VersionReq};
1657
1658 let cache = Arc::new(HttpCache::new());
1663 let registry = MavenCentralRegistry::new(cache);
1664 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1665 Box::new(MavenVersion {
1666 version: "1.0.0".into(),
1667 published_at: None,
1668 }),
1669 Box::new(MavenVersion {
1670 version: "2.0.0-SNAPSHOT".into(),
1671 published_at: None,
1672 }),
1673 ];
1674 let req = VersionReq::new("*");
1675 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1676 }
1677
1678 #[test]
1679 fn test_move_release_to_front_reorders() {
1680 let mut versions = vec![
1681 MavenVersion {
1682 version: "3.4.0".into(),
1683 published_at: None,
1684 },
1685 MavenVersion {
1686 version: "4.0.0-M1".into(),
1687 published_at: None,
1688 },
1689 ];
1690 move_release_to_front(&mut versions, Some("4.0.0-M1"));
1693 assert_eq!(versions[0].version, "4.0.0-M1");
1694 assert_eq!(versions[1].version, "3.4.0");
1695 }
1696
1697 #[test]
1698 fn test_move_release_to_front_already_first_is_a_no_op() {
1699 let mut versions = vec![
1700 MavenVersion {
1701 version: "1.0.0".into(),
1702 published_at: None,
1703 },
1704 MavenVersion {
1705 version: "0.9.0".into(),
1706 published_at: None,
1707 },
1708 ];
1709 move_release_to_front(&mut versions, Some("1.0.0"));
1710 assert_eq!(versions[0].version, "1.0.0");
1711 assert_eq!(versions[1].version, "0.9.0");
1712 }
1713
1714 #[test]
1715 fn test_move_release_to_front_release_absent_from_list_is_a_no_op() {
1716 let mut versions = vec![
1717 MavenVersion {
1718 version: "1.0.0".into(),
1719 published_at: None,
1720 },
1721 MavenVersion {
1722 version: "0.9.0".into(),
1723 published_at: None,
1724 },
1725 ];
1726 move_release_to_front(&mut versions, Some("2.0.0"));
1727 assert_eq!(versions[0].version, "1.0.0");
1728 assert_eq!(versions[1].version, "0.9.0");
1729 }
1730
1731 #[test]
1732 fn test_move_release_to_front_no_release_is_a_no_op() {
1733 let mut versions = vec![MavenVersion {
1734 version: "1.0.0".into(),
1735 published_at: None,
1736 }];
1737 move_release_to_front(&mut versions, None);
1738 assert_eq!(versions[0].version, "1.0.0");
1739 }
1740
1741 #[test]
1744 fn test_move_release_to_front_no_release_falls_back_to_first_non_prerelease() {
1745 let mut versions = vec![
1746 MavenVersion {
1747 version: "1.5.0-alpha01".into(),
1748 published_at: None,
1749 },
1750 MavenVersion {
1751 version: "1.4.0".into(),
1752 published_at: None,
1753 },
1754 ];
1755 move_release_to_front(&mut versions, None);
1756 assert_eq!(versions[0].version, "1.4.0");
1757 assert_eq!(versions[1].version, "1.5.0-alpha01");
1758 }
1759
1760 #[test]
1761 fn test_move_release_to_front_no_release_and_all_prerelease_leaves_sorted_top() {
1762 let mut versions = vec![
1763 MavenVersion {
1764 version: "2.0.0-alpha".into(),
1765 published_at: None,
1766 },
1767 MavenVersion {
1768 version: "1.0.0-beta".into(),
1769 published_at: None,
1770 },
1771 ];
1772 move_release_to_front(&mut versions, None);
1773 assert_eq!(versions[0].version, "2.0.0-alpha");
1774 }
1775
1776 #[test]
1777 fn test_pick_wildcard_latest_prefers_release() {
1778 let versions = vec![
1779 MavenVersion {
1780 version: "1.4.0".into(),
1781 published_at: None,
1782 },
1783 MavenVersion {
1784 version: "1.5.0-M1".into(),
1785 published_at: None,
1786 },
1787 ];
1788 let picked = pick_wildcard_latest(&versions, Some("1.5.0-M1")).unwrap();
1789 assert_eq!(picked.version, "1.5.0-M1");
1790 }
1791
1792 #[test]
1793 fn test_pick_wildcard_latest_release_absent_from_list_synthesizes() {
1794 let versions = vec![MavenVersion {
1799 version: "1.0.0".into(),
1800 published_at: None,
1801 }];
1802 let picked = pick_wildcard_latest(&versions, Some("9.9.9")).unwrap();
1803 assert_eq!(picked.version, "9.9.9");
1804 }
1805
1806 #[test]
1813 fn test_pick_wildcard_latest_release_prerelease_absent_from_list_returns_none() {
1814 let versions: Vec<MavenVersion> = vec![];
1815 let picked = pick_wildcard_latest(&versions, Some("8.0.0.Beta1"));
1816 assert!(picked.is_none());
1817 }
1818
1819 #[test]
1823 fn test_pick_wildcard_latest_release_stable_absent_from_empty_list_still_synthesizes() {
1824 let versions: Vec<MavenVersion> = vec![];
1825 let picked = pick_wildcard_latest(&versions, Some("1.2.3")).unwrap();
1826 assert_eq!(picked.version, "1.2.3");
1827 }
1828
1829 #[test]
1830 fn test_pick_wildcard_latest_no_release_prefers_non_prerelease() {
1831 let versions = vec![
1832 MavenVersion {
1833 version: "2.0.0-alpha".into(),
1834 published_at: None,
1835 },
1836 MavenVersion {
1837 version: "1.0.0".into(),
1838 published_at: None,
1839 },
1840 ];
1841 let picked = pick_wildcard_latest(&versions, None).unwrap();
1842 assert_eq!(picked.version, "1.0.0");
1843 }
1844
1845 fn assert_select_latest_matching_agrees_with_pick_wildcard_latest(
1857 versions: Vec<MavenVersion>,
1858 release: Option<&str>,
1859 ) {
1860 use deps_core::{Registry, VersionReq};
1861
1862 let wildcard_pick = pick_wildcard_latest(&versions, release);
1863
1864 let mut reordered = versions;
1865 move_release_to_front(&mut reordered, release);
1866 let boxed: Vec<Box<dyn deps_core::Version>> = reordered
1867 .into_iter()
1868 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1869 .collect();
1870
1871 let cache = Arc::new(HttpCache::new());
1872 let registry = MavenCentralRegistry::new(cache);
1873 let idx = registry
1874 .select_latest_matching(&boxed, &VersionReq::new("*"))
1875 .expect("non-empty list must select an index");
1876
1877 assert_eq!(
1878 boxed[idx].version_string().as_str(),
1879 wildcard_pick
1880 .expect("fixture always has a pick")
1881 .version
1882 .as_str()
1883 );
1884 }
1885
1886 #[test]
1887 fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_present_stable() {
1888 assert_select_latest_matching_agrees_with_pick_wildcard_latest(
1889 vec![
1890 MavenVersion {
1891 version: "1.5.0-M1".into(),
1892 published_at: None,
1893 },
1894 MavenVersion {
1895 version: "1.4.0".into(),
1896 published_at: None,
1897 },
1898 ],
1899 Some("1.4.0"),
1900 );
1901 }
1902
1903 #[test]
1908 fn test_select_latest_matching_skips_prerelease_release_tag() {
1909 use deps_core::{Registry, VersionReq};
1910
1911 let mut versions = vec![
1912 MavenVersion {
1913 version: "1.4.0".into(),
1914 published_at: None,
1915 },
1916 MavenVersion {
1917 version: "1.5.0-M1".into(),
1918 published_at: None,
1919 },
1920 ];
1921 move_release_to_front(&mut versions, Some("1.5.0-M1"));
1924 assert_eq!(versions[0].version, "1.5.0-M1");
1925
1926 let boxed: Vec<Box<dyn deps_core::Version>> = versions
1927 .into_iter()
1928 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1929 .collect();
1930
1931 let cache = Arc::new(HttpCache::new());
1932 let registry = MavenCentralRegistry::new(cache);
1933 let idx = registry
1934 .select_latest_matching(&boxed, &VersionReq::new("*"))
1935 .expect("non-empty list must select an index");
1936
1937 assert_eq!(boxed[idx].version_string(), "1.4.0");
1938 }
1939
1940 #[test]
1943 fn test_select_latest_matching_wildcard_all_prerelease_falls_back_to_newest() {
1944 use deps_core::{Registry, VersionReq};
1945
1946 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1947 Box::new(MavenVersion {
1948 version: "2.0.0-alpha".into(),
1949 published_at: None,
1950 }),
1951 Box::new(MavenVersion {
1952 version: "1.0.0-beta".into(),
1953 published_at: None,
1954 }),
1955 ];
1956
1957 let cache = Arc::new(HttpCache::new());
1958 let registry = MavenCentralRegistry::new(cache);
1959 let idx = registry
1960 .select_latest_matching(&versions, &VersionReq::new("*"))
1961 .expect("non-empty list must select an index");
1962
1963 assert_eq!(versions[idx].version_string(), "2.0.0-alpha");
1964 }
1965
1966 #[test]
1973 fn test_select_latest_matching_wildcard_all_prerelease_ignores_stale_release_hoist() {
1974 use deps_core::{Registry, VersionReq};
1975
1976 let mut versions = vec![
1977 MavenVersion {
1978 version: "2.0.0-beta".into(),
1979 published_at: None,
1980 },
1981 MavenVersion {
1982 version: "1.0.0-alpha".into(),
1983 published_at: None,
1984 },
1985 ];
1986 move_release_to_front(&mut versions, Some("1.0.0-alpha"));
1990 assert_eq!(versions[0].version, "1.0.0-alpha");
1991
1992 let boxed: Vec<Box<dyn deps_core::Version>> = versions
1993 .into_iter()
1994 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1995 .collect();
1996
1997 let cache = Arc::new(HttpCache::new());
1998 let registry = MavenCentralRegistry::new(cache);
1999 let idx = registry
2000 .select_latest_matching(&boxed, &VersionReq::new("*"))
2001 .expect("non-empty list must select an index");
2002
2003 assert_eq!(
2004 boxed[idx].version_string(),
2005 "2.0.0-beta",
2006 "must scan for the actual newest prerelease, not trust the release-tag hoist"
2007 );
2008 }
2009
2010 #[test]
2011 fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_absent_non_prerelease_exists()
2012 {
2013 assert_select_latest_matching_agrees_with_pick_wildcard_latest(
2014 vec![
2015 MavenVersion {
2016 version: "1.5.0-alpha01".into(),
2017 published_at: None,
2018 },
2019 MavenVersion {
2020 version: "1.4.0".into(),
2021 published_at: None,
2022 },
2023 ],
2024 None,
2025 );
2026 }
2027
2028 #[test]
2029 fn test_select_latest_matching_agrees_with_pick_wildcard_latest_release_absent_only_prereleases()
2030 {
2031 assert_select_latest_matching_agrees_with_pick_wildcard_latest(
2032 vec![
2033 MavenVersion {
2034 version: "2.0.0-alpha".into(),
2035 published_at: None,
2036 },
2037 MavenVersion {
2038 version: "1.0.0-beta".into(),
2039 published_at: None,
2040 },
2041 ],
2042 None,
2043 );
2044 }
2045
2046 const REPO1_FIXTURE: &str = r#"<pre id="contents">
2054<a href="../">../</a>
2055<a href="3.12.0/" title="3.12.0/">3.12.0/</a> 2021-02-26 20:40 -
2056<a href="3.13.0/" title="3.13.0/">3.13.0/</a> 2023-07-23 19:44 -
2057<a href="3.14.0/" title="3.14.0/">3.14.0/</a> 2023-11-18 15:03 -
2058<a href="maven-metadata.xml" title="maven-metadata.xml">maven-metadata.xml</a> 2025-11-16 12:55 817
2059<a href="maven-metadata.xml.md5" title="maven-metadata.xml.md5">maven-metadata.xml.md5</a> 2025-11-16 12:55 32
2060</pre>"#;
2061
2062 #[test]
2063 fn test_parse_publish_times_repo1_fixture() {
2064 let map = parse_publish_times(REPO1_FIXTURE.as_bytes());
2065
2066 assert_eq!(
2067 map.get("3.14.0").copied(),
2068 PublishTime::parse_rfc3339("2023-11-18T15:03:00Z")
2069 );
2070 assert_eq!(
2071 map.get("3.12.0").copied(),
2072 PublishTime::parse_rfc3339("2021-02-26T20:40:00Z")
2073 );
2074 assert!(!map.contains_key(".."));
2076 assert!(!map.contains_key(""));
2077 assert!(!map.contains_key("maven-metadata.xml"));
2080 assert!(!map.contains_key("maven-metadata.xml.md5"));
2081 }
2082
2083 const GRADLE_PLUGIN_PORTAL_FIXTURE: &str = r#"<pre><a href="1.4.2.RELEASE/">1.4.2.RELEASE/</a></pre>
2088<pre><a href="1.5.0.RELEASE/">1.5.0.RELEASE/</a></pre>"#;
2089
2090 #[test]
2091 fn test_parse_publish_times_gradle_plugin_portal_dateless_is_empty() {
2092 let map = parse_publish_times(GRADLE_PLUGIN_PORTAL_FIXTURE.as_bytes());
2093 assert!(map.is_empty());
2094 }
2095
2096 #[test]
2097 fn test_parse_publish_times_malformed_date_entry_absent_rest_parsed() {
2098 let html = r#"<pre id="contents">
2099<a href="1.0.0/" title="1.0.0/">1.0.0/</a> 2011-13-45 99:99 -
2100<a href="1.0.1/" title="1.0.1/">1.0.1/</a> 2011-09-28 16:04 -
2101</pre>"#;
2102 let map = parse_publish_times(html.as_bytes());
2103 assert!(!map.contains_key("1.0.0"));
2104 assert_eq!(
2105 map.get("1.0.1").copied(),
2106 PublishTime::parse_rfc3339("2011-09-28T16:04:00Z")
2107 );
2108 }
2109
2110 #[test]
2111 fn test_parse_publish_times_no_pre_block_is_empty() {
2112 let html = r"<html><body>not a listing at all</body></html>";
2113 let map = parse_publish_times(html.as_bytes());
2114 assert!(map.is_empty());
2115 }
2116
2117 #[test]
2118 fn test_parse_publish_times_empty_body_is_empty() {
2119 let map = parse_publish_times(b"");
2120 assert!(map.is_empty());
2121 }
2122
2123 #[test]
2126 fn test_should_fetch_listing_maven_central_base() {
2127 assert!(should_fetch_listing(
2128 "https://repo1.maven.org/maven2/org/apache/commons/commons-lang3/"
2129 ));
2130 }
2131
2132 #[test]
2133 fn test_should_fetch_listing_google_maven_base_is_false() {
2134 assert!(!should_fetch_listing(
2135 "https://dl.google.com/dl/android/maven2/androidx/core/core/"
2136 ));
2137 }
2138
2139 #[test]
2140 fn test_should_fetch_listing_gradle_plugin_portal_base_is_false() {
2141 assert!(!should_fetch_listing(
2142 "https://plugins.gradle.org/m2/org/example/plugin/"
2143 ));
2144 }
2145
2146 #[test]
2149 fn test_attach_publish_times_matches_by_version_string() {
2150 let mut versions = vec![
2151 MavenVersion {
2152 version: "1.0.0".into(),
2153 published_at: None,
2154 },
2155 MavenVersion {
2156 version: "2.0.0".into(),
2157 published_at: None,
2158 },
2159 ];
2160 let mut times = HashMap::new();
2161 times.insert(
2162 "1.0.0".to_string(),
2163 PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2164 );
2165 attach_publish_times(&mut versions, ×);
2166
2167 assert_eq!(
2168 versions[0].published_at,
2169 PublishTime::parse_rfc3339("2020-01-01T00:00:00Z")
2170 );
2171 assert_eq!(versions[1].published_at, None);
2172 assert_eq!(versions[0].version, "1.0.0");
2174 assert_eq!(versions[1].version, "2.0.0");
2175 }
2176
2177 #[test]
2178 fn test_attach_publish_times_extra_map_entry_does_not_panic_or_cross_assign() {
2179 let mut versions = vec![MavenVersion {
2180 version: "1.0.0".into(),
2181 published_at: None,
2182 }];
2183 let mut times = HashMap::new();
2184 times.insert(
2187 "9.9.9-not-in-metadata".to_string(),
2188 PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2189 );
2190 attach_publish_times(&mut versions, ×);
2191 assert_eq!(versions[0].published_at, None);
2192 }
2193
2194 #[test]
2195 fn test_attach_publish_times_empty_map_leaves_all_none() {
2196 let mut versions = vec![
2197 MavenVersion {
2198 version: "1.0.0".into(),
2199 published_at: None,
2200 },
2201 MavenVersion {
2202 version: "2.0.0".into(),
2203 published_at: None,
2204 },
2205 ];
2206 attach_publish_times(&mut versions, &HashMap::new());
2207 assert!(versions.iter().all(|v| v.published_at.is_none()));
2208 }
2209
2210 #[tokio::test]
2213 async fn test_fetch_publish_times_success_parses_and_attaches() {
2214 let mut server = mockito::Server::new_async().await;
2215 let mock = server
2216 .mock("GET", "/org/example/widget/")
2217 .with_status(200)
2218 .with_body(REPO1_FIXTURE)
2219 .expect(1)
2220 .create_async()
2221 .await;
2222
2223 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2224 let url = format!("{}/org/example/widget/", server.url());
2225 let times = registry.fetch_publish_times(&url).await;
2226
2227 assert_eq!(
2228 times.get("3.14.0").copied(),
2229 PublishTime::parse_rfc3339("2023-11-18T15:03:00Z")
2230 );
2231 mock.assert_async().await;
2232 }
2233
2234 #[tokio::test]
2235 async fn test_fetch_publish_times_404_degrades_to_empty_map() {
2236 let mut server = mockito::Server::new_async().await;
2237 server
2238 .mock("GET", "/org/example/widget/")
2239 .with_status(404)
2240 .create_async()
2241 .await;
2242
2243 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2244 let url = format!("{}/org/example/widget/", server.url());
2245 let times = registry.fetch_publish_times(&url).await;
2246
2247 assert!(times.is_empty());
2248 }
2249
2250 #[tokio::test]
2251 async fn test_fetch_publish_times_500_degrades_to_empty_map() {
2252 let mut server = mockito::Server::new_async().await;
2253 server
2254 .mock("GET", "/org/example/widget/")
2255 .with_status(500)
2256 .create_async()
2257 .await;
2258
2259 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2260 let url = format!("{}/org/example/widget/", server.url());
2261 let times = registry.fetch_publish_times(&url).await;
2262
2263 assert!(times.is_empty());
2264 }
2265
2266 #[tokio::test]
2269 async fn test_get_versions_typed_with_invalid_name_short_circuits_before_any_request() {
2270 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2274 assert!(
2275 registry
2276 .get_versions_typed("bad-name")
2277 .await
2278 .unwrap()
2279 .is_empty()
2280 );
2281 assert!(
2282 registry
2283 .get_versions_typed_with("bad-name", true)
2284 .await
2285 .unwrap()
2286 .is_empty()
2287 );
2288 }
2289
2290 #[tokio::test]
2295 async fn test_get_versions_typed_dot_segment_artifact_id_returns_err() {
2296 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2297 let err = registry
2298 .get_versions_typed("com.example:..")
2299 .await
2300 .expect_err("dot-segment artifactId must be rejected");
2301 assert_matches!(err, DepsError::PackageNotFound { .. });
2302 assert!(err.is_not_found());
2303 }
2304
2305 #[tokio::test]
2308 #[ignore]
2309 async fn test_live_maven_central_attaches_publish_times() {
2310 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2311 let versions = registry
2312 .get_versions_typed_with("org.apache.commons:commons-lang3", true)
2313 .await
2314 .unwrap();
2315
2316 assert!(!versions.is_empty());
2317 assert!(versions.iter().take(5).any(|v| v.published_at.is_some()));
2320 }
2321
2322 #[tokio::test]
2323 #[ignore]
2324 async fn test_live_google_maven_never_attaches_publish_times() {
2325 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2326 let versions = registry
2327 .get_versions_typed_with("androidx.core:core", true)
2328 .await
2329 .unwrap();
2330
2331 assert!(!versions.is_empty());
2332 assert!(versions.iter().all(|v| v.published_at.is_none()));
2335 }
2336
2337 #[tokio::test]
2338 #[ignore]
2339 async fn test_live_gradle_plugin_portal_never_attaches_publish_times() {
2340 let registry = MavenCentralRegistry::new(Arc::new(HttpCache::new()));
2341 let versions = registry
2345 .get_versions_typed_with(
2346 "com.gradle.develocity:com.gradle.develocity.gradle.plugin",
2347 true,
2348 )
2349 .await
2350 .unwrap();
2351
2352 assert!(!versions.is_empty());
2353 assert!(versions.iter().all(|v| v.published_at.is_none()));
2354 }
2355}