1use crate::config::{NuGetAuth, NuGetSourceChain, ResolvedHop};
10use crate::types::{NuGetVersion, PackageInfo};
11use crate::version::compare_versions;
12use dashmap::DashMap;
13use deps_core::net_policy::{PolicyGate, RegistryAccessPolicy};
14use deps_core::parser::DependencySource;
15use deps_core::{
16 DepsError, FreshnessSettings, HOVER_RECENT_VERSIONS, HttpCache, PublishTime, Result,
17};
18use serde::Deserialize;
19use std::any::Any;
20use std::collections::{HashMap, HashSet};
21use std::hash::{Hash, Hasher};
22use std::sync::{Arc, OnceLock};
23use tokio::sync::OnceCell;
24
25fn digest_salt() -> u64 {
30 static SALT: OnceLock<u64> = OnceLock::new();
31 *SALT.get_or_init(|| {
32 use std::collections::hash_map::RandomState;
33 use std::hash::BuildHasher;
34 let mut hasher = RandomState::new().build_hasher();
35 std::process::id().hash(&mut hasher);
36 std::time::SystemTime::now().hash(&mut hasher);
37 hasher.finish()
38 })
39}
40
41fn own_auth_digest(declared_origin: &str, auth: Option<&NuGetAuth>) -> u64 {
49 let Some(auth) = auth else { return 0 };
50 let mut hasher = std::collections::hash_map::DefaultHasher::new();
51 digest_salt().hash(&mut hasher);
52 declared_origin.hash(&mut hasher);
53 auth.header_value().hash(&mut hasher);
54 hasher.finish()
55}
56
57fn chain_auth_digest(hops: &[ResolvedHop]) -> u64 {
62 let mut hasher = std::collections::hash_map::DefaultHasher::new();
63 digest_salt().hash(&mut hasher);
64 for hop in hops {
65 hop.url.as_str().hash(&mut hasher);
66 match &hop.auth {
67 Some(auth) => {
68 1u8.hash(&mut hasher);
69 auth.header_value().hash(&mut hasher);
70 }
71 None => 0u8.hash(&mut hasher),
72 }
73 }
74 hasher.finish()
75}
76
77fn origin_of(s: &str) -> Option<String> {
85 url::Url::parse(s)
86 .ok()
87 .map(|u| format!("{}/", u.origin().ascii_serialization()))
88}
89
90pub(crate) const NUGET_ORG_INDEX_URL: &str = "https://api.nuget.org/v3/index.json";
95
96const MAX_EXTERNAL_PAGE_FETCHES: usize = 2;
99
100const MAX_ALTERNATE_REGISTRIES: usize = 256;
106
107pub const REGISTRY: &str = "NuGet";
109
110pub(crate) fn is_public_registry_url(url: &str) -> bool {
116 url == NUGET_ORG_INDEX_URL
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122enum NuGetRegistryTier {
123 Public,
126 WorkspaceDeclared,
130}
131
132#[derive(Debug, Deserialize)]
133struct ServiceIndexResponse {
134 #[serde(default, deserialize_with = "deserialize_resources")]
141 resources: Vec<ServiceResource>,
142}
143
144#[derive(Debug, Deserialize)]
145struct ServiceResource {
146 #[serde(
151 rename = "@id",
152 default,
153 deserialize_with = "deserialize_optional_string"
154 )]
155 id: Option<String>,
156 #[serde(rename = "@type", default, deserialize_with = "deserialize_type_list")]
161 r#type: Vec<String>,
162}
163
164fn deserialize_resources<'de, D>(
168 deserializer: D,
169) -> std::result::Result<Vec<ServiceResource>, D::Error>
170where
171 D: serde::Deserializer<'de>,
172{
173 let value = serde_json::Value::deserialize(deserializer)?;
174 Ok(match value {
175 serde_json::Value::Array(items) => items
176 .into_iter()
177 .filter_map(|v| serde_json::from_value(v).ok())
178 .collect(),
179 _ => Vec::new(),
180 })
181}
182
183fn deserialize_optional_string<'de, D>(
187 deserializer: D,
188) -> std::result::Result<Option<String>, D::Error>
189where
190 D: serde::Deserializer<'de>,
191{
192 let value = serde_json::Value::deserialize(deserializer)?;
193 Ok(match value {
194 serde_json::Value::String(s) => Some(s),
195 _ => None,
196 })
197}
198
199fn deserialize_type_list<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
204where
205 D: serde::Deserializer<'de>,
206{
207 let value = serde_json::Value::deserialize(deserializer)?;
208 Ok(match value {
209 serde_json::Value::String(s) => vec![s],
210 serde_json::Value::Array(items) => items
211 .into_iter()
212 .filter_map(|v| match v {
213 serde_json::Value::String(s) => Some(s),
214 _ => None,
215 })
216 .collect(),
217 _ => Vec::new(),
218 })
219}
220
221#[derive(Debug, Clone)]
223struct ServiceIndex {
224 package_base_address: String,
226 search_query_service: Option<String>,
230 registrations_base_url: Option<String>,
236}
237
238fn pick_resource(resources: &[ServiceResource], type_preference: &[&str]) -> Option<String> {
239 for want in type_preference {
240 if let Some(r) = resources
241 .iter()
242 .find(|r| r.id.is_some() && r.r#type.iter().any(|t| t == want))
243 {
244 return r
245 .id
246 .as_deref()
247 .map(|id| id.trim_end_matches('/').to_string());
248 }
249 }
250 None
251}
252
253impl ServiceIndex {
254 fn resolve(
264 response: &ServiceIndexResponse,
265 tier: NuGetRegistryTier,
266 policy: &RegistryAccessPolicy,
267 ) -> Result<Self> {
268 let package_base_address =
269 pick_resource(&response.resources, &["PackageBaseAddress/3.0.0"]).ok_or_else(|| {
270 deps_core::DepsError::ParseError {
271 file_type: "NuGet service index".into(),
272 source: Box::new(std::io::Error::other(
273 "missing PackageBaseAddress/3.0.0 resource",
274 )),
275 }
276 })?;
277 let search_query_service = pick_resource(
278 &response.resources,
279 &["SearchQueryService/3.5.0", "SearchQueryService"],
280 );
281 let registrations_base_url = pick_resource(
282 &response.resources,
283 &[
284 "RegistrationsBaseUrl/3.6.0",
285 "RegistrationsBaseUrl/3.4.0",
286 "RegistrationsBaseUrl",
287 ],
288 );
289
290 if tier != NuGetRegistryTier::WorkspaceDeclared {
291 return Ok(Self {
292 package_base_address,
293 search_query_service,
294 registrations_base_url,
295 });
296 }
297
298 let gate = PolicyGate::Enforce(policy);
299 deps_core::net_policy::validate_index_url(
300 &package_base_address,
301 &package_base_address,
302 "nuget",
303 gate,
304 )
305 .map_err(|e| deps_core::DepsError::ParseError {
306 file_type: "NuGet service index".into(),
307 source: Box::new(std::io::Error::other(format!(
308 "PackageBaseAddress blocked by workspace registry policy: {e}"
309 ))),
310 })?;
311 let search_query_service = search_query_service
312 .filter(|u| deps_core::net_policy::validate_index_url(u, u, "nuget", gate).is_ok());
313 let registrations_base_url = registrations_base_url
314 .filter(|u| deps_core::net_policy::validate_index_url(u, u, "nuget", gate).is_ok());
315
316 Ok(Self {
317 package_base_address,
318 search_query_service,
319 registrations_base_url,
320 })
321 }
322}
323
324#[derive(Debug, Deserialize)]
327struct RegistrationIndex {
328 #[serde(default)]
329 items: Vec<RegistrationPage>,
330}
331
332#[derive(Debug, Deserialize)]
333struct RegistrationPage {
334 #[serde(rename = "@id")]
335 id: String,
336 #[serde(default)]
339 items: Option<Vec<CatalogEntryWrapper>>,
340}
341
342#[derive(Debug, Deserialize)]
345struct RegistrationPageBody {
346 #[serde(default)]
347 items: Vec<CatalogEntryWrapper>,
348}
349
350#[derive(Debug, Deserialize)]
351struct CatalogEntryWrapper {
352 #[serde(rename = "catalogEntry")]
353 catalog_entry: CatalogEntry,
354}
355
356#[derive(Debug, Deserialize)]
357struct CatalogEntry {
358 version: String,
359 #[serde(default)]
363 published: Option<String>,
364 #[serde(default)]
368 listed: Option<bool>,
369}
370
371#[derive(Debug, Deserialize)]
372struct FlatContainerIndex {
373 #[serde(default)]
374 versions: Vec<String>,
375}
376
377#[derive(Debug, Deserialize)]
378struct SearchResponse {
379 #[serde(default)]
380 data: Vec<SearchResultDoc>,
381}
382
383#[derive(Debug, Deserialize)]
384struct SearchResultDoc {
385 id: String,
386 #[serde(default)]
387 version: Option<String>,
388 #[serde(default)]
389 description: Option<String>,
390 #[serde(default, rename = "projectUrl")]
391 project_url: Option<String>,
392}
393
394pub fn package_url(name: &str) -> String {
401 format!(
402 "https://www.nuget.org/packages/{}",
403 urlencoding::encode(name)
404 )
405}
406
407fn reject_dot_segment(name: &str) -> Result<()> {
415 if deps_core::is_dot_segment(name) {
416 deps_core::lsp_helpers::warn_rejected_value(
417 "is_dot_segment",
418 "NuGet flat-container/registration request URL",
419 name,
420 );
421 return Err(deps_core::DepsError::PackageNotFound {
422 package: name.to_string(),
423 registry: REGISTRY,
424 });
425 }
426 Ok(())
427}
428
429#[derive(Clone)]
430pub struct NuGetRegistry {
431 cache: Arc<HttpCache>,
432 service_index_url: String,
433 service_index: Arc<OnceCell<ServiceIndex>>,
434 tier: NuGetRegistryTier,
435 policy: Arc<RegistryAccessPolicy>,
440 alternates: Arc<DashMap<String, Arc<Self>>>,
447 fallback_chain: Vec<Arc<Self>>,
452 auth: Option<NuGetAuth>,
456 declared_origin: String,
460 own_auth_id: u64,
463 chain_auth_digest: u64,
469}
470
471impl NuGetRegistry {
472 pub fn new(cache: Arc<HttpCache>) -> Self {
473 Self::with_service_index_url(cache, NUGET_ORG_INDEX_URL.to_string())
474 }
475
476 pub(crate) fn with_service_index_url(cache: Arc<HttpCache>, service_index_url: String) -> Self {
477 let declared_origin = origin_of(&service_index_url).unwrap_or_default();
478 Self {
479 cache,
480 service_index_url,
481 service_index: Arc::new(OnceCell::new()),
482 tier: NuGetRegistryTier::Public,
483 policy: Arc::new(RegistryAccessPolicy::default()),
484 alternates: Arc::new(DashMap::new()),
485 fallback_chain: Vec::new(),
486 auth: None,
487 declared_origin,
488 own_auth_id: 0,
489 chain_auth_digest: 0,
490 }
491 }
492
493 #[must_use]
509 pub fn with_base(
510 cache: Arc<HttpCache>,
511 hop: &ResolvedHop,
512 policy: Arc<RegistryAccessPolicy>,
513 fallback_chain: Vec<Arc<Self>>,
514 ) -> Self {
515 let declared_origin = origin_of(hop.url.as_str()).unwrap_or_default();
516 let own_auth_id = own_auth_digest(&declared_origin, hop.auth.as_ref());
517 Self {
518 cache,
519 service_index_url: hop.url.as_str().to_string(),
520 service_index: Arc::new(OnceCell::new()),
521 tier: NuGetRegistryTier::WorkspaceDeclared,
522 policy,
523 alternates: Arc::new(DashMap::new()),
524 fallback_chain,
525 auth: hop.auth.clone(),
526 declared_origin,
527 own_auth_id,
528 chain_auth_digest: 0,
529 }
530 }
531
532 async fn fetch(&self, url: &str, trusted_prefix: &str) -> Result<bytes::Bytes> {
550 let declared = self.declared_origin.as_str();
551 let can_authenticate = origin_of(url).as_deref() == Some(declared)
552 && origin_of(trusted_prefix).as_deref() == Some(declared);
553
554 if let Some(auth) = self.auth.as_ref()
555 && can_authenticate
556 {
557 return self
558 .cache
559 .get_cached_pinned_with_headers(
560 url,
561 trusted_prefix,
562 true,
563 Some(self.own_auth_id),
564 &[(reqwest::header::AUTHORIZATION, auth.header_value())],
565 )
566 .await;
567 }
568
569 if self.tier == NuGetRegistryTier::WorkspaceDeclared {
570 return self
571 .cache
572 .get_cached_pinned(url, trusted_prefix, false, None)
573 .await;
574 }
575
576 self.cache
577 .get_cached_trusted_origin(url, trusted_prefix)
578 .await
579 }
580
581 pub fn register_chain(
603 root: &Arc<Self>,
604 chain: &NuGetSourceChain,
605 policy: &Arc<RegistryAccessPolicy>,
606 ) {
607 let Some((first_hop, _)) = chain.hops.split_first() else {
608 return;
609 };
610 let new_digest = chain_auth_digest(&chain.hops);
611
612 let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
616
617 match root.alternates.entry(chain.key.clone()) {
618 dashmap::mapref::entry::Entry::Occupied(mut occupied) => {
619 if occupied.get().chain_auth_digest != new_digest {
620 let head = Self::build_head(root, chain, first_hop, policy, new_digest);
621 occupied.insert(Arc::new(head));
622 }
623 }
624 dashmap::mapref::entry::Entry::Vacant(slot) => {
625 if at_capacity {
626 tracing::warn!(
627 key = %chain.key,
628 cap = MAX_ALTERNATE_REGISTRIES,
629 "NuGet alternate registry cap reached; not registering a new chain"
630 );
631 return;
632 }
633 let head = Self::build_head(root, chain, first_hop, policy, new_digest);
634 slot.insert(Arc::new(head));
635 }
636 }
637 }
638
639 fn build_head(
644 root: &Arc<Self>,
645 chain: &NuGetSourceChain,
646 first_hop: &ResolvedHop,
647 policy: &Arc<RegistryAccessPolicy>,
648 chain_auth_digest: u64,
649 ) -> Self {
650 let mut fallback_chain: Vec<Arc<Self>> = chain.hops[1..]
651 .iter()
652 .map(|hop| {
653 Arc::new(Self::with_base(
654 Arc::clone(&root.cache),
655 hop,
656 Arc::clone(policy),
657 Vec::new(),
658 ))
659 })
660 .collect();
661 if chain.implicit_public_fallback {
662 fallback_chain.push(Arc::new(Self::with_service_index_url(
663 Arc::clone(&root.cache),
664 root.service_index_url.clone(),
665 )));
666 }
667
668 let mut head = Self::with_base(
669 Arc::clone(&root.cache),
670 first_hop,
671 Arc::clone(policy),
672 fallback_chain,
673 );
674 head.chain_auth_digest = chain_auth_digest;
675 head
676 }
677
678 #[must_use]
682 pub fn alternate_client(&self, index: &str) -> Option<Arc<Self>> {
683 self.alternates.get(index).map(|entry| Arc::clone(&entry))
684 }
685
686 async fn get_versions_chained(&self, name: &str) -> Result<Vec<NuGetVersion>> {
698 let mut last_miss: Result<Vec<NuGetVersion>> = Err(DepsError::PackageNotFound {
699 package: name.to_string(),
700 registry: REGISTRY,
701 });
702
703 for hop in std::iter::once(self).chain(self.fallback_chain.iter().map(Arc::as_ref)) {
704 match hop.get_versions_typed(name).await {
705 Ok(versions) if !versions.is_empty() => return Ok(versions),
706 Ok(empty) => last_miss = Ok(empty),
707 Err(error) if error.is_not_found() => {
708 last_miss = Err(DepsError::PackageNotFound {
709 package: name.to_string(),
710 registry: REGISTRY,
711 });
712 }
713 Err(other) => {
714 tracing::warn!(
715 package = name,
716 error = %other,
717 "NuGet alternate-feed chain resolution halted on a transport error \
718 — not falling back to api.nuget.org or the next configured feed"
719 );
720 return Err(DepsError::ChainResolutionHalted);
721 }
722 }
723 }
724
725 last_miss
726 }
727
728 async fn service_index(&self) -> Result<&ServiceIndex> {
734 self.service_index
735 .get_or_try_init(|| async {
736 let data = self
739 .fetch(&self.service_index_url, &self.declared_origin)
740 .await?;
741 let response: ServiceIndexResponse = deps_core::parse_json_checked(&data)?;
742 ServiceIndex::resolve(&response, self.tier, &self.policy)
743 })
744 .await
745 }
746
747 pub async fn get_versions_typed(&self, name: &str) -> Result<Vec<NuGetVersion>> {
758 self.get_versions_typed_with(name, false).await
759 }
760
761 pub async fn get_versions_typed_with(
790 &self,
791 name: &str,
792 freshness_enabled: bool,
793 ) -> Result<Vec<NuGetVersion>> {
794 reject_dot_segment(name)?;
795 let index = self.service_index().await?;
796 let flat_url = flat_container_url(&index.package_base_address, name);
797 let flat_trusted_prefix = format!("{}/", index.package_base_address);
798 let registration_base = if freshness_enabled {
799 index.registrations_base_url.clone()
800 } else {
801 None
802 };
803
804 if let Some(base) = registration_base {
810 let registration_url = registration_index_url(&base, name);
811 let registration_trusted_prefix = format!("{base}/");
812 let (flat_result, registration_result) = tokio::join!(
813 self.fetch(&flat_url, &flat_trusted_prefix),
814 self.fetch(®istration_url, ®istration_trusted_prefix),
815 );
816 let mut versions = parse_flat_container(&flat_result?)?;
817 match registration_result {
818 Ok(registration_body) => {
819 let enrichment = self
820 .registration_enrichment_from_index(
821 ®istration_body,
822 ®istration_trusted_prefix,
823 )
824 .await;
825 attach_publish_times(&mut versions, &enrichment.published);
826 }
827 Err(e) => {
828 tracing::debug!(package = %name, error = %e, "registration index fetch failed, publish times unavailable");
829 }
830 }
831 Ok(versions)
832 } else {
833 let data = self.fetch(&flat_url, &flat_trusted_prefix).await?;
834 parse_flat_container(&data)
835 }
836 }
837
838 async fn registration_enrichment_from_index(
853 &self,
854 index_body: &[u8],
855 trusted_prefix: &str,
856 ) -> RegistrationEnrichment {
857 let mut enrichment = RegistrationEnrichment::default();
858 let Ok(index) = deps_core::parse_json_checked::<RegistrationIndex>(index_body) else {
859 return enrichment;
860 };
861
862 let mut collected = 0usize;
863 let mut external_fetches = 0usize;
864
865 for page in index.items.iter().rev() {
866 if collected >= HOVER_RECENT_VERSIONS {
867 break;
868 }
869
870 match &page.items {
871 Some(inline) => {
872 accumulate_catalog_entries(&mut enrichment, &mut collected, inline);
873 }
874 None => {
875 if !page.id.starts_with(trusted_prefix) {
880 continue;
881 }
882 if external_fetches >= MAX_EXTERNAL_PAGE_FETCHES {
883 break;
884 }
885 external_fetches += 1;
886 let Ok(body) = self.fetch(&page.id, trusted_prefix).await else {
887 continue;
888 };
889 let Ok(parsed) = deps_core::parse_json_checked::<RegistrationPageBody>(&body)
890 else {
891 continue;
892 };
893 accumulate_catalog_entries(&mut enrichment, &mut collected, &parsed.items);
894 }
895 }
896 }
897
898 enrichment
899 }
900
901 pub async fn unlisted_versions_for_hover(&self, name: &str) -> Result<HashSet<String>> {
926 reject_dot_segment(name)?;
930 let index = self.service_index().await?;
931 let Some(base) = index.registrations_base_url.clone() else {
932 return Ok(HashSet::new());
933 };
934 let registration_url = registration_index_url(&base, name);
935 let trusted_prefix = format!("{base}/");
936 let Ok(body) = self.fetch(®istration_url, &trusted_prefix).await else {
937 return Ok(HashSet::new());
938 };
939 Ok(self
940 .registration_enrichment_from_index(&body, &trusted_prefix)
941 .await
942 .unlisted)
943 }
944
945 pub async fn get_latest_matching_typed(
954 &self,
955 name: &str,
956 req: &str,
957 ) -> Result<Option<NuGetVersion>> {
958 let versions = self.get_versions_typed(name).await?;
959 Ok(pick_latest_matching(versions, req))
960 }
961
962 pub async fn search_typed(&self, query: &str, limit: usize) -> Result<Vec<PackageInfo>> {
968 let index = self.service_index().await?;
969 let Some(search_base) = index.search_query_service.as_deref() else {
972 return Ok(Vec::new());
973 };
974 let url = search_url(search_base, query, limit);
975 let trusted_prefix = origin_of(search_base).unwrap_or_else(|| search_base.to_string());
980
981 let data = self.fetch(&url, &trusted_prefix).await?;
982 parse_search_response(&data, limit)
983 }
984}
985
986pub fn flat_container_url(base: &str, name: &str) -> String {
996 let lower = name.to_lowercase();
997 format!("{base}/{}/index.json", urlencoding::encode(&lower))
998}
999
1000pub fn registration_index_url(base: &str, name: &str) -> String {
1003 let lower = name.to_lowercase();
1004 format!("{base}/{}/index.json", urlencoding::encode(&lower))
1005}
1006
1007fn attach_publish_times(versions: &mut [NuGetVersion], times: &HashMap<String, PublishTime>) {
1012 for v in versions {
1013 v.published_at = times.get(v.version.as_str()).copied();
1014 }
1015}
1016
1017#[derive(Debug, Default)]
1021struct RegistrationEnrichment {
1022 published: HashMap<String, PublishTime>,
1023 unlisted: HashSet<String>,
1024}
1025
1026fn accumulate_catalog_entries(
1035 enrichment: &mut RegistrationEnrichment,
1036 collected: &mut usize,
1037 entries: &[CatalogEntryWrapper],
1038) {
1039 for entry in entries {
1040 let ce = &entry.catalog_entry;
1041 let parsed_published = ce.published.as_deref().and_then(PublishTime::parse_rfc3339);
1042 let is_sentinel = parsed_published.is_some_and(|t| t.as_unix_secs() <= 0);
1043
1044 if let Some(published) = parsed_published.filter(|t| t.as_unix_secs() > 0) {
1045 enrichment.published.insert(ce.version.clone(), published);
1046 }
1047
1048 let unlisted = ce.listed == Some(false) || (ce.listed.is_none() && is_sentinel);
1049 if unlisted {
1050 enrichment.unlisted.insert(ce.version.clone());
1051 }
1052
1053 *collected += 1;
1054 }
1055}
1056
1057pub fn search_url(base: &str, query: &str, limit: usize) -> String {
1062 format!(
1063 "{base}?q={}&take={limit}&prerelease=false&semVerLevel=2.0.0",
1064 urlencoding::encode(query),
1065 )
1066}
1067
1068pub fn parse_flat_container(data: &[u8]) -> Result<Vec<NuGetVersion>> {
1070 let parsed: FlatContainerIndex = deps_core::parse_json_checked(data)?;
1071
1072 let mut versions = parsed.versions;
1073 versions.sort_by(|a, b| compare_versions(b, a));
1077
1078 Ok(versions
1079 .into_iter()
1080 .map(|version| NuGetVersion {
1081 version: version.into(),
1082 published_at: None,
1083 })
1084 .collect())
1085}
1086
1087fn pick_latest_matching(versions: Vec<NuGetVersion>, req: &str) -> Option<NuGetVersion> {
1098 if versions.is_empty() {
1099 return None;
1100 }
1101
1102 let req = if req.is_empty() { "*" } else { req };
1103
1104 let matched = if req.contains('*') {
1105 let strings: Vec<String> = versions.iter().map(|v| v.version.to_string()).collect();
1106 crate::version::resolve_float(&strings, req).map(|v| NuGetVersion {
1107 version: v.into(),
1108 published_at: None,
1109 })
1110 } else {
1111 let req_is_prerelease_bearing = req.contains('-');
1112 versions
1113 .iter()
1114 .find(|v| {
1115 crate::version::satisfies(v.version.as_str(), req)
1116 && (req_is_prerelease_bearing
1117 || !crate::version::is_prerelease(v.version.as_str()))
1118 })
1119 .cloned()
1120 };
1121
1122 matched.or_else(|| {
1123 if deps_core::is_existence_wildcard_str(req) {
1124 let idx = deps_core::select_latest_for_existence(&versions, |v| {
1125 v as &dyn deps_core::Version
1126 })?;
1127 Some(versions[idx].clone())
1128 } else {
1129 None
1130 }
1131 })
1132}
1133
1134fn parse_search_response(data: &[u8], limit: usize) -> Result<Vec<PackageInfo>> {
1135 let response: SearchResponse = deps_core::parse_json_checked(data)?;
1136
1137 Ok(response
1138 .data
1139 .into_iter()
1140 .take(limit)
1141 .map(|d| PackageInfo {
1142 name: d.id.into(),
1143 description: d.description,
1144 repository: d.project_url,
1145 documentation: None,
1146 latest_version: d.version.unwrap_or_default().into(),
1147 })
1148 .collect())
1149}
1150
1151impl deps_core::Registry for NuGetRegistry {
1152 fn get_versions<'a>(
1153 &'a self,
1154 name: &'a deps_core::PackageName,
1155 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1156 Box::pin(async move {
1157 let versions = self.get_versions_typed(name.as_str()).await?;
1158 Ok(versions
1159 .into_iter()
1160 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1161 .collect())
1162 })
1163 }
1164
1165 fn get_versions_with<'a>(
1166 &'a self,
1167 name: &'a deps_core::PackageName,
1168 freshness: deps_core::FreshnessSettings,
1169 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1170 Box::pin(async move {
1171 let versions = self
1172 .get_versions_typed_with(name.as_str(), freshness.enabled)
1173 .await?;
1174 Ok(versions
1175 .into_iter()
1176 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1177 .collect())
1178 })
1179 }
1180
1181 fn get_latest_matching<'a>(
1182 &'a self,
1183 name: &'a deps_core::PackageName,
1184 req: &'a deps_core::VersionReq,
1185 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
1186 Box::pin(async move {
1187 let version = self
1188 .get_latest_matching_typed(name.as_str(), req.as_str())
1189 .await?;
1190 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
1191 })
1192 }
1193
1194 fn get_versions_from<'a>(
1201 &'a self,
1202 name: &'a deps_core::PackageName,
1203 source: &'a DependencySource,
1204 freshness: FreshnessSettings,
1205 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Version>>>> {
1206 Box::pin(async move {
1207 match source {
1208 DependencySource::AlternateRegistry { index, .. } => {
1209 match self.alternate_client(index) {
1210 Some(client) => {
1211 let versions = client.get_versions_chained(name.as_str()).await?;
1212 Ok(versions
1213 .into_iter()
1214 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1215 .collect())
1216 }
1217 None => Err(DepsError::PackageNotFound {
1218 package: name.to_string(),
1219 registry: "alternate registry (not registered)",
1220 }),
1221 }
1222 }
1223 _ => deps_core::Registry::get_versions_with(self, name, freshness).await,
1224 }
1225 })
1226 }
1227
1228 fn get_latest_matching_from<'a>(
1234 &'a self,
1235 name: &'a deps_core::PackageName,
1236 source: &'a DependencySource,
1237 req: &'a deps_core::VersionReq,
1238 _minimum_stability: Option<&'a str>,
1239 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Option<Box<dyn deps_core::Version>>>> {
1240 Box::pin(async move {
1241 match source {
1242 DependencySource::AlternateRegistry { index, .. } => {
1243 match self.alternate_client(index) {
1244 Some(client) => {
1245 let versions: Vec<Box<dyn deps_core::Version>> = client
1246 .get_versions_chained(name.as_str())
1247 .await?
1248 .into_iter()
1249 .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1250 .collect();
1251 let idx = client.select_latest_matching(&versions, req);
1252 Ok(idx.and_then(|i| versions.into_iter().nth(i)))
1253 }
1254 None => Err(DepsError::PackageNotFound {
1255 package: name.to_string(),
1256 registry: "alternate registry (not registered)",
1257 }),
1258 }
1259 }
1260 _ => {
1261 let version = self
1262 .get_latest_matching_typed(name.as_str(), req.as_str())
1263 .await?;
1264 Ok(version.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
1265 }
1266 }
1267 })
1268 }
1269
1270 fn search<'a>(
1271 &'a self,
1272 query: &'a str,
1273 limit: usize,
1274 ) -> deps_core::ecosystem::BoxFuture<'a, Result<Vec<Box<dyn deps_core::Metadata>>>> {
1275 Box::pin(async move {
1276 let results = self.search_typed(query, limit).await?;
1277 Ok(results
1278 .into_iter()
1279 .map(|m| Box::new(m) as Box<dyn deps_core::Metadata>)
1280 .collect())
1281 })
1282 }
1283
1284 fn select_latest_matching(
1285 &self,
1286 versions: &[Box<dyn deps_core::Version>],
1287 req: &deps_core::VersionReq,
1288 ) -> Option<usize> {
1289 if versions.is_empty() {
1290 return None;
1291 }
1292 let req_str = req.as_str();
1293 let req_str = if req_str.is_empty() { "*" } else { req_str };
1294
1295 let matched = if req_str.contains('*') {
1296 let strings: Vec<String> = versions
1297 .iter()
1298 .map(|v| v.version_string().to_string())
1299 .collect();
1300 crate::version::resolve_float(&strings, req_str)
1301 .and_then(|matched| strings.iter().position(|s| s == matched))
1302 } else {
1303 let req_is_prerelease_bearing = req_str.contains('-');
1304 versions.iter().position(|v| {
1305 crate::version::satisfies(v.version_string().as_str(), req_str)
1306 && (req_is_prerelease_bearing
1307 || !crate::version::is_prerelease(v.version_string().as_str()))
1308 })
1309 };
1310
1311 matched.or_else(|| {
1312 deps_core::is_existence_wildcard_str(req_str)
1313 .then(|| deps_core::select_latest_for_existence(versions, |v| v.as_ref()))
1314 .flatten()
1315 })
1316 }
1317
1318 fn reports_yanked(&self) -> bool {
1326 false
1327 }
1328
1329 fn as_any(&self) -> &dyn Any {
1330 self
1331 }
1332}
1333
1334#[cfg(test)]
1335mod tests {
1336 use super::*;
1337 use crate::config::NuGetFeedUrl;
1338 use std::assert_matches;
1339
1340 fn service_index_body(package_base_address: &str, search_query_service: &str) -> String {
1341 format!(
1342 r#"{{
1343 "version": "3.0.0",
1344 "resources": [
1345 {{"@id": "{package_base_address}", "@type": "PackageBaseAddress/3.0.0"}},
1346 {{"@id": "{search_query_service}", "@type": "SearchQueryService/3.5.0"}}
1347 ]
1348 }}"#
1349 )
1350 }
1351
1352 fn service_index_body_with_registrations(
1353 package_base_address: &str,
1354 search_query_service: &str,
1355 registrations_base_url: &str,
1356 ) -> String {
1357 format!(
1358 r#"{{
1359 "version": "3.0.0",
1360 "resources": [
1361 {{"@id": "{package_base_address}", "@type": "PackageBaseAddress/3.0.0"}},
1362 {{"@id": "{search_query_service}", "@type": "SearchQueryService/3.5.0"}},
1363 {{"@id": "{registrations_base_url}", "@type": "RegistrationsBaseUrl/3.6.0"}}
1364 ]
1365 }}"#
1366 )
1367 }
1368
1369 #[test]
1370 fn test_package_url() {
1371 assert_eq!(
1372 package_url("Newtonsoft.Json"),
1373 "https://www.nuget.org/packages/Newtonsoft.Json"
1374 );
1375 }
1376
1377 #[test]
1378 fn test_package_url_encodes_malicious_name() {
1379 let url = package_url("evil](https://evil.example)[pkg");
1380 assert!(!url.contains('('));
1381 assert!(!url.contains(')'));
1382 assert!(!url.contains('['));
1383 assert!(!url.contains(']'));
1384 }
1385
1386 #[test]
1387 fn test_package_url_encodes_newline_autolink_and_percent() {
1388 let url = package_url("evil\n<https://evil%zz.example>");
1389 assert!(!url.contains('\n'));
1390 assert!(!url.contains('<'));
1391 assert!(!url.contains('>'));
1392 assert!(url.contains("%25"));
1393 }
1394
1395 #[test]
1396 fn test_package_url_empty_name() {
1397 assert_eq!(package_url(""), "https://www.nuget.org/packages/");
1398 }
1399
1400 #[test]
1401 fn test_flat_container_url_lowercases_and_encodes() {
1402 assert_eq!(
1403 flat_container_url("https://api.nuget.org/v3-flatcontainer", "Newtonsoft.Json"),
1404 "https://api.nuget.org/v3-flatcontainer/newtonsoft.json/index.json"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_flat_container_url_encodes_path_traversal_attempt() {
1410 let url = flat_container_url(
1413 "https://api.nuget.org/v3-flatcontainer",
1414 "../../../../etc/passwd",
1415 );
1416 assert_eq!(
1417 url,
1418 "https://api.nuget.org/v3-flatcontainer/..%2F..%2F..%2F..%2Fetc%2Fpasswd/index.json"
1419 );
1420 assert!(
1421 !url.contains("/../"),
1422 "raw path traversal segment leaked into URL: {url}"
1423 );
1424 }
1425
1426 #[test]
1427 fn test_flat_container_url_encodes_fragment_and_query_delimiters() {
1428 assert_eq!(
1431 flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo#x"),
1432 "https://api.nuget.org/v3-flatcontainer/foo%23x/index.json"
1433 );
1434 assert_eq!(
1435 flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo?x=1"),
1436 "https://api.nuget.org/v3-flatcontainer/foo%3Fx%3D1/index.json"
1437 );
1438 }
1439
1440 #[test]
1441 fn test_flat_container_url_encodes_control_characters() {
1442 let url = flat_container_url("https://api.nuget.org/v3-flatcontainer", "Foo\tBar");
1443 assert_eq!(
1444 url,
1445 "https://api.nuget.org/v3-flatcontainer/foo%09bar/index.json"
1446 );
1447 }
1448
1449 #[test]
1450 fn test_reject_dot_segment_rejects_bare_dot_dot() {
1451 assert!(reject_dot_segment("..").is_err());
1452 }
1453
1454 #[test]
1455 fn test_reject_dot_segment_rejects_bare_dot() {
1456 assert!(reject_dot_segment(".").is_err());
1457 }
1458
1459 #[test]
1460 fn test_reject_dot_segment_accepts_normal_names() {
1461 assert!(reject_dot_segment("Newtonsoft.Json").is_ok());
1462 }
1463
1464 #[test]
1468 fn test_flat_container_url_bare_dot_dot_normalizes_above_base_prefix() {
1469 let url = flat_container_url("https://api.nuget.org/v3-flatcontainer", "..");
1470 let parsed = url::Url::parse(&url).unwrap();
1471 assert_eq!(
1472 parsed.path(),
1473 "/index.json",
1474 "parsed path: {}",
1475 parsed.path()
1476 );
1477 }
1478
1479 #[test]
1483 fn test_flat_container_url_dot_segment_sweep() {
1484 deps_core::test_util::assert_dot_segment_gated_or_contained(
1485 |seg| {
1486 reject_dot_segment(seg)
1487 .ok()
1488 .map(|()| flat_container_url("https://api.nuget.org/v3-flatcontainer", seg))
1489 },
1490 "api.nuget.org",
1491 "/v3-flatcontainer/",
1492 );
1493 }
1494
1495 #[test]
1496 fn test_search_url_includes_mandatory_semver_level_and_prerelease_false() {
1497 let url = search_url("https://azuresearch-usnc.nuget.org/query", "json", 10);
1498 assert_eq!(
1499 url,
1500 "https://azuresearch-usnc.nuget.org/query?q=json&take=10&prerelease=false&semVerLevel=2.0.0"
1501 );
1502 }
1503
1504 #[test]
1505 fn test_search_url_encodes_query() {
1506 let url = search_url("https://azuresearch-usnc.nuget.org/query", "a b&c", 5);
1507 assert!(url.contains("q=a%20b%26c"), "query not encoded: {url}");
1508 }
1509
1510 fn public_policy() -> RegistryAccessPolicy {
1511 RegistryAccessPolicy::default()
1512 }
1513
1514 #[test]
1515 fn test_service_index_resolve_success() {
1516 let response: ServiceIndexResponse = serde_json::from_str(&service_index_body(
1517 "https://api.nuget.org/v3-flatcontainer/",
1518 "https://azuresearch-usnc.nuget.org/query",
1519 ))
1520 .unwrap();
1521 let index =
1522 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1523 assert_eq!(
1524 index.package_base_address,
1525 "https://api.nuget.org/v3-flatcontainer"
1526 );
1527 assert_eq!(
1528 index.search_query_service.as_deref(),
1529 Some("https://azuresearch-usnc.nuget.org/query")
1530 );
1531 }
1532
1533 #[test]
1534 fn test_service_index_resolve_missing_resource_errors() {
1535 let response: ServiceIndexResponse = serde_json::from_str(
1536 r#"{"version": "3.0.0", "resources": [{"@id": "https://x", "@type": "SomeOtherType"}]}"#,
1537 )
1538 .unwrap();
1539 assert!(
1540 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).is_err()
1541 );
1542 }
1543
1544 #[test]
1545 fn test_service_index_search_query_service_fallback() {
1546 let response: ServiceIndexResponse = serde_json::from_str(
1547 r#"{"version": "3.0.0", "resources": [
1548 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1549 {"@id": "https://search/", "@type": "SearchQueryService"}
1550 ]}"#,
1551 )
1552 .unwrap();
1553 let index =
1554 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1555 assert_eq!(
1556 index.search_query_service.as_deref(),
1557 Some("https://search")
1558 );
1559 }
1560
1561 #[test]
1563 fn test_service_index_resolve_type_as_array() {
1564 let response: ServiceIndexResponse = serde_json::from_str(
1565 r#"{"version": "3.0.0", "resources": [
1566 {"@id": "https://flat/", "@type": ["PackageBaseAddress/3.0.0", "Other"]},
1567 {"@id": "https://search/", "@type": "SearchQueryService"}
1568 ]}"#,
1569 )
1570 .unwrap();
1571 let index =
1572 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1573 assert_eq!(index.package_base_address, "https://flat");
1574 }
1575
1576 #[test]
1579 fn test_service_index_resolve_type_malformed_scalar_degrades() {
1580 let response: ServiceIndexResponse = serde_json::from_str(
1581 r#"{"version": "3.0.0", "resources": [
1582 {"@id": "https://malformed/", "@type": 123},
1583 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1584 {"@id": "https://search/", "@type": "SearchQueryService"}
1585 ]}"#,
1586 )
1587 .unwrap();
1588 let index =
1589 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1590 assert_eq!(index.package_base_address, "https://flat");
1591 }
1592
1593 #[test]
1595 fn test_service_index_resolve_missing_id_skipped() {
1596 let response: ServiceIndexResponse = serde_json::from_str(
1597 r#"{"version": "3.0.0", "resources": [
1598 {"@type": "PackageBaseAddress/3.0.0"},
1599 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1600 {"@id": "https://search/", "@type": "SearchQueryService"}
1601 ]}"#,
1602 )
1603 .unwrap();
1604 let index =
1605 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1606 assert_eq!(index.package_base_address, "https://flat");
1607 }
1608
1609 #[test]
1612 fn test_service_index_resolve_no_search_query_service_is_none() {
1613 let response: ServiceIndexResponse = serde_json::from_str(
1614 r#"{"version": "3.0.0", "resources": [
1615 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"}
1616 ]}"#,
1617 )
1618 .unwrap();
1619 let index =
1620 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1621 assert!(index.search_query_service.is_none());
1622 }
1623
1624 #[test]
1628 fn test_service_index_resolve_workspace_tier_blocks_disallowed_package_base_address() {
1629 let response: ServiceIndexResponse = serde_json::from_str(
1630 r#"{"version": "3.0.0", "resources": [
1631 {"@id": "https://10.0.0.5/flat", "@type": "PackageBaseAddress/3.0.0"}
1632 ]}"#,
1633 )
1634 .unwrap();
1635 let policy =
1636 RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly);
1637 assert!(
1638 ServiceIndex::resolve(&response, NuGetRegistryTier::WorkspaceDeclared, &policy)
1639 .is_err()
1640 );
1641 }
1642
1643 #[test]
1645 fn test_service_index_resolve_workspace_tier_degrades_blocked_registrations_base() {
1646 let response: ServiceIndexResponse = serde_json::from_str(
1647 r#"{"version": "3.0.0", "resources": [
1648 {"@id": "https://feed.example/flat", "@type": "PackageBaseAddress/3.0.0"},
1649 {"@id": "https://10.0.0.5/reg", "@type": "RegistrationsBaseUrl/3.6.0"}
1650 ]}"#,
1651 )
1652 .unwrap();
1653 let policy =
1654 RegistryAccessPolicy::new(deps_core::net_policy::WorkspaceRegistryAccess::PublicOnly);
1655 let index = ServiceIndex::resolve(&response, NuGetRegistryTier::WorkspaceDeclared, &policy)
1656 .unwrap();
1657 assert!(index.registrations_base_url.is_none());
1658 }
1659
1660 #[test]
1661 fn test_parse_flat_container_sorted_descending() {
1662 let data = br#"{"versions": ["12.0.1", "13.0.3", "13.0.0-beta1"]}"#;
1663 let versions = parse_flat_container(data).unwrap();
1664 let strings: Vec<&str> = versions.iter().map(|v| v.version.as_str()).collect();
1665 assert_eq!(strings, vec!["13.0.3", "13.0.0-beta1", "12.0.1"]);
1666 }
1667
1668 #[test]
1669 fn test_parse_flat_container_empty() {
1670 let data = br#"{"versions": []}"#;
1671 let versions = parse_flat_container(data).unwrap();
1672 assert!(versions.is_empty());
1673 }
1674
1675 #[test]
1676 fn test_parse_flat_container_invalid_json_errors() {
1677 assert!(parse_flat_container(b"not json").is_err());
1678 }
1679
1680 #[test]
1681 fn test_parse_search_response() {
1682 let data = br#"{"totalHits": 1, "data": [{"id": "Newtonsoft.Json", "version": "13.0.3", "description": "JSON framework", "projectUrl": "https://example.com"}]}"#;
1683 let results = parse_search_response(data, 10).unwrap();
1684 assert_eq!(results.len(), 1);
1685 assert_eq!(results[0].name, "Newtonsoft.Json");
1686 assert_eq!(results[0].latest_version, "13.0.3");
1687 assert_eq!(
1688 results[0].repository.as_deref(),
1689 Some("https://example.com")
1690 );
1691 }
1692
1693 #[test]
1694 fn test_parse_search_response_respects_limit() {
1695 let data = br#"{"totalHits": 2, "data": [
1696 {"id": "A", "version": "1.0.0"},
1697 {"id": "B", "version": "2.0.0"}
1698 ]}"#;
1699 let results = parse_search_response(data, 1).unwrap();
1700 assert_eq!(results.len(), 1);
1701 assert_eq!(results[0].name, "A");
1702 }
1703
1704 fn v(s: &str) -> NuGetVersion {
1705 NuGetVersion {
1706 version: s.into(),
1707 published_at: None,
1708 }
1709 }
1710
1711 #[test]
1712 fn test_pick_latest_matching_wildcard_excludes_prerelease() {
1713 let versions = vec![v("1.0.0"), v("1.1.0-rc.1")];
1714 let latest = pick_latest_matching(versions, "*");
1715 assert_eq!(latest.unwrap().version, "1.0.0");
1716 }
1717
1718 #[test]
1719 fn test_pick_latest_matching_empty_req_behaves_like_wildcard() {
1720 let versions = vec![v("1.0.0"), v("1.1.0-rc.1")];
1721 let latest = pick_latest_matching(versions, "");
1722 assert_eq!(latest.unwrap().version, "1.0.0");
1723 }
1724
1725 #[test]
1726 fn test_pick_latest_matching_exact_pin() {
1727 let versions = vec![v("1.0.1"), v("1.0.0")];
1728 let matched = pick_latest_matching(versions, "[1.0.0]");
1729 assert_eq!(matched.unwrap().version, "1.0.0");
1730 }
1731
1732 #[test]
1733 fn test_pick_latest_matching_floating_prefix() {
1734 let versions = vec![v("1.2.0"), v("1.1.5"), v("1.1.0")];
1735 let matched = pick_latest_matching(versions, "1.1.*");
1736 assert_eq!(matched.unwrap().version, "1.1.5");
1737 }
1738
1739 #[test]
1740 fn test_pick_latest_matching_prerelease_bearing_requirement_allows_prerelease() {
1741 let versions = vec![v("1.0.0-rc.2"), v("0.9.0")];
1742 let matched = pick_latest_matching(versions, "[1.0.0-rc.2]");
1743 assert_eq!(matched.unwrap().version, "1.0.0-rc.2");
1744 }
1745
1746 #[test]
1747 fn test_pick_latest_matching_empty_versions_returns_none() {
1748 assert!(pick_latest_matching(vec![], "*").is_none());
1749 }
1750
1751 #[test]
1752 fn test_pick_latest_matching_no_match_returns_none() {
1753 let versions = vec![v("2.0.0")];
1754 assert!(pick_latest_matching(versions, "[1.0.0]").is_none());
1755 }
1756
1757 #[test]
1761 fn test_pick_latest_matching_wildcard_prerelease_only_still_resolves() {
1762 let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1763 let matched = pick_latest_matching(versions, "*");
1764 assert_eq!(matched.unwrap().version, "2.0.0-beta2");
1765 }
1766
1767 #[test]
1770 fn test_pick_latest_matching_empty_req_prerelease_only_still_resolves() {
1771 let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1772 let matched = pick_latest_matching(versions, "");
1773 assert_eq!(matched.unwrap().version, "2.0.0-beta2");
1774 }
1775
1776 #[test]
1780 fn test_pick_latest_matching_prerelease_bearing_wildcard_unaffected_by_rescue() {
1781 let versions = vec![v("2.0.0-rc"), v("1.5.0"), v("1.0.0")];
1782 let matched = pick_latest_matching(versions, "*-*");
1783 assert_eq!(matched.unwrap().version, "2.0.0-rc");
1784 }
1785
1786 #[test]
1792 fn test_pick_latest_matching_concrete_floating_requirement_not_rescued() {
1793 let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1794 assert!(pick_latest_matching(versions, "1.*").is_none());
1795 }
1796
1797 #[test]
1802 fn test_pick_latest_matching_exact_pin_not_rescued() {
1803 let versions = vec![v("2.0.0-beta2"), v("2.0.0-beta1")];
1804 assert!(pick_latest_matching(versions, "[9.9.9]").is_none());
1805 }
1806
1807 #[test]
1808 fn test_registry_creation_and_trait_impls() {
1809 use deps_core::Registry;
1810 let cache = Arc::new(HttpCache::new());
1811 let registry = NuGetRegistry::new(cache);
1812 assert!(registry.as_any().is::<NuGetRegistry>());
1813 }
1814
1815 #[tokio::test]
1826 async fn test_get_versions_typed_with_rejects_bare_dot_dot_as_not_found() {
1827 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
1828 let err = registry
1829 .get_versions_typed_with("..", false)
1830 .await
1831 .unwrap_err();
1832 assert_matches!(err, deps_core::DepsError::PackageNotFound { .. });
1833 }
1834
1835 #[test]
1836 fn test_with_service_index_url_used_by_new() {
1837 let cache = Arc::new(HttpCache::new());
1838 let registry = NuGetRegistry::new(cache);
1839 assert_eq!(registry.service_index_url, NUGET_ORG_INDEX_URL);
1840 }
1841
1842 #[test]
1843 fn test_select_latest_matching_not_default_none() {
1844 use deps_core::{Registry, VersionReq};
1845
1846 let cache = Arc::new(HttpCache::new());
1847 let registry = NuGetRegistry::new(cache);
1848 let versions: Vec<Box<dyn deps_core::Version>> =
1849 vec![Box::new(v("1.1.0-rc.1")), Box::new(v("1.0.0"))];
1850 let req = VersionReq::new("*");
1851 assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1852 }
1853
1854 #[test]
1858 fn test_select_latest_matching_wildcard_prerelease_only_still_resolves() {
1859 use deps_core::{Registry, VersionReq};
1860
1861 let cache = Arc::new(HttpCache::new());
1862 let registry = NuGetRegistry::new(cache);
1863 let versions: Vec<Box<dyn deps_core::Version>> =
1864 vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1865 let req = VersionReq::new("*");
1866 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1867 }
1868
1869 #[test]
1872 fn test_select_latest_matching_empty_req_prerelease_only_still_resolves() {
1873 use deps_core::{Registry, VersionReq};
1874
1875 let cache = Arc::new(HttpCache::new());
1876 let registry = NuGetRegistry::new(cache);
1877 let versions: Vec<Box<dyn deps_core::Version>> =
1878 vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1879 let req = VersionReq::new("");
1880 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1881 }
1882
1883 #[test]
1888 fn test_select_latest_matching_prerelease_bearing_wildcard_unaffected_by_rescue() {
1889 use deps_core::{Registry, VersionReq};
1890
1891 let cache = Arc::new(HttpCache::new());
1892 let registry = NuGetRegistry::new(cache);
1893 let versions: Vec<Box<dyn deps_core::Version>> = vec![
1894 Box::new(v("2.0.0-rc")),
1895 Box::new(v("1.5.0")),
1896 Box::new(v("1.0.0")),
1897 ];
1898 let req = VersionReq::new("*-*");
1899 assert_eq!(registry.select_latest_matching(&versions, &req), Some(0));
1900 }
1901
1902 #[test]
1906 fn test_select_latest_matching_concrete_floating_requirement_not_rescued() {
1907 use deps_core::{Registry, VersionReq};
1908
1909 let cache = Arc::new(HttpCache::new());
1910 let registry = NuGetRegistry::new(cache);
1911 let versions: Vec<Box<dyn deps_core::Version>> =
1912 vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1913 let req = VersionReq::new("1.*");
1914 assert_eq!(registry.select_latest_matching(&versions, &req), None);
1915 }
1916
1917 #[test]
1921 fn test_select_latest_matching_exact_pin_not_rescued() {
1922 use deps_core::{Registry, VersionReq};
1923
1924 let cache = Arc::new(HttpCache::new());
1925 let registry = NuGetRegistry::new(cache);
1926 let versions: Vec<Box<dyn deps_core::Version>> =
1927 vec![Box::new(v("2.0.0-beta2")), Box::new(v("2.0.0-beta1"))];
1928 let req = VersionReq::new("[9.9.9]");
1929 assert_eq!(registry.select_latest_matching(&versions, &req), None);
1930 }
1931
1932 #[test]
1935 fn test_service_index_resolve_registrations_base_url_prefers_3_6_0() {
1936 let response: ServiceIndexResponse = serde_json::from_str(
1937 r#"{"version": "3.0.0", "resources": [
1938 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1939 {"@id": "https://search/", "@type": "SearchQueryService"},
1940 {"@id": "https://reg-semver1/", "@type": "RegistrationsBaseUrl/3.4.0"},
1941 {"@id": "https://reg-semver2/", "@type": "RegistrationsBaseUrl/3.6.0"}
1942 ]}"#,
1943 )
1944 .unwrap();
1945 let index =
1946 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1947 assert_eq!(
1948 index.registrations_base_url.as_deref(),
1949 Some("https://reg-semver2")
1950 );
1951 }
1952
1953 #[test]
1954 fn test_service_index_resolve_registrations_base_url_falls_back_to_3_4_0() {
1955 let response: ServiceIndexResponse = serde_json::from_str(
1956 r#"{"version": "3.0.0", "resources": [
1957 {"@id": "https://flat/", "@type": "PackageBaseAddress/3.0.0"},
1958 {"@id": "https://search/", "@type": "SearchQueryService"},
1959 {"@id": "https://reg-semver1/", "@type": "RegistrationsBaseUrl/3.4.0"}
1960 ]}"#,
1961 )
1962 .unwrap();
1963 let index =
1964 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1965 assert_eq!(
1966 index.registrations_base_url.as_deref(),
1967 Some("https://reg-semver1")
1968 );
1969 }
1970
1971 #[test]
1972 fn test_service_index_resolve_registrations_base_url_absent_is_none() {
1973 let response: ServiceIndexResponse =
1974 serde_json::from_str(&service_index_body("https://flat/", "https://search/")).unwrap();
1975 let index =
1976 ServiceIndex::resolve(&response, NuGetRegistryTier::Public, &public_policy()).unwrap();
1977 assert!(index.registrations_base_url.is_none());
1978 }
1979
1980 #[test]
1981 fn test_registration_index_url_lowercases_and_encodes() {
1982 assert_eq!(
1983 registration_index_url(
1984 "https://api.nuget.org/v3/registration5-gz-semver2",
1985 "Newtonsoft.Json"
1986 ),
1987 "https://api.nuget.org/v3/registration5-gz-semver2/newtonsoft.json/index.json"
1988 );
1989 }
1990
1991 #[test]
1995 fn test_registration_index_url_dot_segment_sweep() {
1996 deps_core::test_util::assert_dot_segment_gated_or_contained(
1997 |seg| {
1998 reject_dot_segment(seg).ok().map(|()| {
1999 registration_index_url("https://api.nuget.org/v3/registration5-gz-semver2", seg)
2000 })
2001 },
2002 "api.nuget.org",
2003 "/v3/registration5-gz-semver2/",
2004 );
2005 }
2006
2007 #[test]
2010 fn test_attach_publish_times_matches_by_version_string() {
2011 let mut versions = vec![v("1.0.0"), v("2.0.0")];
2012 let mut times = HashMap::new();
2013 times.insert(
2014 "1.0.0".to_string(),
2015 PublishTime::parse_rfc3339("2020-01-01T00:00:00Z").unwrap(),
2016 );
2017 attach_publish_times(&mut versions, ×);
2018 assert_eq!(
2019 versions[0].published_at,
2020 PublishTime::parse_rfc3339("2020-01-01T00:00:00Z")
2021 );
2022 assert_eq!(versions[1].published_at, None);
2023 }
2024
2025 #[test]
2026 fn test_attach_publish_times_empty_map_leaves_all_none() {
2027 let mut versions = vec![v("1.0.0"), v("2.0.0")];
2028 attach_publish_times(&mut versions, &HashMap::new());
2029 assert!(versions.iter().all(|ver| ver.published_at.is_none()));
2030 }
2031
2032 fn inline_registration_index(base: &str, entries: &[(&str, Option<&str>)]) -> String {
2035 inline_registration_index_with_listed(
2036 base,
2037 &entries
2038 .iter()
2039 .map(|(v, p)| (*v, *p, None))
2040 .collect::<Vec<_>>(),
2041 )
2042 }
2043
2044 fn inline_registration_index_with_listed(
2045 base: &str,
2046 entries: &[(&str, Option<&str>, Option<bool>)],
2047 ) -> String {
2048 let items: Vec<String> = entries
2049 .iter()
2050 .map(|(version, published, listed)| {
2051 let published_field = published
2052 .map(|p| format!(r#", "published": "{p}""#))
2053 .unwrap_or_default();
2054 let listed_field = listed
2055 .map(|l| format!(r#", "listed": {l}"#))
2056 .unwrap_or_default();
2057 format!(
2058 r#"{{"catalogEntry": {{"version": "{version}"{published_field}{listed_field}}}}}"#
2059 )
2060 })
2061 .collect();
2062 format!(
2063 r#"{{"count": 1, "items": [{{"@id": "{base}/pkg/page/0.json", "count": {n}, "items": [{items}]}}]}}"#,
2064 n = entries.len(),
2065 items = items.join(",")
2066 )
2067 }
2068
2069 #[tokio::test]
2070 async fn test_registration_enrichment_from_index_inline_happy_path() {
2071 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2072 let body = inline_registration_index(
2073 "https://api.nuget.org/v3/reg",
2074 &[
2075 ("1.0.0", Some("2020-01-01T00:00:00Z")),
2076 ("2.0.0", Some("2021-01-01T00:00:00Z")),
2077 ],
2078 );
2079 let enrichment = registry
2080 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2081 .await;
2082 assert_eq!(
2083 enrichment.published.get("2.0.0").copied(),
2084 PublishTime::parse_rfc3339("2021-01-01T00:00:00Z")
2085 );
2086 assert_eq!(enrichment.published.len(), 2);
2087 assert!(enrichment.unlisted.is_empty());
2088 }
2089
2090 #[tokio::test]
2091 async fn test_registration_enrichment_from_index_sentinel_filtered() {
2092 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2093 let body = inline_registration_index(
2094 "https://api.nuget.org/v3/reg",
2095 &[
2096 ("1.0.0", Some("1900-01-01T00:00:00+00:00")),
2097 ("2.0.0", Some("2021-01-01T00:00:00Z")),
2098 ],
2099 );
2100 let enrichment = registry
2101 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2102 .await;
2103 assert!(!enrichment.published.contains_key("1.0.0"));
2104 assert!(enrichment.published.contains_key("2.0.0"));
2105 }
2106
2107 #[tokio::test]
2111 async fn test_registration_enrichment_from_index_legacy_sentinel_marks_unlisted() {
2112 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2113 let body = inline_registration_index(
2114 "https://api.nuget.org/v3/reg",
2115 &[
2116 ("1.0.0", Some("1900-01-01T00:00:00+00:00")),
2117 ("2.0.0", Some("2021-01-01T00:00:00Z")),
2118 ],
2119 );
2120 let enrichment = registry
2121 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2122 .await;
2123 assert!(enrichment.unlisted.contains("1.0.0"));
2124 assert!(!enrichment.unlisted.contains("2.0.0"));
2125 }
2126
2127 #[tokio::test]
2130 async fn test_registration_enrichment_from_index_explicit_listed_false_marks_unlisted() {
2131 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2132 let body = inline_registration_index_with_listed(
2133 "https://api.nuget.org/v3/reg",
2134 &[
2135 ("1.0.0", Some("2020-01-01T00:00:00Z"), Some(false)),
2136 ("2.0.0", Some("2021-01-01T00:00:00Z"), Some(true)),
2137 ],
2138 );
2139 let enrichment = registry
2140 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2141 .await;
2142 assert!(enrichment.unlisted.contains("1.0.0"));
2143 assert!(!enrichment.unlisted.contains("2.0.0"));
2144 assert!(enrichment.published.contains_key("1.0.0"));
2146 }
2147
2148 #[tokio::test]
2149 async fn test_registration_enrichment_from_index_missing_published_is_absent_rest_intact() {
2150 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2151 let body = inline_registration_index(
2152 "https://api.nuget.org/v3/reg",
2153 &[("1.0.0", None), ("2.0.0", Some("2021-01-01T00:00:00Z"))],
2154 );
2155 let enrichment = registry
2156 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2157 .await;
2158 assert!(!enrichment.published.contains_key("1.0.0"));
2159 assert!(enrichment.published.contains_key("2.0.0"));
2160 assert!(!enrichment.unlisted.contains("1.0.0"));
2162 }
2163
2164 #[tokio::test]
2165 async fn test_registration_enrichment_from_index_malformed_json_returns_empty() {
2166 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2167 let enrichment = registry
2168 .registration_enrichment_from_index(b"not json", "https://api.nuget.org/v3/reg/")
2169 .await;
2170 assert!(enrichment.published.is_empty());
2171 assert!(enrichment.unlisted.is_empty());
2172 }
2173
2174 #[tokio::test]
2175 async fn test_registration_enrichment_from_index_foreign_origin_page_skipped_no_request() {
2176 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2180 let body = r#"{"count": 1, "items": [
2181 {"@id": "https://evil.example/pkg/page/0.json", "count": 1}
2182 ]}"#;
2183 let enrichment = registry
2184 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2185 .await;
2186 assert!(enrichment.published.is_empty());
2187 assert!(enrichment.unlisted.is_empty());
2188 }
2189
2190 #[tokio::test]
2191 async fn test_registration_enrichment_from_index_lookalike_origin_page_skipped_no_request() {
2192 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2195 let body = r#"{"count": 1, "items": [
2196 {"@id": "https://api.nuget.org.evil.test/v3/reg/pkg/page/0.json", "count": 1}
2197 ]}"#;
2198 let enrichment = registry
2199 .registration_enrichment_from_index(body.as_bytes(), "https://api.nuget.org/v3/reg/")
2200 .await;
2201 assert!(enrichment.published.is_empty());
2202 assert!(enrichment.unlisted.is_empty());
2203 }
2204
2205 #[tokio::test]
2208 async fn test_unlisted_versions_for_hover_reports_explicit_and_legacy_unlisted() {
2209 let mut server = mockito::Server::new_async().await;
2210 let base = server.url();
2211
2212 let _service_index_mock = server
2213 .mock("GET", "/index.json")
2214 .with_status(200)
2215 .with_body(service_index_body_with_registrations(
2216 &format!("{base}/flatcontainer"),
2217 &format!("{base}/query"),
2218 &format!("{base}/registrations"),
2219 ))
2220 .create_async()
2221 .await;
2222 let registration_body = inline_registration_index_with_listed(
2223 &format!("{base}/registrations"),
2224 &[
2225 ("1.0.0", Some("1900-01-01T00:00:00+00:00"), None),
2226 ("2.0.0", Some("2021-01-01T00:00:00Z"), Some(false)),
2227 ("3.0.0", Some("2022-01-01T00:00:00Z"), Some(true)),
2228 ],
2229 );
2230 let _reg_mock = server
2231 .mock("GET", "/registrations/widget/index.json")
2232 .with_status(200)
2233 .with_body(registration_body)
2234 .create_async()
2235 .await;
2236
2237 let registry = NuGetRegistry::with_service_index_url(
2238 Arc::new(HttpCache::new()),
2239 format!("{base}/index.json"),
2240 );
2241 let unlisted = registry
2242 .unlisted_versions_for_hover("widget")
2243 .await
2244 .unwrap();
2245
2246 assert!(unlisted.contains("1.0.0"));
2247 assert!(unlisted.contains("2.0.0"));
2248 assert!(!unlisted.contains("3.0.0"));
2249 }
2250
2251 #[tokio::test]
2252 async fn test_unlisted_versions_for_hover_no_registrations_base_url_degrades_to_empty() {
2253 let mut server = mockito::Server::new_async().await;
2254 let base = server.url();
2255
2256 let _service_index_mock = server
2257 .mock("GET", "/index.json")
2258 .with_status(200)
2259 .with_body(service_index_body(
2260 &format!("{base}/flatcontainer"),
2261 &format!("{base}/query"),
2262 ))
2263 .create_async()
2264 .await;
2265
2266 let registry = NuGetRegistry::with_service_index_url(
2267 Arc::new(HttpCache::new()),
2268 format!("{base}/index.json"),
2269 );
2270 let unlisted = registry
2271 .unlisted_versions_for_hover("widget")
2272 .await
2273 .unwrap();
2274 assert!(unlisted.is_empty());
2275 }
2276
2277 #[tokio::test]
2278 async fn test_unlisted_versions_for_hover_fetch_failure_degrades_to_empty() {
2279 let mut server = mockito::Server::new_async().await;
2280 let base = server.url();
2281
2282 let _service_index_mock = server
2283 .mock("GET", "/index.json")
2284 .with_status(200)
2285 .with_body(service_index_body_with_registrations(
2286 &format!("{base}/flatcontainer"),
2287 &format!("{base}/query"),
2288 &format!("{base}/registrations"),
2289 ))
2290 .create_async()
2291 .await;
2292 let _reg_mock = server
2293 .mock("GET", "/registrations/widget/index.json")
2294 .with_status(500)
2295 .create_async()
2296 .await;
2297
2298 let registry = NuGetRegistry::with_service_index_url(
2299 Arc::new(HttpCache::new()),
2300 format!("{base}/index.json"),
2301 );
2302 let unlisted = registry
2303 .unlisted_versions_for_hover("widget")
2304 .await
2305 .unwrap();
2306 assert!(unlisted.is_empty());
2307 }
2308
2309 #[tokio::test]
2310 async fn test_unlisted_versions_for_hover_rejects_bare_dot_dot_as_not_found() {
2311 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2312 let err = registry
2313 .unlisted_versions_for_hover("..")
2314 .await
2315 .unwrap_err();
2316 assert_matches!(err, deps_core::DepsError::PackageNotFound { .. });
2317 }
2318
2319 #[tokio::test]
2322 async fn test_get_versions_typed_with_disabled_issues_zero_registration_requests() {
2323 let mut server = mockito::Server::new_async().await;
2324 let base = server.url();
2325
2326 let _service_index_mock = server
2327 .mock("GET", "/index.json")
2328 .with_status(200)
2329 .with_body(service_index_body_with_registrations(
2330 &format!("{base}/flatcontainer"),
2331 &format!("{base}/query"),
2332 &format!("{base}/registrations"),
2333 ))
2334 .create_async()
2335 .await;
2336 let _flat_mock = server
2337 .mock("GET", "/flatcontainer/widget/index.json")
2338 .with_status(200)
2339 .with_body(r#"{"versions": ["1.0.0", "2.0.0"]}"#)
2340 .create_async()
2341 .await;
2342 let registry = NuGetRegistry::with_service_index_url(
2345 Arc::new(HttpCache::new()),
2346 format!("{base}/index.json"),
2347 );
2348
2349 let versions = registry
2350 .get_versions_typed_with("widget", false)
2351 .await
2352 .unwrap();
2353 assert_eq!(versions.len(), 2);
2354 assert!(versions.iter().all(|v| v.published_at.is_none()));
2355 }
2356
2357 #[tokio::test]
2358 async fn test_get_versions_typed_with_enabled_matches_disabled_set_and_order() {
2359 let mut server = mockito::Server::new_async().await;
2362 let base = server.url();
2363
2364 let _service_index_mock = server
2365 .mock("GET", "/index.json")
2366 .with_status(200)
2367 .with_body(service_index_body_with_registrations(
2368 &format!("{base}/flatcontainer"),
2369 &format!("{base}/query"),
2370 &format!("{base}/registrations"),
2371 ))
2372 .create_async()
2373 .await;
2374 let _flat_mock = server
2375 .mock("GET", "/flatcontainer/widget/index.json")
2376 .with_status(200)
2377 .with_body(r#"{"versions": ["1.0.0", "2.0.0"]}"#)
2378 .create_async()
2379 .await;
2380 let registration_body = inline_registration_index(
2381 &format!("{base}/registrations"),
2382 &[
2383 ("1.0.0", Some("2020-01-01T00:00:00Z")),
2384 ("2.0.0", Some("2021-01-01T00:00:00Z")),
2385 ],
2386 );
2387 let _reg_mock = server
2388 .mock("GET", "/registrations/widget/index.json")
2389 .with_status(200)
2390 .with_body(registration_body)
2391 .create_async()
2392 .await;
2393
2394 let registry = NuGetRegistry::with_service_index_url(
2395 Arc::new(HttpCache::new()),
2396 format!("{base}/index.json"),
2397 );
2398
2399 let disabled = registry
2400 .get_versions_typed_with("widget", false)
2401 .await
2402 .unwrap();
2403 let enabled = registry
2404 .get_versions_typed_with("widget", true)
2405 .await
2406 .unwrap();
2407
2408 let disabled_strings: Vec<&str> = disabled.iter().map(|v| v.version.as_str()).collect();
2409 let enabled_strings: Vec<&str> = enabled.iter().map(|v| v.version.as_str()).collect();
2410 assert_eq!(disabled_strings, enabled_strings);
2411 assert!(enabled.iter().all(|v| v.published_at.is_some()));
2412 }
2413
2414 #[tokio::test]
2415 async fn test_get_versions_typed_with_externalized_index_fetches_only_needed_pages() {
2416 let mut server = mockito::Server::new_async().await;
2417 let base = server.url();
2418 let reg_base = format!("{base}/registrations");
2419
2420 let _service_index_mock = server
2421 .mock("GET", "/index.json")
2422 .with_status(200)
2423 .with_body(service_index_body_with_registrations(
2424 &format!("{base}/flatcontainer"),
2425 &format!("{base}/query"),
2426 ®_base,
2427 ))
2428 .create_async()
2429 .await;
2430 let _flat_mock = server
2431 .mock("GET", "/flatcontainer/widget/index.json")
2432 .with_status(200)
2433 .with_body(r#"{"versions": ["9.0.0", "8.0.0", "7.0.0"]}"#)
2434 .create_async()
2435 .await;
2436
2437 let index_body = format!(
2440 r#"{{"count": 2, "items": [
2441 {{"@id": "{reg_base}/widget/page/0.json", "count": 5}},
2442 {{"@id": "{reg_base}/widget/page/1.json", "count": 5}}
2443 ]}}"#
2444 );
2445 let _reg_mock = server
2446 .mock("GET", "/registrations/widget/index.json")
2447 .with_status(200)
2448 .with_body(index_body)
2449 .create_async()
2450 .await;
2451
2452 let page1_body = r#"{"items": [
2455 {"catalogEntry": {"version": "2.0.0", "published": "2015-01-01T00:00:00Z"}},
2456 {"catalogEntry": {"version": "3.0.0", "published": "2016-01-01T00:00:00Z"}},
2457 {"catalogEntry": {"version": "4.0.0", "published": "2017-01-01T00:00:00Z"}},
2458 {"catalogEntry": {"version": "5.0.0", "published": "2018-01-01T00:00:00Z"}},
2459 {"catalogEntry": {"version": "6.0.0", "published": "2019-01-01T00:00:00Z"}},
2460 {"catalogEntry": {"version": "7.0.0", "published": "2020-01-01T00:00:00Z"}},
2461 {"catalogEntry": {"version": "8.0.0", "published": "2021-01-01T00:00:00Z"}},
2462 {"catalogEntry": {"version": "9.0.0", "published": "2022-01-01T00:00:00Z"}}
2463 ]}"#;
2464 let page1_mock = server
2465 .mock("GET", "/registrations/widget/page/1.json")
2466 .with_status(200)
2467 .with_body(page1_body)
2468 .expect(1)
2469 .create_async()
2470 .await;
2471 let page0_mock = server
2472 .mock("GET", "/registrations/widget/page/0.json")
2473 .with_status(200)
2474 .with_body(r#"{"items": []}"#)
2475 .expect(0)
2476 .create_async()
2477 .await;
2478
2479 let registry = NuGetRegistry::with_service_index_url(
2480 Arc::new(HttpCache::new()),
2481 format!("{base}/index.json"),
2482 );
2483 let versions = registry
2484 .get_versions_typed_with("widget", true)
2485 .await
2486 .unwrap();
2487
2488 assert_eq!(versions.len(), 3);
2489 assert!(versions.iter().all(|v| v.published_at.is_some()));
2490 page1_mock.assert_async().await;
2491 page0_mock.assert_async().await;
2492 }
2493
2494 #[tokio::test]
2495 async fn test_get_versions_typed_with_external_fetch_cap_stops_walk_at_two_pages() {
2496 let mut server = mockito::Server::new_async().await;
2502 let base = server.url();
2503 let reg_base = format!("{base}/registrations");
2504
2505 let _service_index_mock = server
2506 .mock("GET", "/index.json")
2507 .with_status(200)
2508 .with_body(service_index_body_with_registrations(
2509 &format!("{base}/flatcontainer"),
2510 &format!("{base}/query"),
2511 ®_base,
2512 ))
2513 .create_async()
2514 .await;
2515 let _flat_mock = server
2516 .mock("GET", "/flatcontainer/widget/index.json")
2517 .with_status(200)
2518 .with_body(r#"{"versions": ["6.0.0", "5.0.0", "4.0.0", "3.0.0", "2.0.0", "1.0.0"]}"#)
2519 .create_async()
2520 .await;
2521
2522 let index_body = format!(
2523 r#"{{"count": 3, "items": [
2524 {{"@id": "{reg_base}/widget/page/0.json", "count": 2}},
2525 {{"@id": "{reg_base}/widget/page/1.json", "count": 2}},
2526 {{"@id": "{reg_base}/widget/page/2.json", "count": 2}}
2527 ]}}"#
2528 );
2529 let _reg_mock = server
2530 .mock("GET", "/registrations/widget/index.json")
2531 .with_status(200)
2532 .with_body(index_body)
2533 .create_async()
2534 .await;
2535
2536 let page2_body = r#"{"items": [
2537 {"catalogEntry": {"version": "5.0.0", "published": "2021-01-01T00:00:00Z"}},
2538 {"catalogEntry": {"version": "6.0.0", "published": "2022-01-01T00:00:00Z"}}
2539 ]}"#;
2540 let page2_mock = server
2541 .mock("GET", "/registrations/widget/page/2.json")
2542 .with_status(200)
2543 .with_body(page2_body)
2544 .expect(1)
2545 .create_async()
2546 .await;
2547
2548 let page1_body = r#"{"items": [
2549 {"catalogEntry": {"version": "3.0.0", "published": "2019-01-01T00:00:00Z"}},
2550 {"catalogEntry": {"version": "4.0.0", "published": "2020-01-01T00:00:00Z"}}
2551 ]}"#;
2552 let page1_mock = server
2553 .mock("GET", "/registrations/widget/page/1.json")
2554 .with_status(200)
2555 .with_body(page1_body)
2556 .expect(1)
2557 .create_async()
2558 .await;
2559
2560 let page0_mock = server
2563 .mock("GET", "/registrations/widget/page/0.json")
2564 .with_status(200)
2565 .with_body(
2566 r#"{"items": [
2567 {"catalogEntry": {"version": "1.0.0", "published": "2017-01-01T00:00:00Z"}},
2568 {"catalogEntry": {"version": "2.0.0", "published": "2018-01-01T00:00:00Z"}}
2569 ]}"#,
2570 )
2571 .expect(0)
2572 .create_async()
2573 .await;
2574
2575 let registry = NuGetRegistry::with_service_index_url(
2576 Arc::new(HttpCache::new()),
2577 format!("{base}/index.json"),
2578 );
2579 let versions = registry
2580 .get_versions_typed_with("widget", true)
2581 .await
2582 .unwrap();
2583
2584 assert_eq!(versions.len(), 6);
2585 for v in ["3.0.0", "4.0.0", "5.0.0", "6.0.0"] {
2587 assert!(
2588 versions
2589 .iter()
2590 .find(|ver| ver.version == v)
2591 .unwrap()
2592 .published_at
2593 .is_some(),
2594 "{v} should have a published_at"
2595 );
2596 }
2597 for v in ["1.0.0", "2.0.0"] {
2598 assert!(
2599 versions
2600 .iter()
2601 .find(|ver| ver.version == v)
2602 .unwrap()
2603 .published_at
2604 .is_none(),
2605 "{v} is beyond the external-fetch cap and must have no published_at"
2606 );
2607 }
2608 page2_mock.assert_async().await;
2609 page1_mock.assert_async().await;
2610 page0_mock.assert_async().await;
2611 }
2612
2613 #[tokio::test]
2614 async fn test_get_versions_typed_with_single_version_package_terminates() {
2615 let mut server = mockito::Server::new_async().await;
2618 let base = server.url();
2619
2620 let _service_index_mock = server
2621 .mock("GET", "/index.json")
2622 .with_status(200)
2623 .with_body(service_index_body_with_registrations(
2624 &format!("{base}/flatcontainer"),
2625 &format!("{base}/query"),
2626 &format!("{base}/registrations"),
2627 ))
2628 .create_async()
2629 .await;
2630 let _flat_mock = server
2631 .mock("GET", "/flatcontainer/orchard.core/index.json")
2632 .with_status(200)
2633 .with_body(r#"{"versions": ["1.0.0"]}"#)
2634 .create_async()
2635 .await;
2636 let registration_body = inline_registration_index(
2637 &format!("{base}/registrations"),
2638 &[("1.0.0", Some("2020-01-01T00:00:00Z"))],
2639 );
2640 let _reg_mock = server
2641 .mock("GET", "/registrations/orchard.core/index.json")
2642 .with_status(200)
2643 .with_body(registration_body)
2644 .create_async()
2645 .await;
2646
2647 let registry = NuGetRegistry::with_service_index_url(
2648 Arc::new(HttpCache::new()),
2649 format!("{base}/index.json"),
2650 );
2651 let versions = registry
2652 .get_versions_typed_with("orchard.core", true)
2653 .await
2654 .unwrap();
2655
2656 assert_eq!(versions.len(), 1);
2657 assert!(versions[0].published_at.is_some());
2658 }
2659
2660 #[tokio::test]
2661 async fn test_get_versions_typed_with_no_registrations_base_url_degrades_gracefully() {
2662 let mut server = mockito::Server::new_async().await;
2663 let base = server.url();
2664
2665 let _service_index_mock = server
2666 .mock("GET", "/index.json")
2667 .with_status(200)
2668 .with_body(service_index_body(
2669 &format!("{base}/flatcontainer"),
2670 &format!("{base}/query"),
2671 ))
2672 .create_async()
2673 .await;
2674 let _flat_mock = server
2675 .mock("GET", "/flatcontainer/widget/index.json")
2676 .with_status(200)
2677 .with_body(r#"{"versions": ["1.0.0"]}"#)
2678 .create_async()
2679 .await;
2680
2681 let registry = NuGetRegistry::with_service_index_url(
2682 Arc::new(HttpCache::new()),
2683 format!("{base}/index.json"),
2684 );
2685 let versions = registry
2686 .get_versions_typed_with("widget", true)
2687 .await
2688 .unwrap();
2689
2690 assert_eq!(versions.len(), 1);
2691 assert!(versions[0].published_at.is_none());
2692 }
2693
2694 #[tokio::test]
2697 #[ignore]
2698 async fn test_live_nuget_attaches_publish_times() {
2699 let registry = NuGetRegistry::new(Arc::new(HttpCache::new()));
2700 let versions = registry
2701 .get_versions_typed_with("Newtonsoft.Json", true)
2702 .await
2703 .unwrap();
2704
2705 assert!(!versions.is_empty());
2706 assert!(versions.iter().take(5).any(|v| v.published_at.is_some()));
2707 }
2708
2709 fn all_policy() -> Arc<RegistryAccessPolicy> {
2712 Arc::new(RegistryAccessPolicy::new(
2713 deps_core::net_policy::WorkspaceRegistryAccess::All,
2714 ))
2715 }
2716
2717 fn hop(feed: &NuGetFeedUrl) -> ResolvedHop {
2720 ResolvedHop {
2721 url: feed.clone(),
2722 slot: None,
2723 auth: None,
2724 }
2725 }
2726
2727 fn workspace_client(base: &str, policy: &Arc<RegistryAccessPolicy>) -> NuGetRegistry {
2728 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), policy).unwrap();
2729 NuGetRegistry::with_base(
2730 Arc::new(HttpCache::new()),
2731 &hop(&feed),
2732 Arc::clone(policy),
2733 Vec::new(),
2734 )
2735 }
2736
2737 #[tokio::test]
2738 async fn test_get_versions_chained_falls_through_on_package_not_found() {
2739 let mut hop0 = mockito::Server::new_async().await;
2740 let hop0_index = hop0
2741 .mock("GET", "/index.json")
2742 .with_status(200)
2743 .with_body(service_index_body(
2744 &format!("{}/flat", hop0.url()),
2745 &format!("{}/search", hop0.url()),
2746 ))
2747 .create_async()
2748 .await;
2749 let hop0_flat = hop0
2750 .mock("GET", "/flat/pkg/index.json")
2751 .with_status(404)
2752 .create_async()
2753 .await;
2754
2755 let mut hop1 = mockito::Server::new_async().await;
2756 let hop1_index = hop1
2757 .mock("GET", "/index.json")
2758 .with_status(200)
2759 .with_body(service_index_body(
2760 &format!("{}/flat", hop1.url()),
2761 &format!("{}/search", hop1.url()),
2762 ))
2763 .create_async()
2764 .await;
2765 let hop1_flat = hop1
2766 .mock("GET", "/flat/pkg/index.json")
2767 .with_status(200)
2768 .with_body(r#"{"versions": ["2.0.0"]}"#)
2769 .create_async()
2770 .await;
2771
2772 let policy = all_policy();
2773 let cache = Arc::new(HttpCache::new());
2774 let hop1_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop1.url()), &policy).unwrap();
2775 let hop1_client = Arc::new(NuGetRegistry::with_base(
2776 Arc::clone(&cache),
2777 &hop(&hop1_feed),
2778 Arc::clone(&policy),
2779 Vec::new(),
2780 ));
2781 let hop0_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2782 let head = NuGetRegistry::with_base(
2783 cache,
2784 &hop(&hop0_feed),
2785 Arc::clone(&policy),
2786 vec![hop1_client],
2787 );
2788
2789 let versions = head.get_versions_chained("pkg").await.unwrap();
2790 assert_eq!(versions.len(), 1);
2791 assert_eq!(versions[0].version.as_str(), "2.0.0");
2792
2793 hop0_index.assert_async().await;
2794 hop0_flat.assert_async().await;
2795 hop1_index.assert_async().await;
2796 hop1_flat.assert_async().await;
2797 }
2798
2799 #[tokio::test]
2800 async fn test_get_versions_chained_falls_through_on_empty_listing() {
2801 let mut hop0 = mockito::Server::new_async().await;
2802 hop0.mock("GET", "/index.json")
2803 .with_status(200)
2804 .with_body(service_index_body(
2805 &format!("{}/flat", hop0.url()),
2806 &format!("{}/search", hop0.url()),
2807 ))
2808 .create_async()
2809 .await;
2810 hop0.mock("GET", "/flat/pkg/index.json")
2811 .with_status(200)
2812 .with_body(r#"{"versions": []}"#)
2813 .create_async()
2814 .await;
2815
2816 let mut hop1 = mockito::Server::new_async().await;
2817 hop1.mock("GET", "/index.json")
2818 .with_status(200)
2819 .with_body(service_index_body(
2820 &format!("{}/flat", hop1.url()),
2821 &format!("{}/search", hop1.url()),
2822 ))
2823 .create_async()
2824 .await;
2825 hop1.mock("GET", "/flat/pkg/index.json")
2826 .with_status(200)
2827 .with_body(r#"{"versions": ["3.0.0"]}"#)
2828 .create_async()
2829 .await;
2830
2831 let policy = all_policy();
2832 let cache = Arc::new(HttpCache::new());
2833 let hop1_client = Arc::new(workspace_client(&hop1.url(), &policy));
2834 let head = {
2835 let feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2836 NuGetRegistry::with_base(cache, &hop(&feed), Arc::clone(&policy), vec![hop1_client])
2837 };
2838
2839 let versions = head.get_versions_chained("pkg").await.unwrap();
2840 assert_eq!(versions.len(), 1);
2841 assert_eq!(versions[0].version.as_str(), "3.0.0");
2842 }
2843
2844 #[tokio::test]
2847 async fn test_get_versions_chained_terminates_on_transport_error_never_tries_next_hop() {
2848 let mut hop0 = mockito::Server::new_async().await;
2849 hop0.mock("GET", "/index.json")
2850 .with_status(200)
2851 .with_body(service_index_body(
2852 &format!("{}/flat", hop0.url()),
2853 &format!("{}/search", hop0.url()),
2854 ))
2855 .create_async()
2856 .await;
2857 hop0.mock("GET", "/flat/pkg/index.json")
2858 .with_status(503)
2859 .create_async()
2860 .await;
2861
2862 let mut hop1 = mockito::Server::new_async().await;
2863 let hop1_flat = hop1
2864 .mock("GET", "/flat/pkg/index.json")
2865 .expect(0)
2866 .create_async()
2867 .await;
2868
2869 let policy = all_policy();
2870 let cache = Arc::new(HttpCache::new());
2871 let hop1_client = Arc::new(workspace_client(&hop1.url(), &policy));
2872 let head = {
2873 let feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2874 NuGetRegistry::with_base(cache, &hop(&feed), Arc::clone(&policy), vec![hop1_client])
2875 };
2876
2877 let err = head.get_versions_chained("pkg").await.unwrap_err();
2878 assert!(
2879 matches!(err, DepsError::ChainResolutionHalted),
2880 "expected ChainResolutionHalted, got: {err:?}"
2881 );
2882 hop1_flat.assert_async().await;
2883 }
2884
2885 #[tokio::test]
2892 async fn test_register_chain_multi_hop_fallback_chain_walks_every_hop_in_order() {
2893 let mut hop0 = mockito::Server::new_async().await;
2894 hop0.mock("GET", "/index.json")
2895 .with_status(200)
2896 .with_body(service_index_body(
2897 &format!("{}/flat", hop0.url()),
2898 &format!("{}/search", hop0.url()),
2899 ))
2900 .create_async()
2901 .await;
2902 hop0.mock("GET", "/flat/pkg/index.json")
2903 .with_status(404)
2904 .create_async()
2905 .await;
2906
2907 let mut hop1 = mockito::Server::new_async().await;
2908 hop1.mock("GET", "/index.json")
2909 .with_status(200)
2910 .with_body(service_index_body(
2911 &format!("{}/flat", hop1.url()),
2912 &format!("{}/search", hop1.url()),
2913 ))
2914 .create_async()
2915 .await;
2916 hop1.mock("GET", "/flat/pkg/index.json")
2917 .with_status(404)
2918 .create_async()
2919 .await;
2920
2921 let mut public = mockito::Server::new_async().await;
2922 public
2923 .mock("GET", "/index.json")
2924 .with_status(200)
2925 .with_body(service_index_body(
2926 &format!("{}/flat", public.url()),
2927 &format!("{}/search", public.url()),
2928 ))
2929 .create_async()
2930 .await;
2931 public
2932 .mock("GET", "/flat/pkg/index.json")
2933 .with_status(200)
2934 .with_body(r#"{"versions": ["9.0.0"]}"#)
2935 .create_async()
2936 .await;
2937
2938 let policy = all_policy();
2939 let cache = Arc::new(HttpCache::new());
2940 let root = Arc::new(NuGetRegistry::with_service_index_url(
2941 Arc::clone(&cache),
2942 format!("{}/index.json", public.url()),
2943 ));
2944
2945 let hop0_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop0.url()), &policy).unwrap();
2946 let hop1_feed = NuGetFeedUrl::new(&format!("{}/index.json", hop1.url()), &policy).unwrap();
2947 let chain = NuGetSourceChain {
2948 key: "nuget-chain:test-multi-hop".to_string(),
2949 hops: vec![hop(&hop0_feed), hop(&hop1_feed)],
2950 implicit_public_fallback: true,
2951 };
2952 NuGetRegistry::register_chain(&root, &chain, &policy);
2953
2954 let client = root
2955 .alternate_client(&chain.key)
2956 .expect("chain must be registered");
2957 assert_eq!(
2958 client.fallback_chain.len(),
2959 2,
2960 "expected [hop1, implicit-public] in the head's fallback_chain"
2961 );
2962
2963 let versions = client.get_versions_chained("pkg").await.unwrap();
2964 assert_eq!(versions.len(), 1);
2965 assert_eq!(versions[0].version.as_str(), "9.0.0");
2966 }
2967
2968 fn auth_hop(feed: &NuGetFeedUrl, slot: &str, username: &str, password: &str) -> ResolvedHop {
2971 ResolvedHop {
2972 url: feed.clone(),
2973 slot: Some(slot.to_string()),
2974 auth: Some(NuGetAuth::new(username, password)),
2975 }
2976 }
2977
2978 #[tokio::test]
2982 async fn test_fetch_attaches_credential_on_service_index_and_flat_container() {
2983 let mut server = mockito::Server::new_async().await;
2984 let base = server.url();
2985 let auth = NuGetAuth::new("user", "pat");
2987
2988 let _index = server
2989 .mock("GET", "/index.json")
2990 .match_header("authorization", auth.header_value())
2991 .with_status(200)
2992 .with_body(service_index_body(
2993 &format!("{base}/flat"),
2994 &format!("{base}/search"),
2995 ))
2996 .create_async()
2997 .await;
2998 let _flat = server
2999 .mock("GET", "/flat/pkg/index.json")
3000 .match_header("authorization", auth.header_value())
3001 .with_status(200)
3002 .with_body(r#"{"versions": ["1.0.0"]}"#)
3003 .create_async()
3004 .await;
3005
3006 let policy = all_policy();
3007 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3008 let client = NuGetRegistry::with_base(
3009 Arc::new(HttpCache::new()),
3010 &auth_hop(&feed, "corpfeed", "user", "pat"),
3012 Arc::clone(&policy),
3013 Vec::new(),
3014 );
3015
3016 let versions = client.get_versions_typed("pkg").await.unwrap();
3017 assert_eq!(versions.len(), 1);
3018 _index.assert_async().await;
3019 _flat.assert_async().await;
3020 }
3021
3022 #[tokio::test]
3026 async fn test_fetch_attaches_credential_on_search_query_service() {
3027 let mut server = mockito::Server::new_async().await;
3028 let base = server.url();
3029 let auth = NuGetAuth::new("user", "pat");
3031
3032 let _index = server
3033 .mock("GET", "/index.json")
3034 .with_status(200)
3035 .with_body(service_index_body(
3036 &format!("{base}/flat"),
3037 &format!("{base}/search"),
3038 ))
3039 .create_async()
3040 .await;
3041 let _search = server
3042 .mock("GET", "/search")
3043 .match_query(mockito::Matcher::Any)
3044 .match_header("authorization", auth.header_value())
3045 .with_status(200)
3046 .with_body(r#"{"data": []}"#)
3047 .create_async()
3048 .await;
3049
3050 let policy = all_policy();
3051 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3052 let client = NuGetRegistry::with_base(
3053 Arc::new(HttpCache::new()),
3054 &auth_hop(&feed, "corpfeed", "user", "pat"),
3056 Arc::clone(&policy),
3057 Vec::new(),
3058 );
3059
3060 let results = client.search_typed("query", 10).await.unwrap();
3061 assert!(results.is_empty());
3062 _search.assert_async().await;
3063 }
3064
3065 #[tokio::test]
3072 async fn test_fetch_attaches_credential_on_registration_hive() {
3073 let mut server = mockito::Server::new_async().await;
3074 let base = server.url();
3075 let auth = NuGetAuth::new("user", "pat");
3077 let reg_base = format!("{base}/registrations");
3078
3079 let _index = server
3080 .mock("GET", "/index.json")
3081 .match_header("authorization", auth.header_value())
3082 .with_status(200)
3083 .with_body(service_index_body_with_registrations(
3084 &format!("{base}/flat"),
3085 &format!("{base}/search"),
3086 ®_base,
3087 ))
3088 .create_async()
3089 .await;
3090 let _flat = server
3091 .mock("GET", "/flat/pkg/index.json")
3092 .match_header("authorization", auth.header_value())
3093 .with_status(200)
3094 .with_body(r#"{"versions": ["1.0.0"]}"#)
3095 .create_async()
3096 .await;
3097 let index_body = format!(
3098 r#"{{"count": 1, "items": [{{"@id": "{reg_base}/pkg/page/0.json", "count": 1}}]}}"#
3099 );
3100 let _reg = server
3101 .mock("GET", "/registrations/pkg/index.json")
3102 .match_header("authorization", auth.header_value())
3103 .with_status(200)
3104 .with_body(index_body)
3105 .create_async()
3106 .await;
3107 let _reg_page = server
3108 .mock("GET", "/registrations/pkg/page/0.json")
3109 .match_header("authorization", auth.header_value())
3110 .with_status(200)
3111 .with_body(
3112 r#"{"items": [{"catalogEntry": {"version": "1.0.0", "published": "2020-01-01T00:00:00Z"}}]}"#,
3113 )
3114 .create_async()
3115 .await;
3116
3117 let policy = all_policy();
3118 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3119 let client = NuGetRegistry::with_base(
3120 Arc::new(HttpCache::new()),
3121 &auth_hop(&feed, "corpfeed", "user", "pat"),
3123 Arc::clone(&policy),
3124 Vec::new(),
3125 );
3126
3127 let versions = client.get_versions_typed_with("pkg", true).await.unwrap();
3128 assert_eq!(versions.len(), 1);
3129 assert!(versions[0].published_at.is_some());
3130 _index.assert_async().await;
3131 _flat.assert_async().await;
3132 _reg.assert_async().await;
3133 _reg_page.assert_async().await;
3134 }
3135
3136 #[tokio::test]
3140 async fn test_fetch_attaches_credential_on_unlisted_versions_for_hover() {
3141 let mut server = mockito::Server::new_async().await;
3142 let base = server.url();
3143 let auth = NuGetAuth::new("user", "pat");
3145 let reg_base = format!("{base}/registrations");
3146
3147 let _index = server
3148 .mock("GET", "/index.json")
3149 .match_header("authorization", auth.header_value())
3150 .with_status(200)
3151 .with_body(service_index_body_with_registrations(
3152 &format!("{base}/flat"),
3153 &format!("{base}/search"),
3154 ®_base,
3155 ))
3156 .create_async()
3157 .await;
3158 let _reg = server
3159 .mock("GET", "/registrations/pkg/index.json")
3160 .match_header("authorization", auth.header_value())
3161 .with_status(200)
3162 .with_body(r#"{"count": 0, "items": []}"#)
3163 .create_async()
3164 .await;
3165
3166 let policy = all_policy();
3167 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3168 let client = NuGetRegistry::with_base(
3169 Arc::new(HttpCache::new()),
3170 &auth_hop(&feed, "corpfeed", "user", "pat"),
3172 Arc::clone(&policy),
3173 Vec::new(),
3174 );
3175
3176 let unlisted = client.unlisted_versions_for_hover("pkg").await.unwrap();
3177 assert!(unlisted.is_empty());
3178 _index.assert_async().await;
3179 _reg.assert_async().await;
3180 }
3181
3182 #[tokio::test]
3187 async fn test_fetch_withholds_credential_when_resolved_resource_is_off_origin() {
3188 let mut declared = mockito::Server::new_async().await;
3189 let mut attacker = mockito::Server::new_async().await;
3190 let attacker_base = attacker.url();
3191
3192 let _index = declared
3193 .mock("GET", "/index.json")
3194 .with_status(200)
3195 .with_body(service_index_body(
3196 &format!("{attacker_base}/flat"),
3197 &format!("{attacker_base}/search"),
3198 ))
3199 .create_async()
3200 .await;
3201 let _attacker_flat = attacker
3202 .mock("GET", "/flat/pkg/index.json")
3203 .match_header("authorization", mockito::Matcher::Missing)
3204 .with_status(200)
3205 .with_body(r#"{"versions": ["1.0.0"]}"#)
3206 .create_async()
3207 .await;
3208
3209 let policy = all_policy();
3210 let feed = NuGetFeedUrl::new(&format!("{}/index.json", declared.url()), &policy).unwrap();
3211 let client = NuGetRegistry::with_base(
3212 Arc::new(HttpCache::new()),
3213 &auth_hop(&feed, "corpfeed", "user", "pat"),
3215 Arc::clone(&policy),
3216 Vec::new(),
3217 );
3218
3219 let versions = client.get_versions_typed("pkg").await.unwrap();
3220 assert_eq!(versions.len(), 1);
3221 _index.assert_async().await;
3222 _attacker_flat.assert_async().await;
3223 }
3224
3225 #[tokio::test]
3229 async fn test_register_chain_credential_rotation_at_capacity_replaces_in_place() {
3230 let mut server = mockito::Server::new_async().await;
3231 let base = server.url();
3232 let auth_v2 = NuGetAuth::new("user", "pat-v2");
3234
3235 let _index = server
3236 .mock("GET", "/index.json")
3237 .match_header("authorization", auth_v2.header_value())
3238 .with_status(200)
3239 .with_body(service_index_body(
3240 &format!("{base}/flat"),
3241 &format!("{base}/search"),
3242 ))
3243 .create_async()
3244 .await;
3245 let _flat = server
3246 .mock("GET", "/flat/pkg/index.json")
3247 .match_header("authorization", auth_v2.header_value())
3248 .with_status(200)
3249 .with_body(r#"{"versions": ["2.0.0"]}"#)
3250 .create_async()
3251 .await;
3252
3253 let policy = all_policy();
3254 let cache = Arc::new(HttpCache::new());
3255 let root = Arc::new(NuGetRegistry::with_service_index_url(
3256 Arc::clone(&cache),
3257 NUGET_ORG_INDEX_URL.to_string(),
3258 ));
3259 let feed = NuGetFeedUrl::new(&format!("{base}/index.json"), &policy).unwrap();
3260 let chain_key = "nuget-chain:rotation-test".to_string();
3261
3262 for i in 0..MAX_ALTERNATE_REGISTRIES - 1 {
3266 let dummy = NuGetRegistry::with_base(
3267 Arc::clone(&cache),
3268 &hop(&feed),
3269 Arc::clone(&policy),
3270 Vec::new(),
3271 );
3272 root.alternates
3273 .insert(format!("dummy-{i}"), Arc::new(dummy));
3274 }
3275 assert_eq!(root.alternates.len(), MAX_ALTERNATE_REGISTRIES - 1);
3276
3277 let chain_v1 = NuGetSourceChain {
3278 key: chain_key.clone(),
3279 hops: vec![auth_hop(&feed, "corpfeed", "user", "pat-v1")],
3281 implicit_public_fallback: false,
3282 };
3283 NuGetRegistry::register_chain(&root, &chain_v1, &policy);
3284 assert_eq!(
3285 root.alternates.len(),
3286 MAX_ALTERNATE_REGISTRIES,
3287 "the vacant-slot arm must still be allowed to insert its own new key up to the cap"
3288 );
3289
3290 let chain_v2 = NuGetSourceChain {
3291 key: chain_key.clone(),
3292 hops: vec![auth_hop(&feed, "corpfeed", "user", "pat-v2")],
3294 implicit_public_fallback: false,
3295 };
3296 NuGetRegistry::register_chain(&root, &chain_v2, &policy);
3297 assert_eq!(
3298 root.alternates.len(),
3299 MAX_ALTERNATE_REGISTRIES,
3300 "the replace arm must not grow the map, and must not be blocked by the cap either"
3301 );
3302
3303 let client = root
3304 .alternate_client(&chain_key)
3305 .expect("chain must remain registered after rotation");
3306 let versions = client.get_versions_chained("pkg").await.unwrap();
3307 assert_eq!(versions[0].version.as_str(), "2.0.0");
3308 _index.assert_async().await;
3309 _flat.assert_async().await;
3310 }
3311}