1use std::collections::HashSet;
43use std::hash::{Hash, Hasher};
44use std::path::{Path, PathBuf};
45use std::sync::Arc;
46use std::sync::atomic::AtomicBool;
47
48use base64::Engine;
49use deps_core::PackageName;
50use deps_core::fs_probe::MAX_CONFIG_ANCESTOR_DEPTH;
51use deps_core::net_policy::{
52 IndexUrlError, PolicyGate, RegistryAccessPolicy, redact_userinfo, validate_index_url,
53};
54use deps_core::parser::DependencySource;
55use quick_xml::Reader;
56use quick_xml::events::Event;
57use zeroize::Zeroizing;
58
59const CONFIG_FILENAMES: &[&str] = &["NuGet.Config", "nuget.config", "NuGet.config"];
62
63const NO_SOURCES_CONFIGURED_SENTINEL: &str = "<clear/> removed every NuGet package source";
69
70#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
73pub enum NuGetFeedUrlError {
74 #[error("not a valid URL: {0}")]
76 InvalidUrl(String),
77 #[error("registry feed must use https, got scheme {0:?}")]
79 NotHttps(String),
80 #[error("registry feed URL must not carry userinfo")]
82 UserInfoPresent,
83 #[error("registry feed host class {class} blocked by registries.workspace_registries policy")]
86 BlockedHost {
87 class: deps_core::net_policy::HostClass,
89 },
90 #[error("source has packageSourceCredentials configured; credentials are never read")]
93 HasCredentials,
94 #[error("source is disabled via disabledPackageSources")]
96 Disabled,
97 #[error("unsupported NuGet protocolVersion {0:?}; only V3 feeds are supported")]
99 UnsupportedProtocolVersion(String),
100 #[error("local/UNC feed paths are not supported")]
104 LocalFeedUnsupported,
105 #[error("DPAPI-encrypted <Password> credentials are not supported")]
109 EncryptedPasswordUnsupported,
110}
111
112impl From<IndexUrlError> for NuGetFeedUrlError {
113 fn from(error: IndexUrlError) -> Self {
114 match error {
115 IndexUrlError::InvalidUrl(raw) => Self::InvalidUrl(raw),
116 IndexUrlError::NotHttps(scheme) => Self::NotHttps(scheme),
117 IndexUrlError::UserInfoPresent => Self::UserInfoPresent,
118 IndexUrlError::BlockedHost { class } => Self::BlockedHost { class },
119 }
120 }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq, Hash)]
127pub struct NuGetFeedUrl {
128 normalized: String,
130}
131
132impl NuGetFeedUrl {
133 pub fn new(raw: &str, policy: &RegistryAccessPolicy) -> Result<Self, NuGetFeedUrlError> {
141 let url = validate_index_url(raw, raw, "nuget", PolicyGate::Enforce(policy))?;
142 Ok(Self {
143 normalized: url.as_str().trim_end_matches('/').to_string(),
144 })
145 }
146
147 #[must_use]
149 pub fn as_str(&self) -> &str {
150 &self.normalized
151 }
152
153 fn trusted_public() -> Self {
161 Self {
162 normalized: crate::registry::NUGET_ORG_INDEX_URL.to_string(),
163 }
164 }
165}
166
167impl std::fmt::Display for NuGetFeedUrl {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 f.write_str(self.as_str())
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
181pub enum ConfigTier {
182 UserProfile,
185 Repo,
188}
189
190#[derive(Clone, PartialEq, Eq)]
206pub struct NuGetAuth(deps_core::secret::Redacted);
207
208impl NuGetAuth {
209 pub(crate) fn new(username: &str, password: &str) -> Self {
216 let mut user_pass =
217 Zeroizing::new(String::with_capacity(username.len() + 1 + password.len()));
218 user_pass.push_str(username);
219 user_pass.push(':');
220 user_pass.push_str(password);
221 let encoded = Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(&*user_pass));
222 Self(deps_core::secret::Redacted::new(format!(
223 "Basic {}",
224 *encoded
225 )))
226 }
227
228 pub(crate) fn header_value(&self) -> &str {
231 self.0.expose_secret()
232 }
233}
234
235impl std::fmt::Debug for NuGetAuth {
236 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237 f.write_str("NuGetAuth(***)")
238 }
239}
240
241impl std::fmt::Display for NuGetAuth {
242 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243 f.write_str("***")
244 }
245}
246
247#[derive(Clone, PartialEq, Eq, Hash)]
265struct RedactedSecret(deps_core::secret::Redacted);
266
267impl RedactedSecret {
268 fn new(value: String) -> Self {
269 Self(deps_core::secret::Redacted::new(value))
270 }
271
272 fn expose_secret(&self) -> &str {
273 self.0.expose_secret()
274 }
275}
276
277impl std::fmt::Debug for RedactedSecret {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 f.write_str("RedactedSecret(***)")
280 }
281}
282
283impl std::fmt::Display for RedactedSecret {
284 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285 f.write_str("***")
286 }
287}
288
289#[derive(Debug, Clone)]
292pub struct InvalidEntry {
293 pub raw: String,
297 pub reason: NuGetFeedUrlError,
299}
300
301#[derive(Debug, Clone)]
304pub struct PackageSourceEntry {
305 pub key: String,
306 pub value: Result<NuGetFeedUrl, InvalidEntry>,
307 pub tier: ConfigTier,
310 pub auth: Option<NuGetAuth>,
313}
314
315#[derive(Debug, Clone)]
323pub struct ResolvedHop {
324 pub url: NuGetFeedUrl,
325 pub slot: Option<String>,
331 pub auth: Option<NuGetAuth>,
334}
335
336impl ResolvedHop {
337 fn slot_key_parts(&self) -> [&str; 2] {
348 self.slot
349 .as_deref()
350 .map_or(["no-slot", ""], |slot| ["slot", slot])
351 }
352}
353
354#[derive(Debug, Clone)]
357pub struct NuGetSourceChain {
358 pub key: String,
363 pub hops: Vec<ResolvedHop>,
365 pub implicit_public_fallback: bool,
372}
373
374impl NuGetSourceChain {
375 fn chain(hops: Vec<ResolvedHop>, implicit_public_fallback: bool) -> Self {
376 let flag = if implicit_public_fallback {
377 "true"
378 } else {
379 "false"
380 };
381 let key = deps_core::hash_routing_key(
382 "nuget-chain",
383 hops.iter()
384 .flat_map(|hop| {
385 let [presence, slot] = hop.slot_key_parts();
386 [hop.url.as_str(), presence, slot]
387 })
388 .chain(std::iter::once(flag)),
389 );
390 Self {
391 key,
392 hops,
393 implicit_public_fallback,
394 }
395 }
396}
397
398#[derive(Debug, Clone, Default)]
401struct PackageSourceMapping {
402 patterns: Vec<(String, Vec<String>)>,
403}
404
405#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
409enum MatchScore {
410 Wildcard,
411 Prefix(usize),
412 Exact(usize),
413}
414
415impl PackageSourceMapping {
416 fn is_empty(&self) -> bool {
417 self.patterns.is_empty()
418 }
419
420 fn extend(&mut self, source_key: &str, patterns: &[String]) {
424 for pattern in patterns {
425 let normalized_pattern = pattern.to_lowercase();
426 if let Some((_, keys)) = self
427 .patterns
428 .iter_mut()
429 .find(|(p, _)| *p == normalized_pattern)
430 {
431 let dup = keys.iter().any(|k| key_candidates_overlap(k, source_key));
432 if !dup {
433 keys.push(source_key.to_string());
434 }
435 } else {
436 self.patterns
437 .push((normalized_pattern, vec![source_key.to_string()]));
438 }
439 }
440 }
441
442 fn resolve_keys_for(&self, name_lower: &str) -> Option<Vec<&str>> {
448 let mut best: Option<MatchScore> = None;
449 let mut winners: Vec<&(String, Vec<String>)> = Vec::new();
450
451 for entry @ (pattern, _) in &self.patterns {
452 let score = if pattern == "*" {
453 Some(MatchScore::Wildcard)
454 } else if let Some(prefix) = pattern.strip_suffix('*') {
455 name_lower
456 .starts_with(prefix)
457 .then(|| MatchScore::Prefix(prefix.chars().count()))
458 } else {
459 (name_lower == pattern).then(|| MatchScore::Exact(pattern.chars().count()))
460 };
461 let Some(score) = score else { continue };
462 match best {
463 Some(b) if score < b => {}
464 Some(b) if score == b => winners.push(entry),
465 _ => {
466 best = Some(score);
467 winners = vec![entry];
468 }
469 }
470 }
471
472 if winners.is_empty() {
473 return None;
474 }
475 let mut keys: Vec<&str> = Vec::new();
476 for (_, group_keys) in winners {
477 for k in group_keys {
478 if !keys
479 .iter()
480 .any(|existing| key_candidates_overlap(existing, k))
481 {
482 keys.push(k.as_str());
483 }
484 }
485 }
486 Some(keys)
487 }
488}
489
490fn decode_xml_name(raw: &str) -> String {
499 let chars: Vec<char> = raw.chars().collect();
500 let mut out = String::with_capacity(raw.len());
501 let mut i = 0;
502 while i < chars.len() {
503 if chars[i] == '_' && chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') {
504 let hex: String = chars[i + 2..i + 6].iter().collect();
505 if hex.chars().all(|c| c.is_ascii_hexdigit())
506 && let Ok(code) = u32::from_str_radix(&hex, 16)
507 && let Some(ch) = char::from_u32(code)
508 {
509 out.push(ch);
510 i += 7;
511 continue;
512 }
513 }
514 out.push(chars[i]);
515 i += 1;
516 }
517 out
518}
519
520fn key_candidates(raw: &str) -> [String; 2] {
526 [raw.to_lowercase(), decode_xml_name(raw).to_lowercase()]
527}
528
529fn key_candidates_overlap(a: &str, b: &str) -> bool {
530 let ca = key_candidates(a);
531 let cb = key_candidates(b);
532 ca.iter().any(|x| cb.contains(x))
533}
534
535fn unique_overlap<'s, T>(key: &str, items: &'s [T], key_of: impl Fn(&T) -> &str) -> Option<&'s T> {
546 let mut matches = items
547 .iter()
548 .filter(|t| key_candidates_overlap(key_of(t), key));
549 let first = matches.next()?;
550 if matches.next().is_some() {
551 return None;
552 }
553 Some(first)
554}
555
556fn resolve_mapping_source_key<'s>(
564 mapping_key: &str,
565 sources: &'s [PackageSourceEntry],
566) -> Option<&'s PackageSourceEntry> {
567 let resolved = unique_overlap(mapping_key, sources, |s| s.key.as_str());
568 if resolved.is_none()
569 && sources
570 .iter()
571 .any(|s| key_candidates_overlap(&s.key, mapping_key))
572 {
573 tracing::debug!(
574 key = mapping_key,
575 "packageSourceMapping key resolves to more than one declared source; treating as unresolvable"
576 );
577 }
578 resolved
579}
580
581#[derive(Debug, Default)]
584pub struct NuGetConfig {
585 sources: Vec<PackageSourceEntry>,
586 cleared: bool,
590 nuget_org_removed: bool,
597 mapping: PackageSourceMapping,
598}
599
600impl NuGetConfig {
601 #[must_use]
603 pub fn resolve_source_for(&self, package: &PackageName) -> DependencySource {
604 if !self.mapping.is_empty() {
605 return self.resolve_via_mapping(package);
606 }
607 self.resolve_plain()
608 }
609
610 fn resolve_via_mapping(&self, package: &PackageName) -> DependencySource {
611 let name_lower = package.as_str().to_lowercase();
612 let Some(keys) = self.mapping.resolve_keys_for(&name_lower) else {
613 return no_source(package);
614 };
615 let hops = self.hops_for_mapping_keys(&keys);
616 if hops.is_empty() {
617 return no_source(package);
618 }
619 if hops.len() == 1 && crate::registry::is_public_registry_url(hops[0].url.as_str()) {
620 return DependencySource::Registry;
621 }
622 DependencySource::AlternateRegistry {
623 index: NuGetSourceChain::chain(hops, false).key,
624 mirrors_crates_io: false,
625 }
626 }
627
628 fn hops_for_mapping_keys(&self, keys: &[&str]) -> Vec<ResolvedHop> {
640 let mut hops = Vec::new();
641 for key in keys {
642 let resolved = resolve_mapping_source_key(key, &self.sources)
643 .and_then(|entry| {
644 entry.value.as_ref().ok().map(|url| ResolvedHop {
645 url: url.clone(),
646 slot: entry.auth.is_some().then(|| entry.key.to_lowercase()),
647 auth: entry.auth.clone(),
648 })
649 })
650 .or_else(|| {
651 key.eq_ignore_ascii_case("nuget.org").then(|| ResolvedHop {
652 url: NuGetFeedUrl::trusted_public(),
653 slot: None,
654 auth: None,
655 })
656 });
657 if let Some(hop) = resolved
658 && !hops
659 .iter()
660 .any(|h: &ResolvedHop| h.url.as_str() == hop.url.as_str())
661 {
662 hops.push(hop);
663 }
664 }
665 hops
666 }
667
668 fn resolve_plain(&self) -> DependencySource {
677 let valid_hops = self.valid_hops();
678 if valid_hops.is_empty() {
679 if self.implicit_public_fallback() {
680 return DependencySource::Registry;
681 }
682 let raw = self
683 .sources
684 .iter()
685 .find_map(|s| s.value.as_ref().err().map(|e| e.raw.clone()))
686 .unwrap_or_else(|| NO_SOURCES_CONFIGURED_SENTINEL.to_string());
687 return DependencySource::CustomRegistry { url: raw };
688 }
689 if valid_hops.len() == 1
695 && crate::registry::is_public_registry_url(valid_hops[0].url.as_str())
696 {
697 return DependencySource::Registry;
698 }
699 DependencySource::AlternateRegistry {
700 index: NuGetSourceChain::chain(valid_hops, self.implicit_public_fallback()).key,
701 mirrors_crates_io: false,
702 }
703 }
704
705 fn implicit_public_fallback(&self) -> bool {
710 !self.cleared && !self.nuget_org_removed
711 }
712
713 fn valid_hops(&self) -> Vec<ResolvedHop> {
714 self.sources
715 .iter()
716 .filter_map(|s| {
717 let url = s.value.as_ref().ok()?.clone();
718 Some(ResolvedHop {
719 url,
720 slot: s.auth.is_some().then(|| s.key.to_lowercase()),
721 auth: s.auth.clone(),
722 })
723 })
724 .collect()
725 }
726
727 #[must_use]
732 pub fn resolved_chains(&self) -> Vec<NuGetSourceChain> {
733 let mut chains = Vec::new();
734 let mut seen = HashSet::new();
735
736 if self.mapping.is_empty() {
737 let valid_hops = self.valid_hops();
738 let is_public_only = valid_hops.len() == 1
739 && crate::registry::is_public_registry_url(valid_hops[0].url.as_str());
740 if !valid_hops.is_empty() && !is_public_only {
741 chains.push(NuGetSourceChain::chain(
742 valid_hops,
743 self.implicit_public_fallback(),
744 ));
745 }
746 } else {
747 for (_, group_keys) in &self.mapping.patterns {
748 let keys: Vec<&str> = group_keys.iter().map(String::as_str).collect();
749 let hops = self.hops_for_mapping_keys(&keys);
750 if hops.is_empty()
751 || (hops.len() == 1
752 && crate::registry::is_public_registry_url(hops[0].url.as_str()))
753 {
754 continue;
755 }
756 let chain = NuGetSourceChain::chain(hops, false);
757 if seen.insert(chain.key.clone()) {
758 chains.push(chain);
759 }
760 }
761 }
762 chains
763 }
764}
765
766fn no_source(package: &PackageName) -> DependencySource {
767 DependencySource::CustomRegistry {
768 url: package.as_str().to_string(),
769 }
770}
771
772#[derive(Debug, Default, Clone, Hash)]
774struct RawSourceAdd {
775 key: String,
776 value: String,
777 protocol_version: Option<String>,
778}
779
780#[derive(Debug, Default, Clone, Hash)]
784struct RawCredential {
785 key: String,
788 username: Option<RedactedSecret>,
789 password: Option<RedactedSecret>,
791 encrypted: bool,
794}
795
796#[derive(Debug, Default, Clone, Hash)]
806struct RawNuGetConfigFile {
807 sources_cleared: bool,
808 sources: Vec<RawSourceAdd>,
809 removed: Vec<String>,
816 disabled: Vec<(String, String)>,
818 credentialed_keys: Vec<String>,
820 mapping: Vec<(String, Vec<String>)>,
822 credentials: Vec<RawCredential>,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829enum ConfigSection {
830 Sources,
831 Disabled,
832 Credentials,
833 Mapping,
834}
835
836fn parse_nuget_config_raw(content: &str) -> RawNuGetConfigFile {
841 let mut out = RawNuGetConfigFile::default();
842 let mut reader = Reader::from_str(content);
843 reader.config_mut().trim_text(true);
844
845 let mut section: Option<ConfigSection> = None;
846 let mut credential_source: Option<String> = None;
847 let mut mapping_source_key: Option<String> = None;
848
849 loop {
850 let event = match reader.read_event() {
857 Ok(event) => event,
858 Err(error) => {
859 tracing::warn!(
860 %error,
861 "malformed NuGet.Config XML; ignoring this file's declarations entirely"
862 );
863 return RawNuGetConfigFile::default();
864 }
865 };
866 match event {
867 Event::Start(ref e) | Event::Empty(ref e) => {
868 let is_start = matches!(event, Event::Start(_));
869 let local: String = e.local_name().as_ref().to_string();
870
871 if section.is_none() {
872 if is_start {
879 section = match local.as_str() {
880 "packageSources" => Some(ConfigSection::Sources),
881 "disabledPackageSources" => Some(ConfigSection::Disabled),
882 "packageSourceCredentials" => Some(ConfigSection::Credentials),
883 "packageSourceMapping" => Some(ConfigSection::Mapping),
884 _ => None,
885 };
886 }
887 continue;
888 }
889
890 match (section, local.as_str()) {
891 (Some(ConfigSection::Sources), "clear") => out.sources_cleared = true,
892 (Some(ConfigSection::Sources), "remove") => {
893 for attr in e.attributes().flatten() {
894 if attr.key.local_name().as_ref() == "key" {
895 out.removed.push(decode_attr(&attr.value));
896 }
897 }
898 }
899 (Some(ConfigSection::Sources), "add") => {
900 let mut add = RawSourceAdd::default();
901 for attr in e.attributes().flatten() {
902 match attr.key.local_name().as_ref() {
903 "key" => add.key = decode_attr(&attr.value),
904 "value" => add.value = decode_attr(&attr.value),
905 "protocolVersion" => {
906 add.protocol_version = Some(decode_attr(&attr.value));
907 }
908 _ => {}
909 }
910 }
911 if !add.key.is_empty() {
912 out.sources.push(add);
913 }
914 }
915 (Some(ConfigSection::Disabled), "add") => {
916 let mut key = String::new();
917 let mut value = String::new();
918 for attr in e.attributes().flatten() {
919 match attr.key.local_name().as_ref() {
920 "key" => key = decode_attr(&attr.value),
921 "value" => value = decode_attr(&attr.value),
922 _ => {}
923 }
924 }
925 if !key.is_empty() {
926 out.disabled.push((key, value));
927 }
928 }
929 (Some(ConfigSection::Credentials), _) if credential_source.is_none() => {
930 out.credentialed_keys.push(local.clone());
931 if is_start {
932 out.credentials.push(RawCredential {
933 key: local.clone(),
934 ..Default::default()
935 });
936 credential_source = Some(local);
937 }
938 }
939 (Some(ConfigSection::Credentials), "add") if credential_source.is_some() => {
945 let mut attr_key = String::new();
946 let mut attr_value = String::new();
947 for attr in e.attributes().flatten() {
948 match attr.key.local_name().as_ref() {
949 "key" => attr_key = decode_attr(&attr.value),
950 "value" => attr_value = decode_attr(&attr.value),
951 _ => {}
952 }
953 }
954 if let Some(cred) = out.credentials.last_mut() {
955 match attr_key.as_str() {
956 "Username" => {
957 cred.username = Some(RedactedSecret::new(attr_value));
958 }
959 "ClearTextPassword" => {
960 cred.password = Some(RedactedSecret::new(attr_value));
961 }
962 "Password" => cred.encrypted = true,
963 _ => {}
964 }
965 }
966 }
967 (Some(ConfigSection::Mapping), "packageSource") => {
968 let mut key = String::new();
969 for attr in e.attributes().flatten() {
970 if attr.key.local_name().as_ref() == "key" {
971 key = decode_attr(&attr.value);
972 }
973 }
974 if !key.is_empty() {
975 if is_start {
976 mapping_source_key = Some(key.clone());
977 }
978 out.mapping.push((key, Vec::new()));
979 }
980 }
981 (Some(ConfigSection::Mapping), "package") if mapping_source_key.is_some() => {
982 for attr in e.attributes().flatten() {
983 if attr.key.local_name().as_ref() == "pattern"
984 && let Some(last) = out.mapping.last_mut()
985 {
986 last.1.push(decode_attr(&attr.value));
987 }
988 }
989 }
990 _ => {}
991 }
992 }
993 Event::End(ref e) => {
994 let local: String = e.local_name().as_ref().to_string();
995 match section {
996 Some(ConfigSection::Sources) if local == "packageSources" => section = None,
997 Some(ConfigSection::Disabled) if local == "disabledPackageSources" => {
998 section = None;
999 }
1000 Some(ConfigSection::Credentials) if local == "packageSourceCredentials" => {
1001 section = None;
1002 }
1003 Some(ConfigSection::Mapping) if local == "packageSourceMapping" => {
1004 section = None;
1005 }
1006 Some(ConfigSection::Mapping) if local == "packageSource" => {
1007 mapping_source_key = None;
1008 }
1009 Some(ConfigSection::Credentials)
1010 if credential_source.as_deref() == Some(local.as_str()) =>
1011 {
1012 credential_source = None;
1013 }
1014 _ => {}
1015 }
1016 }
1017 Event::Eof => break,
1018 _ => {}
1019 }
1020 }
1021
1022 out
1023}
1024
1025fn decode_attr(raw: &str) -> String {
1026 quick_xml::escape::unescape(raw)
1027 .map(|c| c.into_owned())
1028 .unwrap_or_else(|_| raw.to_string())
1029}
1030
1031fn resolve_source_entry(add: &RawSourceAdd, policy: &RegistryAccessPolicy) -> InvalidOrValid {
1034 if add.protocol_version.as_deref() == Some("2") {
1035 tracing::debug!(
1036 key = %add.key,
1037 "skipping NuGet V2 (protocolVersion=\"2\") package source; only V3 feeds are supported"
1038 );
1039 return Err(InvalidEntry {
1040 raw: redact_userinfo(&add.value),
1041 reason: NuGetFeedUrlError::UnsupportedProtocolVersion("2".to_string()),
1042 });
1043 }
1044 if !add.value.contains("://") {
1045 tracing::debug!(
1046 key = %add.key,
1047 value = %add.value,
1048 "skipping local/UNC NuGet package source; only V3 http(s) feeds are supported"
1049 );
1050 return Err(InvalidEntry {
1051 raw: redact_userinfo(&add.value),
1052 reason: NuGetFeedUrlError::LocalFeedUnsupported,
1053 });
1054 }
1055 NuGetFeedUrl::new(&add.value, policy).map_err(|reason| {
1056 let redacted = redact_userinfo(&add.value);
1057 tracing::warn!(key = %add.key, raw = %redacted, %reason, "NuGet package source failed validation");
1058 InvalidEntry {
1059 raw: redacted,
1060 reason,
1061 }
1062 })
1063}
1064
1065type InvalidOrValid = Result<NuGetFeedUrl, InvalidEntry>;
1066
1067fn upsert_source(
1068 sources: &mut Vec<PackageSourceEntry>,
1069 add: &RawSourceAdd,
1070 policy: &RegistryAccessPolicy,
1071 tier: ConfigTier,
1072) {
1073 let value = resolve_source_entry(add, policy);
1074 if let Some(existing) = sources
1080 .iter_mut()
1081 .find(|s| key_candidates_overlap(&s.key, &add.key))
1082 {
1083 *existing = PackageSourceEntry {
1089 key: existing.key.clone(),
1090 value,
1091 tier,
1092 auth: None,
1093 };
1094 } else {
1095 sources.push(PackageSourceEntry {
1096 key: add.key.clone(),
1097 value,
1098 tier,
1099 auth: None,
1100 });
1101 }
1102}
1103
1104const WARNED_CAPACITY: usize = deps_core::DEFAULT_MAX_CACHED_FILES;
1116
1117#[derive(Debug)]
1118pub struct NuGetConfigCache {
1119 files: deps_core::MtimeFileCache<RawNuGetConfigFile>,
1120 warned: dashmap::DashSet<u64>,
1138}
1139
1140impl Default for NuGetConfigCache {
1141 fn default() -> Self {
1142 Self::new()
1143 }
1144}
1145
1146impl NuGetConfigCache {
1147 #[must_use]
1149 pub fn new() -> Self {
1150 Self {
1151 files: deps_core::MtimeFileCache::new(
1152 deps_core::DEFAULT_MAX_CACHED_FILES,
1153 "nuget config",
1154 ),
1155 warned: dashmap::DashSet::new(),
1156 }
1157 }
1158
1159 fn get_or_parse(&self, path: &Path) -> Option<Arc<RawNuGetConfigFile>> {
1160 self.files.get_or_parse(path, parse_nuget_config_raw)
1161 }
1162
1163 fn should_warn_once(&self, key: u64) -> bool {
1166 if self.warned.len() >= WARNED_CAPACITY && !self.warned.contains(&key) {
1167 self.warned.clear();
1168 }
1169 self.warned.insert(key)
1170 }
1171}
1172
1173#[derive(Debug, Clone, Default)]
1175pub struct NuGetParseContext {
1176 pub policy: Arc<RegistryAccessPolicy>,
1178 pub config_cache: Arc<NuGetConfigCache>,
1180 pub user_profile_config: Option<PathBuf>,
1186 pub user_profile_sources: Arc<AtomicBool>,
1190}
1191
1192impl NuGetParseContext {
1193 #[must_use]
1196 pub fn new(
1197 policy: Arc<RegistryAccessPolicy>,
1198 config_cache: Arc<NuGetConfigCache>,
1199 user_profile_sources: Arc<AtomicBool>,
1200 ) -> Self {
1201 Self {
1202 policy,
1203 config_cache,
1204 user_profile_config: discover_user_profile_config(),
1205 user_profile_sources,
1206 }
1207 }
1208}
1209
1210fn user_profile_config_candidates(home: Option<&Path>) -> Vec<PathBuf> {
1215 let mut candidates = Vec::new();
1216 if cfg!(windows) {
1217 if let Ok(appdata) = std::env::var("APPDATA") {
1218 candidates.push(PathBuf::from(appdata).join("NuGet").join("NuGet.Config"));
1219 }
1220 } else {
1221 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
1222 && !xdg.is_empty()
1223 {
1224 candidates.push(PathBuf::from(xdg).join("NuGet").join("NuGet.Config"));
1225 }
1226 if let Some(home) = home {
1227 candidates.push(home.join(".config").join("NuGet").join("NuGet.Config"));
1228 candidates.push(home.join(".nuget").join("NuGet").join("NuGet.Config"));
1229 }
1230 }
1231 candidates
1232}
1233
1234fn discover_user_profile_config_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
1237 user_profile_config_candidates(home.as_deref())
1238 .into_iter()
1239 .find(|p| p.is_file())
1240}
1241
1242#[must_use]
1245fn discover_user_profile_config() -> Option<PathBuf> {
1246 discover_user_profile_config_with_home(dirs::home_dir())
1247}
1248
1249#[cfg(test)]
1255#[must_use]
1256pub(crate) fn resolve(
1257 manifest_dir: &Path,
1258 config_cache: &NuGetConfigCache,
1259 policy: &RegistryAccessPolicy,
1260) -> NuGetConfig {
1261 resolve_with_context(
1262 manifest_dir,
1263 config_cache,
1264 policy,
1265 None,
1266 &AtomicBool::new(false),
1267 )
1268}
1269
1270#[must_use]
1306pub fn resolve_with_context(
1307 manifest_dir: &Path,
1308 config_cache: &NuGetConfigCache,
1309 policy: &RegistryAccessPolicy,
1310 user_profile_config: Option<&Path>,
1311 user_profile_sources: &AtomicBool,
1312) -> NuGetConfig {
1313 let ancestors = collect_config_ancestors(manifest_dir, config_cache, user_profile_config);
1314
1315 let user_profile_sources_enabled =
1316 user_profile_sources.load(std::sync::atomic::Ordering::Relaxed);
1317
1318 let config_fingerprint = config_ancestors_fingerprint(&ancestors);
1323
1324 let accumulated = accumulate_config_tiers(&ancestors, policy, user_profile_sources_enabled);
1325
1326 bind_credentials_and_finalize(accumulated, config_cache, config_fingerprint)
1327}
1328
1329fn collect_config_ancestors(
1340 manifest_dir: &Path,
1341 config_cache: &NuGetConfigCache,
1342 user_profile_config: Option<&Path>,
1343) -> Vec<(ConfigTier, Arc<RawNuGetConfigFile>)> {
1344 let mut repo_ancestors: Vec<Arc<RawNuGetConfigFile>> = Vec::new();
1345 let mut repo_paths: Vec<PathBuf> = Vec::new();
1346 let mut current: Option<&Path> = Some(manifest_dir);
1347 let mut depth = 0usize;
1348 while let Some(dir) = current {
1349 if depth >= MAX_CONFIG_ANCESTOR_DEPTH {
1350 break;
1351 }
1352 depth += 1;
1353
1354 for name in CONFIG_FILENAMES {
1355 let candidate: PathBuf = dir.join(name);
1356 if candidate.is_file() {
1357 if let Some(parsed) = config_cache.get_or_parse(&candidate) {
1358 repo_ancestors.push(parsed);
1359 repo_paths.push(candidate);
1360 }
1361 break;
1362 }
1363 }
1364
1365 current = dir.parent();
1366 }
1367
1368 let user_profile_file: Option<Arc<RawNuGetConfigFile>> = user_profile_config.and_then(|upc| {
1369 let canon = std::fs::canonicalize(upc).ok()?;
1370 let is_repo_dup = repo_paths
1371 .iter()
1372 .any(|p| std::fs::canonicalize(p).ok().as_deref() == Some(canon.as_path()));
1373 if is_repo_dup {
1374 return None;
1375 }
1376 config_cache.get_or_parse(&canon)
1377 });
1378
1379 let mut ancestors: Vec<(ConfigTier, Arc<RawNuGetConfigFile>)> = repo_ancestors
1380 .into_iter()
1381 .map(|f| (ConfigTier::Repo, f))
1382 .collect();
1383 if let Some(user_file) = user_profile_file {
1384 ancestors.push((ConfigTier::UserProfile, user_file));
1385 }
1386 ancestors
1387}
1388
1389fn config_ancestors_fingerprint(ancestors: &[(ConfigTier, Arc<RawNuGetConfigFile>)]) -> u64 {
1400 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1401 for (tier, file) in ancestors {
1402 tier.hash(&mut hasher);
1403 file.hash(&mut hasher);
1404 }
1405 hasher.finish()
1406}
1407
1408struct AccumulatedConfig {
1414 sources: Vec<PackageSourceEntry>,
1415 cleared: bool,
1416 nuget_org_removed: bool,
1417 disabled_raw: Vec<(String, String)>,
1418 repo_credentialed_raw: Vec<String>,
1419 user_credentialed_raw: Vec<String>,
1420 mapping: PackageSourceMapping,
1421 user_credentials: Vec<RawCredential>,
1424 user_profile_add: Vec<PackageSourceEntry>,
1425 user_profile_credential_suppressed: HashSet<String>,
1426}
1427
1428fn accumulate_config_tiers(
1441 ancestors: &[(ConfigTier, Arc<RawNuGetConfigFile>)],
1442 policy: &RegistryAccessPolicy,
1443 user_profile_sources_enabled: bool,
1444) -> AccumulatedConfig {
1445 let mut sources: Vec<PackageSourceEntry> = Vec::new();
1446 let mut cleared = false;
1447 let mut nuget_org_removed = false;
1448 let mut disabled_raw: Vec<(String, String)> = Vec::new();
1449 let mut repo_credentialed_raw: Vec<String> = Vec::new();
1450 let mut user_credentialed_raw: Vec<String> = Vec::new();
1451 let mut mapping = PackageSourceMapping::default();
1452
1453 let mut user_credentials: Vec<RawCredential> = Vec::new();
1455 let mut user_profile_add: Vec<PackageSourceEntry> = Vec::new();
1456 let mut user_profile_credential_suppressed: HashSet<String> = HashSet::new();
1457
1458 for (tier, file) in ancestors.iter().rev() {
1459 let tier = *tier;
1460
1461 if tier == ConfigTier::UserProfile {
1462 user_credentialed_raw.extend(file.credentialed_keys.iter().cloned());
1464 user_credentials.extend(file.credentials.iter().cloned());
1465 for (key, value) in &file.disabled {
1466 if value.eq_ignore_ascii_case("true") {
1467 user_profile_credential_suppressed.extend(key_candidates(key));
1468 }
1469 }
1470 if file.sources_cleared {
1471 user_profile_add.clear();
1472 }
1473 for add in &file.sources {
1474 upsert_source(&mut user_profile_add, add, policy, ConfigTier::UserProfile);
1475 }
1476 for key in &file.removed {
1477 user_profile_add.retain(|e| !key_candidates_overlap(&e.key, key));
1478 }
1479
1480 if !user_profile_sources_enabled {
1481 continue;
1483 }
1484 } else {
1485 repo_credentialed_raw.extend(file.credentialed_keys.iter().cloned());
1486 }
1487
1488 if file.sources_cleared {
1493 sources.clear();
1494 cleared = true;
1495 }
1496 for add in &file.sources {
1497 upsert_source(&mut sources, add, policy, tier);
1498 }
1499 for key in &file.removed {
1504 sources.retain(|s| !key_candidates_overlap(&s.key, key));
1505 if key.eq_ignore_ascii_case("nuget.org") {
1506 nuget_org_removed = true;
1507 }
1508 }
1509 if tier == ConfigTier::Repo {
1510 disabled_raw.extend(file.disabled.iter().cloned());
1511 }
1512 for (source_key, patterns) in &file.mapping {
1513 mapping.extend(source_key, patterns);
1514 }
1515 }
1516
1517 AccumulatedConfig {
1518 sources,
1519 cleared,
1520 nuget_org_removed,
1521 disabled_raw,
1522 repo_credentialed_raw,
1523 user_credentialed_raw,
1524 mapping,
1525 user_credentials,
1526 user_profile_add,
1527 user_profile_credential_suppressed,
1528 }
1529}
1530
1531fn bind_credentials_and_finalize(
1539 mut accumulated: AccumulatedConfig,
1540 config_cache: &NuGetConfigCache,
1541 config_fingerprint: u64,
1542) -> NuGetConfig {
1543 let mut disabled_keys: HashSet<String> = HashSet::new();
1544 for (key, value) in &accumulated.disabled_raw {
1545 if value.eq_ignore_ascii_case("true") {
1546 disabled_keys.extend(key_candidates(key));
1547 }
1548 }
1549 let repo_credentialed_keys: HashSet<String> = accumulated
1550 .repo_credentialed_raw
1551 .iter()
1552 .flat_map(|k| key_candidates(k))
1553 .collect();
1554 let user_credentialed_keys: HashSet<String> = accumulated
1555 .user_credentialed_raw
1556 .iter()
1557 .flat_map(|k| key_candidates(k))
1558 .collect();
1559
1560 for entry in &mut accumulated.sources {
1561 let Ok(url) = entry.value.as_ref() else {
1562 continue;
1563 };
1564 let resolved_url = url.as_str().to_string();
1565 let candidates = key_candidates(&entry.key);
1566 let is_disabled = candidates.iter().any(|c| disabled_keys.contains(c));
1567 let is_repo_credentialed = candidates
1568 .iter()
1569 .any(|c| repo_credentialed_keys.contains(c));
1570 let is_user_credentialed = candidates
1571 .iter()
1572 .any(|c| user_credentialed_keys.contains(c));
1573 let is_public = crate::registry::is_public_registry_url(&resolved_url);
1574
1575 if is_repo_credentialed {
1581 fail_closed(
1582 entry,
1583 NuGetFeedUrlError::HasCredentials,
1584 FailClosedCause::RepoTierCredentialed,
1585 config_cache,
1586 config_fingerprint,
1587 );
1588 continue;
1589 }
1590 if is_disabled {
1591 fail_closed(
1592 entry,
1593 NuGetFeedUrlError::Disabled,
1594 FailClosedCause::MachineDisabled,
1595 config_cache,
1596 config_fingerprint,
1597 );
1598 continue;
1599 }
1600 if is_public {
1603 continue;
1604 }
1605
1606 match bind_user_profile_credential(
1607 entry,
1608 &accumulated.user_credentials,
1609 &accumulated.user_profile_add,
1610 &accumulated.user_profile_credential_suppressed,
1611 &resolved_url,
1612 ) {
1613 Some(Ok(auth)) => entry.auth = Some(auth),
1614 Some(Err((reason, cause))) => {
1615 fail_closed(entry, reason, cause, config_cache, config_fingerprint);
1616 }
1617 None if is_user_credentialed => {
1618 fail_closed(
1619 entry,
1620 NuGetFeedUrlError::HasCredentials,
1621 FailClosedCause::AmbiguousCredentialKeyMatch,
1622 config_cache,
1623 config_fingerprint,
1624 );
1625 }
1626 None => {}
1627 }
1628 }
1629
1630 NuGetConfig {
1631 sources: accumulated.sources,
1632 cleared: accumulated.cleared,
1633 nuget_org_removed: accumulated.nuget_org_removed,
1634 mapping: accumulated.mapping,
1635 }
1636}
1637
1638#[derive(Debug, Clone, Copy, Hash)]
1649enum FailClosedCause {
1650 RepoTierCredentialed,
1652 MachineDisabled,
1656 UserProfileSuppressed,
1659 NoMatchingUserProfileAdd,
1662 UserProfileAddEntryInvalid,
1665 UserProfileUrlMismatch,
1668 AmbiguousCredentialKeyMatch,
1675 CredentialExpansionFailed,
1679}
1680
1681fn fail_closed(
1704 entry: &mut PackageSourceEntry,
1705 reason: NuGetFeedUrlError,
1706 cause: FailClosedCause,
1707 config_cache: &NuGetConfigCache,
1708 config_fingerprint: u64,
1709) {
1710 let raw = match &entry.value {
1711 Ok(url) => url.as_str().to_string(),
1712 Err(invalid) => invalid.raw.clone(),
1713 };
1714
1715 if !matches!(reason, NuGetFeedUrlError::EncryptedPasswordUnsupported) {
1716 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1717 config_fingerprint.hash(&mut hasher);
1718 entry.key.hash(&mut hasher);
1719 cause.hash(&mut hasher);
1720 std::mem::discriminant(&reason).hash(&mut hasher);
1721 if config_cache.should_warn_once(hasher.finish()) {
1722 if matches!(reason, NuGetFeedUrlError::Disabled) {
1723 tracing::debug!(
1724 key = %entry.key,
1725 %reason,
1726 ?cause,
1727 "NuGet package source fails closed on credential binding"
1728 );
1729 } else {
1730 tracing::warn!(
1731 key = %entry.key,
1732 %reason,
1733 ?cause,
1734 "NuGet package source fails closed on credential binding"
1735 );
1736 }
1737 }
1738 }
1739
1740 entry.value = Err(InvalidEntry { raw, reason });
1741}
1742
1743fn bind_user_profile_credential(
1751 entry: &PackageSourceEntry,
1752 user_credentials: &[RawCredential],
1753 user_profile_add: &[PackageSourceEntry],
1754 suppressed: &HashSet<String>,
1755 resolved_url: &str,
1756) -> Option<Result<NuGetAuth, (NuGetFeedUrlError, FailClosedCause)>> {
1757 let candidates = key_candidates(&entry.key);
1762 let suppressed_match = candidates.iter().any(|c| suppressed.contains(c));
1763
1764 let credential = unique_overlap(&entry.key, user_credentials, |c| c.key.as_str())?;
1766
1767 if suppressed_match {
1768 return Some(Err((
1769 NuGetFeedUrlError::HasCredentials,
1770 FailClosedCause::UserProfileSuppressed,
1771 )));
1772 }
1773
1774 let Some(add_entry) = unique_overlap(&credential.key, user_profile_add, |e| e.key.as_str())
1777 else {
1778 return Some(Err((
1779 NuGetFeedUrlError::HasCredentials,
1780 FailClosedCause::NoMatchingUserProfileAdd,
1781 )));
1782 };
1783
1784 let Ok(add_url) = add_entry.value.as_ref() else {
1786 return Some(Err((
1787 NuGetFeedUrlError::HasCredentials,
1788 FailClosedCause::UserProfileAddEntryInvalid,
1789 )));
1790 };
1791 if add_url.as_str() != resolved_url {
1792 return Some(Err((
1793 NuGetFeedUrlError::HasCredentials,
1794 FailClosedCause::UserProfileUrlMismatch,
1795 )));
1796 }
1797
1798 Some(
1799 expand_credential(credential)
1800 .map_err(|reason| (reason, FailClosedCause::CredentialExpansionFailed)),
1801 )
1802}
1803
1804fn expand_credential(credential: &RawCredential) -> Result<NuGetAuth, NuGetFeedUrlError> {
1810 if credential.encrypted {
1811 tracing::debug!(
1812 key = %credential.key,
1813 "DPAPI-encrypted <Password> is not supported; dropping credential"
1814 );
1815 return Err(NuGetFeedUrlError::EncryptedPasswordUnsupported);
1816 }
1817 let Some(password) = &credential.password else {
1818 return Err(NuGetFeedUrlError::HasCredentials);
1819 };
1820 let username = credential
1821 .username
1822 .as_ref()
1823 .map(RedactedSecret::expose_secret)
1824 .unwrap_or("");
1825 let username = expand_env_vars(username)?;
1826 let password = expand_env_vars(password.expose_secret())?;
1827 Ok(NuGetAuth::new(&username, &password))
1828}
1829
1830fn expand_env_vars(raw: &str) -> Result<Zeroizing<String>, NuGetFeedUrlError> {
1838 expand_env_vars_with(raw, |name| std::env::var(name).ok().map(Zeroizing::new))
1839}
1840
1841fn expand_env_vars_with(
1854 raw: &str,
1855 lookup: impl Fn(&str) -> Option<Zeroizing<String>>,
1856) -> Result<Zeroizing<String>, NuGetFeedUrlError> {
1857 enum Segment<'a> {
1858 Literal(&'a str),
1859 Value(Zeroizing<String>),
1860 }
1861
1862 let mut segments = Vec::new();
1863 let mut rest = raw;
1864 while let Some(pct) = rest.find('%') {
1865 let literal = &rest[..pct];
1866 let after = &rest[pct + 1..];
1867 if let Some(end) = after.find('%') {
1868 let name = &after[..end];
1869 if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1870 if !literal.is_empty() {
1871 segments.push(Segment::Literal(literal));
1872 }
1873 let value = lookup(name).ok_or(NuGetFeedUrlError::HasCredentials)?;
1874 segments.push(Segment::Value(value));
1875 rest = &after[end + 1..];
1876 continue;
1877 }
1878 }
1879 segments.push(Segment::Literal(&rest[..=pct]));
1882 rest = &rest[pct + 1..];
1883 }
1884 if !rest.is_empty() {
1885 segments.push(Segment::Literal(rest));
1886 }
1887
1888 let total_len: usize = segments
1889 .iter()
1890 .map(|segment| match segment {
1891 Segment::Literal(s) => s.len(),
1892 Segment::Value(v) => v.len(),
1893 })
1894 .sum();
1895 let mut out = Zeroizing::new(String::with_capacity(total_len));
1896 for segment in &segments {
1897 match segment {
1898 Segment::Literal(s) => out.push_str(s),
1899 Segment::Value(v) => out.push_str(v.as_str()),
1900 }
1901 }
1902 Ok(out)
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907 use super::*;
1908 use deps_core::net_policy::WorkspaceRegistryAccess;
1909 use std::assert_matches;
1910
1911 fn all_policy() -> RegistryAccessPolicy {
1912 RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
1913 }
1914
1915 fn pkg(name: &str) -> PackageName {
1916 PackageName::new(name)
1917 }
1918
1919 fn write_config(dir: &Path, content: &str) {
1920 std::fs::write(dir.join("NuGet.Config"), content).unwrap();
1921 }
1922
1923 #[test]
1926 fn test_feed_url_accepts_https() {
1927 let policy = all_policy();
1928 assert!(NuGetFeedUrl::new("https://feed.mycorp.example/v3/index.json", &policy).is_ok());
1929 }
1930
1931 #[test]
1932 fn test_feed_url_rejects_userinfo() {
1933 let policy = all_policy();
1934 assert_matches!(
1935 NuGetFeedUrl::new("https://user:pass@feed.example/v3/index.json", &policy),
1936 Err(NuGetFeedUrlError::UserInfoPresent)
1937 );
1938 }
1939
1940 #[test]
1941 fn test_feed_url_normalizes_trailing_slash() {
1942 let policy = all_policy();
1943 let a = NuGetFeedUrl::new("https://feed.example/v3/index.json/", &policy).unwrap();
1944 let b = NuGetFeedUrl::new("https://feed.example/v3/index.json", &policy).unwrap();
1945 assert_eq!(a, b);
1946 }
1947
1948 #[test]
1951 fn test_decode_xml_name_space() {
1952 assert_eq!(decode_xml_name("Corp_x0020_Feed"), "Corp Feed");
1953 }
1954
1955 #[test]
1956 fn test_decode_xml_name_literal_underscore() {
1957 assert_eq!(decode_xml_name("Corp_x005F_Feed"), "Corp_Feed");
1958 }
1959
1960 #[test]
1961 fn test_decode_xml_name_no_escapes_is_identity() {
1962 assert_eq!(decode_xml_name("CorpFeed"), "CorpFeed");
1963 }
1964
1965 #[test]
1966 fn test_key_candidates_overlap_case_insensitive() {
1967 assert!(key_candidates_overlap("CorpFeed", "corpfeed"));
1968 }
1969
1970 #[test]
1971 fn test_key_candidates_overlap_decoded_form() {
1972 assert!(key_candidates_overlap("Corp_x0020_Feed", "Corp Feed"));
1973 }
1974
1975 fn source(key: &str, url: &str, policy: &RegistryAccessPolicy) -> PackageSourceEntry {
1978 PackageSourceEntry {
1979 key: key.to_string(),
1980 value: NuGetFeedUrl::new(url, policy).map_err(|reason| InvalidEntry {
1981 raw: url.to_string(),
1982 reason,
1983 }),
1984 tier: ConfigTier::Repo,
1985 auth: None,
1986 }
1987 }
1988
1989 #[test]
1990 fn test_resolve_mapping_source_key_unique_match() {
1991 let policy = all_policy();
1992 let sources = vec![source(
1993 "CorpFeed",
1994 "https://corp.example/v3/index.json",
1995 &policy,
1996 )];
1997 assert!(resolve_mapping_source_key("CorpFeed", &sources).is_some());
1998 assert!(resolve_mapping_source_key("corpfeed", &sources).is_some());
1999 }
2000
2001 #[test]
2002 fn test_resolve_mapping_source_key_absent_source_is_none() {
2003 let sources: Vec<PackageSourceEntry> = Vec::new();
2004 assert!(resolve_mapping_source_key("Missing", &sources).is_none());
2005 }
2006
2007 #[test]
2010 fn test_resolve_mapping_source_key_ambiguous_is_none() {
2011 let policy = all_policy();
2012 let sources = vec![
2013 source(
2014 "Corp_x0020_Feed",
2015 "https://a.example/v3/index.json",
2016 &policy,
2017 ),
2018 source("Corp Feed", "https://b.example/v3/index.json", &policy),
2019 ];
2020 assert!(resolve_mapping_source_key("Corp Feed", &sources).is_none());
2021 }
2022
2023 #[test]
2026 fn test_no_config_resolves_to_plain_registry() {
2027 let config = NuGetConfig::default();
2028 assert_eq!(
2029 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2030 DependencySource::Registry
2031 );
2032 assert!(config.resolved_chains().is_empty());
2033 }
2034
2035 #[test]
2036 fn test_single_alternate_source_no_clear_appends_implicit_public_fallback() {
2037 let dir = tempfile::tempdir().unwrap();
2038 write_config(
2039 dir.path(),
2040 r#"<configuration><packageSources>
2041 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2042 </packageSources></configuration>"#,
2043 );
2044 let cache = NuGetConfigCache::new();
2045 let policy = all_policy();
2046 let config = resolve(dir.path(), &cache, &policy);
2047
2048 let source = config.resolve_source_for(&pkg("Any.Package"));
2049 assert_matches!(source, DependencySource::AlternateRegistry { .. });
2050 let chains = config.resolved_chains();
2051 assert_eq!(chains.len(), 1);
2052 assert_eq!(chains[0].hops.len(), 1);
2053 assert!(chains[0].implicit_public_fallback);
2054 }
2055
2056 #[test]
2057 fn test_clear_suppresses_implicit_public_fallback() {
2058 let dir = tempfile::tempdir().unwrap();
2059 write_config(
2060 dir.path(),
2061 r#"<configuration><packageSources>
2062 <clear />
2063 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2064 </packageSources></configuration>"#,
2065 );
2066 let cache = NuGetConfigCache::new();
2067 let policy = all_policy();
2068 let config = resolve(dir.path(), &cache, &policy);
2069
2070 let chains = config.resolved_chains();
2071 assert_eq!(chains.len(), 1);
2072 assert!(!chains[0].implicit_public_fallback);
2073 }
2074
2075 #[test]
2078 fn test_clear_with_nothing_readded_is_explicit_fail_closed() {
2079 let dir = tempfile::tempdir().unwrap();
2080 write_config(
2081 dir.path(),
2082 "<configuration><packageSources><clear /></packageSources></configuration>",
2083 );
2084 let cache = NuGetConfigCache::new();
2085 let policy = all_policy();
2086 let config = resolve(dir.path(), &cache, &policy);
2087
2088 let source = config.resolve_source_for(&pkg("Any.Package"));
2089 assert_eq!(
2090 source,
2091 DependencySource::CustomRegistry {
2092 url: NO_SOURCES_CONFIGURED_SENTINEL.to_string(),
2093 }
2094 );
2095 assert!(config.resolved_chains().is_empty());
2096 }
2097
2098 #[test]
2101 fn test_c1_root_clear_survives_leaf_without_clear() {
2102 let root = tempfile::tempdir().unwrap();
2103 let leaf = root.path().join("src").join("App");
2104 std::fs::create_dir_all(&leaf).unwrap();
2105 write_config(
2106 root.path(),
2107 r#"<configuration><packageSources>
2108 <clear />
2109 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2110 </packageSources></configuration>"#,
2111 );
2112 write_config(
2113 &leaf,
2114 r#"<configuration><packageSources>
2115 <add key="SecondFeed" value="https://second.example/v3/index.json" />
2116 </packageSources></configuration>"#,
2117 );
2118 let cache = NuGetConfigCache::new();
2119 let policy = all_policy();
2120 let config = resolve(&leaf, &cache, &policy);
2121
2122 let chains = config.resolved_chains();
2123 assert_eq!(chains.len(), 1);
2124 assert!(!chains[0].implicit_public_fallback);
2125 assert_eq!(chains[0].hops.len(), 2);
2126 assert_eq!(
2127 chains[0].hops[0].url.as_str(),
2128 "https://corp.example/v3/index.json"
2129 );
2130 assert_eq!(
2131 chains[0].hops[1].url.as_str(),
2132 "https://second.example/v3/index.json"
2133 );
2134 }
2135
2136 #[test]
2139 fn test_leaf_clear_wipes_ancestor_source() {
2140 let root = tempfile::tempdir().unwrap();
2141 let leaf = root.path().join("src").join("App");
2142 std::fs::create_dir_all(&leaf).unwrap();
2143 write_config(
2144 root.path(),
2145 r#"<configuration><packageSources>
2146 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2147 </packageSources></configuration>"#,
2148 );
2149 write_config(
2150 &leaf,
2151 "<configuration><packageSources><clear /></packageSources></configuration>",
2152 );
2153 let cache = NuGetConfigCache::new();
2154 let policy = all_policy();
2155 let config = resolve(&leaf, &cache, &policy);
2156
2157 assert!(config.resolved_chains().is_empty());
2158 assert_eq!(
2159 config.resolve_source_for(&pkg("Any.Package")),
2160 DependencySource::CustomRegistry {
2161 url: NO_SOURCES_CONFIGURED_SENTINEL.to_string(),
2162 }
2163 );
2164 }
2165
2166 #[test]
2169 fn test_disabled_source_case_insensitive_key_match() {
2170 let dir = tempfile::tempdir().unwrap();
2171 write_config(
2172 dir.path(),
2173 r#"<configuration>
2174 <packageSources>
2175 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2176 </packageSources>
2177 <disabledPackageSources>
2178 <add key="corpfeed" value="True" />
2179 </disabledPackageSources>
2180 </configuration>"#,
2181 );
2182 let cache = NuGetConfigCache::new();
2183 let policy = all_policy();
2184 let config = resolve(dir.path(), &cache, &policy);
2185
2186 assert!(config.resolved_chains().is_empty());
2187 assert_eq!(
2188 config.resolve_source_for(&pkg("Any.Package")),
2189 DependencySource::Registry
2190 );
2191 }
2192
2193 #[test]
2194 fn test_credentialed_source_dropped_with_decoded_name_match() {
2195 let dir = tempfile::tempdir().unwrap();
2196 write_config(
2197 dir.path(),
2198 r#"<configuration>
2199 <packageSources>
2200 <add key="Corp Feed" value="https://corp.example/v3/index.json" />
2201 </packageSources>
2202 <packageSourceCredentials>
2203 <Corp_x0020_Feed>
2204 <add key="Username" value="user" />
2205 <add key="ClearTextPassword" value="pass" />
2206 </Corp_x0020_Feed>
2207 </packageSourceCredentials>
2208 </configuration>"#,
2209 );
2210 let cache = NuGetConfigCache::new();
2211 let policy = all_policy();
2212 let config = resolve(dir.path(), &cache, &policy);
2213
2214 assert!(config.resolved_chains().is_empty());
2215 }
2216
2217 #[test]
2220 fn test_mapping_unmatched_package_fails_closed() {
2221 let dir = tempfile::tempdir().unwrap();
2222 write_config(
2223 dir.path(),
2224 r#"<configuration>
2225 <packageSources>
2226 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2227 </packageSources>
2228 <packageSourceMapping>
2229 <packageSource key="CorpFeed">
2230 <package pattern="MyCompany.*" />
2231 </packageSource>
2232 </packageSourceMapping>
2233 </configuration>"#,
2234 );
2235 let cache = NuGetConfigCache::new();
2236 let policy = all_policy();
2237 let config = resolve(dir.path(), &cache, &policy);
2238
2239 assert_eq!(
2240 config.resolve_source_for(&pkg("Unrelated.Package")),
2241 DependencySource::CustomRegistry {
2242 url: "Unrelated.Package".to_string(),
2243 }
2244 );
2245 }
2246
2247 #[test]
2248 fn test_mapping_matched_private_pattern_never_falls_back_to_public() {
2249 let dir = tempfile::tempdir().unwrap();
2250 write_config(
2251 dir.path(),
2252 r#"<configuration>
2253 <packageSources>
2254 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2255 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2256 </packageSources>
2257 <packageSourceMapping>
2258 <packageSource key="CorpFeed">
2259 <package pattern="MyCompany.*" />
2260 </packageSource>
2261 <packageSource key="nuget.org">
2262 <package pattern="*" />
2263 </packageSource>
2264 </packageSourceMapping>
2265 </configuration>"#,
2266 );
2267 let cache = NuGetConfigCache::new();
2268 let policy = all_policy();
2269 let config = resolve(dir.path(), &cache, &policy);
2270
2271 let source = config.resolve_source_for(&pkg("MyCompany.Internal"));
2272 let DependencySource::AlternateRegistry { index, .. } = source else {
2273 panic!("expected AlternateRegistry, private package must never route to nuget.org");
2274 };
2275 let chains = config.resolved_chains();
2276 let chain = chains.iter().find(|c| c.key == index).unwrap();
2277 assert_eq!(chain.hops.len(), 1);
2278 assert_eq!(
2279 chain.hops[0].url.as_str(),
2280 "https://corp.example/v3/index.json"
2281 );
2282 assert!(!chain.implicit_public_fallback);
2283 }
2284
2285 #[test]
2288 fn test_mapping_public_only_pattern_resolves_to_plain_registry() {
2289 let dir = tempfile::tempdir().unwrap();
2290 write_config(
2291 dir.path(),
2292 r#"<configuration>
2293 <packageSources>
2294 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2295 </packageSources>
2296 <packageSourceMapping>
2297 <packageSource key="nuget.org">
2298 <package pattern="*" />
2299 </packageSource>
2300 </packageSourceMapping>
2301 </configuration>"#,
2302 );
2303 let cache = NuGetConfigCache::new();
2304 let policy = all_policy();
2305 let config = resolve(dir.path(), &cache, &policy);
2306
2307 assert_eq!(
2308 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2309 DependencySource::Registry
2310 );
2311 assert!(config.resolved_chains().is_empty());
2312 }
2313
2314 #[test]
2317 fn test_mapping_source_named_nuget_org_but_different_url_is_not_public() {
2318 let dir = tempfile::tempdir().unwrap();
2319 write_config(
2320 dir.path(),
2321 r#"<configuration>
2322 <packageSources>
2323 <add key="nuget.org" value="https://evil.example/v3/index.json" />
2324 </packageSources>
2325 <packageSourceMapping>
2326 <packageSource key="nuget.org">
2327 <package pattern="*" />
2328 </packageSource>
2329 </packageSourceMapping>
2330 </configuration>"#,
2331 );
2332 let cache = NuGetConfigCache::new();
2333 let policy = all_policy();
2334 let config = resolve(dir.path(), &cache, &policy);
2335
2336 assert_matches!(
2337 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2338 DependencySource::AlternateRegistry { .. }
2339 );
2340 }
2341
2342 #[test]
2346 fn test_r1_mapping_merges_across_ancestor_and_leaf_not_nearest_wins() {
2347 let root = tempfile::tempdir().unwrap();
2348 let leaf = root.path().join("src").join("App");
2349 std::fs::create_dir_all(&leaf).unwrap();
2350 write_config(
2351 root.path(),
2352 r#"<configuration>
2353 <packageSources>
2354 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2355 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2356 </packageSources>
2357 <packageSourceMapping>
2358 <packageSource key="CorpFeed">
2359 <package pattern="MyCompany.*" />
2360 </packageSource>
2361 <packageSource key="nuget.org">
2362 <package pattern="*" />
2363 </packageSource>
2364 </packageSourceMapping>
2365 </configuration>"#,
2366 );
2367 write_config(
2368 &leaf,
2369 r#"<configuration>
2370 <packageSourceMapping>
2371 <packageSource key="nuget.org">
2372 <package pattern="*" />
2373 </packageSource>
2374 </packageSourceMapping>
2375 </configuration>"#,
2376 );
2377 let cache = NuGetConfigCache::new();
2378 let policy = all_policy();
2379 let config = resolve(&leaf, &cache, &policy);
2380
2381 let source = config.resolve_source_for(&pkg("MyCompany.Internal"));
2382 let DependencySource::AlternateRegistry { index, .. } = source else {
2383 panic!("R1 regression: MyCompany.Internal leaked to nuget.org via nearest-wins");
2384 };
2385 let chains = config.resolved_chains();
2386 let chain = chains.iter().find(|c| c.key == index).unwrap();
2387 assert_eq!(
2388 chain.hops[0].url.as_str(),
2389 "https://corp.example/v3/index.json"
2390 );
2391
2392 assert_eq!(
2395 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2396 DependencySource::Registry
2397 );
2398 }
2399
2400 #[test]
2403 fn test_protocol_version_2_rejected() {
2404 let dir = tempfile::tempdir().unwrap();
2405 write_config(
2406 dir.path(),
2407 r#"<configuration><packageSources>
2408 <add key="Legacy" value="https://legacy.example/api/v2" protocolVersion="2" />
2409 </packageSources></configuration>"#,
2410 );
2411 let cache = NuGetConfigCache::new();
2412 let policy = all_policy();
2413 let config = resolve(dir.path(), &cache, &policy);
2414
2415 assert!(config.resolved_chains().is_empty());
2416 assert_eq!(
2417 config.resolve_source_for(&pkg("Any.Package")),
2418 DependencySource::Registry
2419 );
2420 }
2421
2422 #[test]
2423 fn test_local_feed_path_rejected() {
2424 let dir = tempfile::tempdir().unwrap();
2425 write_config(
2426 dir.path(),
2427 r#"<configuration><packageSources>
2428 <add key="Local" value="../packages" />
2429 </packageSources></configuration>"#,
2430 );
2431 let cache = NuGetConfigCache::new();
2432 let policy = all_policy();
2433 let config = resolve(dir.path(), &cache, &policy);
2434
2435 assert!(config.resolved_chains().is_empty());
2436 }
2437
2438 #[test]
2441 fn test_resolve_source_for_and_resolved_chains_agree_on_key() {
2442 let dir = tempfile::tempdir().unwrap();
2443 write_config(
2444 dir.path(),
2445 r#"<configuration><packageSources>
2446 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2447 </packageSources></configuration>"#,
2448 );
2449 let cache = NuGetConfigCache::new();
2450 let policy = all_policy();
2451 let config = resolve(dir.path(), &cache, &policy);
2452
2453 let DependencySource::AlternateRegistry { index, .. } =
2454 config.resolve_source_for(&pkg("Any.Package"))
2455 else {
2456 panic!("expected AlternateRegistry");
2457 };
2458 assert_eq!(config.resolved_chains()[0].key, index);
2459 }
2460
2461 #[test]
2464 fn test_config_cache_reparses_after_mtime_change() {
2465 let dir = tempfile::tempdir().unwrap();
2466 let path = dir.path().join("NuGet.Config");
2467 std::fs::write(
2468 &path,
2469 r#"<configuration><packageSources><add key="A" value="https://a.example/v3/index.json" /></packageSources></configuration>"#,
2470 )
2471 .unwrap();
2472 let cache = NuGetConfigCache::new();
2473 let first = cache.get_or_parse(&path).unwrap();
2474 assert_eq!(first.sources.len(), 1);
2475
2476 let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
2477 std::fs::write(
2478 &path,
2479 r#"<configuration><packageSources>
2480 <add key="A" value="https://a.example/v3/index.json" />
2481 <add key="B" value="https://b.example/v3/index.json" />
2482 </packageSources></configuration>"#,
2483 )
2484 .unwrap();
2485 std::fs::OpenOptions::new()
2486 .write(true)
2487 .open(&path)
2488 .unwrap()
2489 .set_modified(future)
2490 .unwrap();
2491
2492 let second = cache.get_or_parse(&path).unwrap();
2493 assert_eq!(second.sources.len(), 2);
2494 }
2495
2496 #[test]
2497 fn test_resolve_with_no_config_anywhere_is_default() {
2498 let dir = tempfile::tempdir().unwrap();
2499 let cache = NuGetConfigCache::new();
2500 let policy = all_policy();
2501 let config = resolve(dir.path(), &cache, &policy);
2502 assert!(config.resolved_chains().is_empty());
2503 assert_eq!(
2504 config.resolve_source_for(&pkg("Any.Package")),
2505 DependencySource::Registry
2506 );
2507 }
2508
2509 #[test]
2517 fn test_h1_malformed_xml_degrades_to_all_default_not_partial() {
2518 let raw = parse_nuget_config_raw(
2519 r#"<configuration><packageSources>
2520 <clear />
2521 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2522 </packageSource></configuration>"#,
2523 );
2524 assert!(!raw.sources_cleared);
2525 assert!(raw.sources.is_empty());
2526 }
2527
2528 #[test]
2529 fn test_h1_malformed_config_file_resolves_as_if_absent() {
2530 let dir = tempfile::tempdir().unwrap();
2531 write_config(
2532 dir.path(),
2533 r#"<configuration><packageSources>
2534 <clear />
2535 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2536 </packageSource></configuration>"#,
2537 );
2538 let cache = NuGetConfigCache::new();
2539 let policy = all_policy();
2540 let config = resolve(dir.path(), &cache, &policy);
2541 assert!(config.resolved_chains().is_empty());
2542 assert_eq!(
2543 config.resolve_source_for(&pkg("Any.Package")),
2544 DependencySource::Registry
2545 );
2546 }
2547
2548 #[test]
2551 fn test_s2_self_closing_credentials_section_does_not_swallow_later_elements() {
2552 let dir = tempfile::tempdir().unwrap();
2553 write_config(
2554 dir.path(),
2555 r#"<configuration>
2556 <packageSourceCredentials />
2557 <packageSources>
2558 <clear />
2559 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2560 </packageSources>
2561 </configuration>"#,
2562 );
2563 let cache = NuGetConfigCache::new();
2564 let policy = all_policy();
2565 let config = resolve(dir.path(), &cache, &policy);
2566
2567 let chains = config.resolved_chains();
2568 assert_eq!(chains.len(), 1, "packageSources must not be swallowed");
2569 assert!(!chains[0].implicit_public_fallback);
2570 }
2571
2572 #[test]
2573 fn test_s2_self_closing_sources_section_does_not_latch() {
2574 let dir = tempfile::tempdir().unwrap();
2575 write_config(
2576 dir.path(),
2577 r#"<configuration>
2578 <packageSources />
2579 <disabledPackageSources>
2580 <add key="CorpFeed" value="true" />
2581 </disabledPackageSources>
2582 <packageSources>
2583 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2584 </packageSources>
2585 </configuration>"#,
2586 );
2587 let cache = NuGetConfigCache::new();
2588 let policy = all_policy();
2589 let config = resolve(dir.path(), &cache, &policy);
2590
2591 assert!(config.resolved_chains().is_empty());
2596 assert_eq!(
2597 config.resolve_source_for(&pkg("Any.Package")),
2598 DependencySource::Registry
2599 );
2600 }
2601
2602 #[test]
2605 fn test_s1_mapping_undeclared_nuget_org_key_falls_back_to_real_public_source() {
2606 let dir = tempfile::tempdir().unwrap();
2607 write_config(
2608 dir.path(),
2609 r#"<configuration>
2610 <packageSources>
2611 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2612 </packageSources>
2613 <packageSourceMapping>
2614 <packageSource key="CorpFeed">
2615 <package pattern="MyCompany.*" />
2616 </packageSource>
2617 <packageSource key="nuget.org">
2618 <package pattern="*" />
2619 </packageSource>
2620 </packageSourceMapping>
2621 </configuration>"#,
2622 );
2623 let cache = NuGetConfigCache::new();
2624 let policy = all_policy();
2625 let config = resolve(dir.path(), &cache, &policy);
2626
2627 assert_eq!(
2631 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2632 DependencySource::Registry
2633 );
2634 assert_matches!(
2636 config.resolve_source_for(&pkg("MyCompany.Internal")),
2637 DependencySource::AlternateRegistry { .. }
2638 );
2639 }
2640
2641 #[test]
2642 fn test_s1_mapping_undeclared_key_other_than_nuget_org_still_fails_closed() {
2643 let dir = tempfile::tempdir().unwrap();
2644 write_config(
2645 dir.path(),
2646 r#"<configuration>
2647 <packageSourceMapping>
2648 <packageSource key="SomeOtherUndeclaredFeed">
2649 <package pattern="*" />
2650 </packageSource>
2651 </packageSourceMapping>
2652 </configuration>"#,
2653 );
2654 let cache = NuGetConfigCache::new();
2655 let policy = all_policy();
2656 let config = resolve(dir.path(), &cache, &policy);
2657
2658 assert_eq!(
2659 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2660 DependencySource::CustomRegistry {
2661 url: "Newtonsoft.Json".to_string(),
2662 }
2663 );
2664 }
2665
2666 #[test]
2669 fn test_s4_remove_excludes_previously_declared_source() {
2670 let dir = tempfile::tempdir().unwrap();
2671 write_config(
2672 dir.path(),
2673 r#"<configuration><packageSources>
2674 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2675 <remove key="CorpFeed" />
2676 </packageSources></configuration>"#,
2677 );
2678 let cache = NuGetConfigCache::new();
2679 let policy = all_policy();
2680 let config = resolve(dir.path(), &cache, &policy);
2681
2682 assert!(config.resolved_chains().is_empty());
2683 assert_eq!(
2684 config.resolve_source_for(&pkg("Any.Package")),
2685 DependencySource::Registry
2686 );
2687 }
2688
2689 #[test]
2690 fn test_s4_remove_across_ancestor_files() {
2691 let root = tempfile::tempdir().unwrap();
2692 let leaf = root.path().join("src").join("App");
2693 std::fs::create_dir_all(&leaf).unwrap();
2694 write_config(
2695 root.path(),
2696 r#"<configuration><packageSources>
2697 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2698 </packageSources></configuration>"#,
2699 );
2700 write_config(
2701 &leaf,
2702 r#"<configuration><packageSources>
2703 <remove key="CorpFeed" />
2704 </packageSources></configuration>"#,
2705 );
2706 let cache = NuGetConfigCache::new();
2707 let policy = all_policy();
2708 let config = resolve(&leaf, &cache, &policy);
2709
2710 assert!(config.resolved_chains().is_empty());
2711 assert_eq!(
2712 config.resolve_source_for(&pkg("Any.Package")),
2713 DependencySource::Registry
2714 );
2715 }
2716
2717 #[test]
2721 fn test_s4_remove_nuget_org_suppresses_implicit_fallback() {
2722 let dir = tempfile::tempdir().unwrap();
2723 write_config(
2724 dir.path(),
2725 r#"<configuration><packageSources>
2726 <remove key="nuget.org" />
2727 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2728 </packageSources></configuration>"#,
2729 );
2730 let cache = NuGetConfigCache::new();
2731 let policy = all_policy();
2732 let config = resolve(dir.path(), &cache, &policy);
2733
2734 let chains = config.resolved_chains();
2735 assert_eq!(chains.len(), 1);
2736 assert!(
2737 !chains[0].implicit_public_fallback,
2738 "explicitly-removed nuget.org must not be resurrected as the implicit tail"
2739 );
2740 }
2741
2742 #[test]
2745 fn test_s4_remove_nuget_org_alone_fails_closed() {
2746 let dir = tempfile::tempdir().unwrap();
2747 write_config(
2748 dir.path(),
2749 r#"<configuration><packageSources>
2750 <remove key="nuget.org" />
2751 </packageSources></configuration>"#,
2752 );
2753 let cache = NuGetConfigCache::new();
2754 let policy = all_policy();
2755 let config = resolve(dir.path(), &cache, &policy);
2756
2757 assert!(config.resolved_chains().is_empty());
2758 assert_matches!(
2759 config.resolve_source_for(&pkg("Any.Package")),
2760 DependencySource::CustomRegistry { .. }
2761 );
2762 }
2763
2764 #[test]
2767 fn test_m2_explicit_clear_plus_nuget_org_add_resolves_to_plain_registry() {
2768 let dir = tempfile::tempdir().unwrap();
2769 write_config(
2770 dir.path(),
2771 r#"<configuration><packageSources>
2772 <clear />
2773 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2774 </packageSources></configuration>"#,
2775 );
2776 let cache = NuGetConfigCache::new();
2777 let policy = all_policy();
2778 let config = resolve(dir.path(), &cache, &policy);
2779
2780 assert_eq!(
2781 config.resolve_source_for(&pkg("Newtonsoft.Json")),
2782 DependencySource::Registry
2783 );
2784 assert!(config.resolved_chains().is_empty());
2785 }
2786
2787 #[test]
2790 fn test_resolve_keys_for_exact_beats_longer_prefix() {
2791 let mut mapping = PackageSourceMapping::default();
2792 mapping.extend("ExactSource", &["MyCompany.Foo".to_string()]);
2793 mapping.extend("PrefixSource", &["MyCompany.*".to_string()]);
2794
2795 let keys = mapping.resolve_keys_for("mycompany.foo").unwrap();
2796 assert_eq!(keys, vec!["ExactSource"]);
2797 }
2798
2799 #[test]
2800 fn test_resolve_keys_for_longer_prefix_beats_shorter_prefix() {
2801 let mut mapping = PackageSourceMapping::default();
2802 mapping.extend("ShortPrefix", &["My.*".to_string()]);
2803 mapping.extend("LongPrefix", &["My.Company.*".to_string()]);
2804
2805 let keys = mapping.resolve_keys_for("my.company.internal").unwrap();
2806 assert_eq!(keys, vec!["LongPrefix"]);
2807 }
2808
2809 #[test]
2810 fn test_resolve_keys_for_prefix_beats_wildcard() {
2811 let mut mapping = PackageSourceMapping::default();
2812 mapping.extend("Wildcard", &["*".to_string()]);
2813 mapping.extend("Prefix", &["My.*".to_string()]);
2814
2815 let keys = mapping.resolve_keys_for("my.internal").unwrap();
2816 assert_eq!(keys, vec!["Prefix"]);
2817 }
2818
2819 #[test]
2823 fn test_resolve_keys_for_tie_on_identical_pattern_fans_out() {
2824 let mut mapping = PackageSourceMapping::default();
2825 mapping.extend("SourceA", &["*".to_string()]);
2826 mapping.extend("SourceB", &["*".to_string()]);
2827
2828 let mut keys = mapping.resolve_keys_for("any.package").unwrap();
2829 keys.sort_unstable();
2830 assert_eq!(keys, vec!["SourceA", "SourceB"]);
2831 }
2832
2833 #[test]
2839 fn test_r4_mapping_winning_pattern_resolves_only_to_disabled_source() {
2840 let dir = tempfile::tempdir().unwrap();
2841 write_config(
2842 dir.path(),
2843 r#"<configuration>
2844 <packageSources>
2845 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2846 </packageSources>
2847 <disabledPackageSources>
2848 <add key="CorpFeed" value="true" />
2849 </disabledPackageSources>
2850 <packageSourceMapping>
2851 <packageSource key="CorpFeed">
2852 <package pattern="MyCompany.*" />
2853 </packageSource>
2854 </packageSourceMapping>
2855 </configuration>"#,
2856 );
2857 let cache = NuGetConfigCache::new();
2858 let policy = all_policy();
2859 let config = resolve(dir.path(), &cache, &policy);
2860
2861 assert_eq!(
2862 config.resolve_source_for(&pkg("MyCompany.Internal")),
2863 DependencySource::CustomRegistry {
2864 url: "MyCompany.Internal".to_string(),
2865 }
2866 );
2867 assert!(config.resolved_chains().is_empty());
2868 }
2869
2870 #[test]
2873 fn test_upsert_source_dedupes_across_xml_encoded_key_variants() {
2874 let root = tempfile::tempdir().unwrap();
2875 let leaf = root.path().join("src").join("App");
2876 std::fs::create_dir_all(&leaf).unwrap();
2877 write_config(
2878 root.path(),
2879 r#"<configuration><packageSources>
2880 <add key="Corp Feed" value="https://old.example/v3/index.json" />
2881 </packageSources></configuration>"#,
2882 );
2883 write_config(
2884 &leaf,
2885 r#"<configuration><packageSources>
2886 <add key="Corp_x0020_Feed" value="https://new.example/v3/index.json" />
2887 </packageSources></configuration>"#,
2888 );
2889 let cache = NuGetConfigCache::new();
2890 let policy = all_policy();
2891 let config = resolve(&leaf, &cache, &policy);
2892
2893 let chains = config.resolved_chains();
2894 assert_eq!(chains.len(), 1);
2895 assert_eq!(
2896 chains[0].hops.len(),
2897 1,
2898 "XML-name-equivalent keys must upsert into one entry, not two"
2899 );
2900 assert_eq!(
2901 chains[0].hops[0].url.as_str(),
2902 "https://new.example/v3/index.json"
2903 );
2904 }
2905
2906 fn write_user_profile(dir: &Path, content: &str) -> PathBuf {
2909 let path = dir.join("UserProfile.NuGet.Config");
2910 std::fs::write(&path, content).unwrap();
2911 path
2912 }
2913
2914 fn resolve_ctx(
2915 repo_dir: &Path,
2916 cache: &NuGetConfigCache,
2917 policy: &RegistryAccessPolicy,
2918 user_profile: Option<&Path>,
2919 flag_on: bool,
2920 ) -> NuGetConfig {
2921 resolve_with_context(
2922 repo_dir,
2923 cache,
2924 policy,
2925 user_profile,
2926 &AtomicBool::new(flag_on),
2927 )
2928 }
2929
2930 const CORP_CRED_USER_PROFILE: &str = r#"<configuration>
2931 <packageSources>
2932 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2933 </packageSources>
2934 <packageSourceCredentials>
2935 <CorpFeed>
2936 <add key="Username" value="user" />
2937 <add key="ClearTextPassword" value="pat-value" />
2938 </CorpFeed>
2939 </packageSourceCredentials>
2940 </configuration>"#;
2941
2942 #[test]
2945 fn test_c2_exact_url_match_attaches_credential() {
2946 let root = tempfile::tempdir().unwrap();
2947 let repo = root.path().join("repo");
2948 std::fs::create_dir_all(&repo).unwrap();
2949 let user_profile = write_user_profile(root.path(), CORP_CRED_USER_PROFILE);
2950 write_config(
2951 &repo,
2952 r#"<configuration><packageSources>
2953 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2954 </packageSources></configuration>"#,
2955 );
2956 let cache = NuGetConfigCache::new();
2957 let policy = all_policy();
2958 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
2959
2960 let chains = config.resolved_chains();
2961 assert_eq!(chains.len(), 1);
2962 assert_eq!(chains[0].hops.len(), 1);
2963 assert!(
2964 chains[0].hops[0].auth.is_some(),
2965 "matching-URL repo entry must receive the user-profile credential"
2966 );
2967 assert_eq!(chains[0].hops[0].slot.as_deref(), Some("corpfeed"));
2968 }
2969
2970 #[test]
2973 fn test_c2_same_origin_different_path_fails_closed() {
2974 let root = tempfile::tempdir().unwrap();
2975 let repo = root.path().join("repo");
2976 std::fs::create_dir_all(&repo).unwrap();
2977 let user_profile = write_user_profile(
2978 root.path(),
2979 r#"<configuration>
2980 <packageSources>
2981 <add key="CorpFeed" value="https://pkgs.dev.azure.com/real-org/_packaging/x/nuget/v3/index.json" />
2982 </packageSources>
2983 <packageSourceCredentials>
2984 <CorpFeed>
2985 <add key="Username" value="user" />
2986 <add key="ClearTextPassword" value="pat" />
2987 </CorpFeed>
2988 </packageSourceCredentials>
2989 </configuration>"#,
2990 );
2991 write_config(
2992 &repo,
2993 r#"<configuration><packageSources>
2994 <add key="CorpFeed" value="https://pkgs.dev.azure.com/attacker-org/_packaging/x/nuget/v3/index.json" />
2995 </packageSources></configuration>"#,
2996 );
2997 let cache = NuGetConfigCache::new();
2998 let policy = all_policy();
2999 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3000
3001 assert!(
3002 config.resolved_chains().is_empty(),
3003 "URL mismatch must fail the source closed, not attach nor route it"
3004 );
3005 }
3006
3007 #[test]
3012 fn test_c2_condition_0_suppressed_key_fails_closed_not_machine_disabled() {
3013 let root = tempfile::tempdir().unwrap();
3014 let repo = root.path().join("repo");
3015 std::fs::create_dir_all(&repo).unwrap();
3016 let user_profile = write_user_profile(
3017 root.path(),
3018 r#"<configuration>
3019 <packageSources>
3020 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3021 </packageSources>
3022 <packageSourceCredentials>
3023 <CorpFeed>
3024 <add key="Username" value="user" />
3025 <add key="ClearTextPassword" value="pat" />
3026 </CorpFeed>
3027 </packageSourceCredentials>
3028 <disabledPackageSources>
3029 <add key="CorpFeed" value="true" />
3030 </disabledPackageSources>
3031 </configuration>"#,
3032 );
3033 write_config(
3034 &repo,
3035 r#"<configuration><packageSources>
3036 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3037 </packageSources></configuration>"#,
3038 );
3039 let cache = NuGetConfigCache::new();
3040 let policy = all_policy();
3041 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3042
3043 assert!(config.resolved_chains().is_empty());
3047 }
3048
3049 #[test]
3054 fn test_env_var_expansion_set_and_unset() {
3055 let set = expand_env_vars_with("%CORP_FEED_PAT%", |name| {
3056 (name == "CORP_FEED_PAT").then(|| Zeroizing::new("secret-pat".to_string()))
3057 });
3058 assert_eq!(set.unwrap().as_str(), "secret-pat");
3059
3060 let unset = expand_env_vars_with("%CORP_FEED_PAT%", |_| None);
3061 assert_matches!(unset, Err(NuGetFeedUrlError::HasCredentials));
3062 }
3063
3064 #[test]
3071 fn test_env_var_expansion_edge_cases() {
3072 let lookup = |name: &str| match name {
3073 "A" => Some(Zeroizing::new("1".to_string())),
3074 "B" => Some(Zeroizing::new("2".to_string())),
3075 _ => None,
3076 };
3077
3078 assert_eq!(
3079 expand_env_vars_with("pre-%A%-post", lookup)
3080 .unwrap()
3081 .as_str(),
3082 "pre-1-post"
3083 );
3084 assert_eq!(
3085 expand_env_vars_with("%A%%B%", lookup).unwrap().as_str(),
3086 "12"
3087 );
3088 assert_eq!(
3089 expand_env_vars_with("abc%1bad!name%def", lookup)
3090 .unwrap()
3091 .as_str(),
3092 "abc%1bad!name%def"
3093 );
3094 assert_eq!(
3095 expand_env_vars_with("abc%A", lookup).unwrap().as_str(),
3096 "abc%A"
3097 );
3098 assert_eq!(expand_env_vars_with("%%", lookup).unwrap().as_str(), "%%");
3099 assert_matches!(
3100 expand_env_vars_with("pre-%UNSET%-post", lookup),
3101 Err(NuGetFeedUrlError::HasCredentials)
3102 );
3103 }
3104
3105 #[test]
3110 fn test_expand_credential_missing_password_fails_closed() {
3111 let credential = RawCredential {
3112 key: "CorpFeed".to_string(),
3113 username: Some(RedactedSecret::new("user".to_string())),
3114 password: None,
3115 encrypted: false,
3116 };
3117 assert_matches!(
3118 expand_credential(&credential),
3119 Err(NuGetFeedUrlError::HasCredentials)
3120 );
3121 }
3122
3123 #[test]
3126 fn test_nuget_auth_and_redacted_secret_never_debug_print_the_literal() {
3127 let auth = NuGetAuth::new("user", "super-secret-pat");
3129 assert!(!format!("{auth:?}").contains("super-secret-pat"));
3130 assert!(!format!("{auth}").contains("super-secret-pat"));
3131
3132 let secret = RedactedSecret::new("super-secret-pat".to_string());
3133 assert!(!format!("{secret:?}").contains("super-secret-pat"));
3134 assert!(!format!("{secret}").contains("super-secret-pat"));
3135 }
3136
3137 #[test]
3140 fn test_dpapi_encrypted_password_rejected_distinctly() {
3141 let root = tempfile::tempdir().unwrap();
3142 let repo = root.path().join("repo");
3143 std::fs::create_dir_all(&repo).unwrap();
3144 let user_profile = write_user_profile(
3145 root.path(),
3146 r#"<configuration>
3147 <packageSources>
3148 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3149 </packageSources>
3150 <packageSourceCredentials>
3151 <CorpFeed>
3152 <add key="Username" value="user" />
3153 <add key="Password" value="AQAAANCM...encrypted..." />
3154 </CorpFeed>
3155 </packageSourceCredentials>
3156 </configuration>"#,
3157 );
3158 write_config(
3159 &repo,
3160 r#"<configuration><packageSources>
3161 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3162 </packageSources></configuration>"#,
3163 );
3164 let cache = NuGetConfigCache::new();
3165 let policy = all_policy();
3166 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3167
3168 assert!(config.resolved_chains().is_empty());
3169 assert_eq!(
3174 config.resolve_source_for(&pkg("Any.Package")),
3175 DependencySource::Registry
3176 );
3177 }
3178
3179 #[test]
3183 fn test_expand_credential_encrypted_password_is_distinct_reason() {
3184 let credential = RawCredential {
3185 key: "CorpFeed".to_string(),
3186 username: Some(RedactedSecret::new("user".to_string())),
3187 password: None,
3188 encrypted: true,
3189 };
3190 assert_matches!(
3191 expand_credential(&credential),
3192 Err(NuGetFeedUrlError::EncryptedPasswordUnsupported)
3193 );
3194 }
3195
3196 #[test]
3200 fn test_repo_tier_credential_always_fails_closed_even_with_matching_user_profile() {
3201 let root = tempfile::tempdir().unwrap();
3202 let repo = root.path().join("repo");
3203 std::fs::create_dir_all(&repo).unwrap();
3204 let user_profile = write_user_profile(root.path(), CORP_CRED_USER_PROFILE);
3205 write_config(
3206 &repo,
3207 r#"<configuration>
3208 <packageSources>
3209 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3210 </packageSources>
3211 <packageSourceCredentials>
3212 <CorpFeed>
3213 <add key="Username" value="repo-user" />
3214 <add key="ClearTextPassword" value="repo-pass" />
3215 </CorpFeed>
3216 </packageSourceCredentials>
3217 </configuration>"#,
3218 );
3219 let cache = NuGetConfigCache::new();
3220 let policy = all_policy();
3221 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3222
3223 assert!(
3224 config.resolved_chains().is_empty(),
3225 "repo-tier credentialed source must fail closed regardless of C2"
3226 );
3227 }
3228
3229 #[test]
3234 fn test_fail_closed_logs_warning_with_key_and_reason() {
3235 let root = tempfile::tempdir().unwrap();
3236 let repo = root.path().join("repo");
3237 std::fs::create_dir_all(&repo).unwrap();
3238 let user_profile = write_user_profile(
3239 root.path(),
3240 r#"<configuration>
3241 <packageSourceCredentials>
3242 <CorpFeed>
3243 <add key="Username" value="user" />
3244 <add key="ClearTextPassword" value="pat" />
3245 </CorpFeed>
3246 </packageSourceCredentials>
3247 </configuration>"#,
3248 );
3249 write_config(
3250 &repo,
3251 r#"<configuration><packageSources>
3252 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3253 </packageSources></configuration>"#,
3254 );
3255 let cache = NuGetConfigCache::new();
3256 let policy = all_policy();
3257
3258 let log = deps_core::test_util::capture_tracing_output(|| {
3259 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3260 assert!(
3261 config.resolved_chains().is_empty(),
3262 "unresolvable credential binding must still fail the source closed"
3263 );
3264 });
3265
3266 assert!(
3267 log.contains("CorpFeed") && log.contains("packageSourceCredentials"),
3268 "expected fail-closed warning naming the source key and reason in log: {log:?}"
3269 );
3270 assert!(
3274 log.contains("NoMatchingUserProfileAdd"),
3275 "expected the specific C2 sub-condition cause in log: {log:?}"
3276 );
3277 }
3278
3279 #[test]
3284 fn test_fail_closed_repo_tier_and_c2_causes_are_distinguishable() {
3285 let root = tempfile::tempdir().unwrap();
3286 let repo = root.path().join("repo");
3287 std::fs::create_dir_all(&repo).unwrap();
3288 write_config(
3289 &repo,
3290 r#"<configuration>
3291 <packageSources>
3292 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3293 </packageSources>
3294 <packageSourceCredentials>
3295 <CorpFeed>
3296 <add key="Username" value="repo-user" />
3297 <add key="ClearTextPassword" value="repo-pass" />
3298 </CorpFeed>
3299 </packageSourceCredentials>
3300 </configuration>"#,
3301 );
3302 let cache = NuGetConfigCache::new();
3303 let policy = all_policy();
3304
3305 let log = deps_core::test_util::capture_tracing_output(|| {
3306 let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3307 });
3308
3309 assert!(
3310 log.contains("RepoTierCredentialed"),
3311 "expected the repo-tier cause, distinct from NoMatchingUserProfileAdd, in log: {log:?}"
3312 );
3313 assert!(
3314 !log.contains("NoMatchingUserProfileAdd"),
3315 "repo-tier cause must not be conflated with the C2 sub-condition cause: {log:?}"
3316 );
3317 }
3318
3319 #[test]
3323 fn test_fail_closed_warning_debounced_across_repeat_resolves() {
3324 let root = tempfile::tempdir().unwrap();
3325 let repo = root.path().join("repo");
3326 std::fs::create_dir_all(&repo).unwrap();
3327 let config_path = repo.join("NuGet.Config");
3328 write_config(
3329 &repo,
3330 r#"<configuration>
3331 <packageSources>
3332 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3333 </packageSources>
3334 <packageSourceCredentials>
3335 <CorpFeed>
3336 <add key="Username" value="repo-user" />
3337 <add key="ClearTextPassword" value="repo-pass" />
3338 </CorpFeed>
3339 </packageSourceCredentials>
3340 </configuration>"#,
3341 );
3342 let cache = NuGetConfigCache::new();
3343 let policy = all_policy();
3344
3345 let log = deps_core::test_util::capture_tracing_output(|| {
3346 for _ in 0..4 {
3347 let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3348 }
3349
3350 let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
3366 write_config(
3367 &repo,
3368 r#"<configuration>
3369 <packageSources>
3370 <add key="CorpFeed" value="https://corp.example/v3/index-v2.json" />
3371 </packageSources>
3372 <packageSourceCredentials>
3373 <CorpFeed>
3374 <add key="Username" value="repo-user" />
3375 <add key="ClearTextPassword" value="repo-pass" />
3376 </CorpFeed>
3377 </packageSourceCredentials>
3378 </configuration>"#,
3379 );
3380 std::fs::OpenOptions::new()
3381 .write(true)
3382 .open(&config_path)
3383 .unwrap()
3384 .set_modified(future)
3385 .unwrap();
3386
3387 for _ in 0..2 {
3388 let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3389 }
3390 });
3391
3392 assert_eq!(
3393 log.matches("fails closed on credential binding").count(),
3394 2,
3395 "expected one warning for the original content and one more after a genuine \
3396 content change: {log:?}"
3397 );
3398 }
3399
3400 #[test]
3407 fn test_disabled_source_fails_closed_at_debug_level_not_warn() {
3408 let root = tempfile::tempdir().unwrap();
3409 let repo = root.path().join("repo");
3410 std::fs::create_dir_all(&repo).unwrap();
3411 write_config(
3412 &repo,
3413 r#"<configuration>
3414 <packageSources>
3415 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3416 </packageSources>
3417 <disabledPackageSources>
3418 <add key="CorpFeed" value="true" />
3419 </disabledPackageSources>
3420 </configuration>"#,
3421 );
3422 let cache = NuGetConfigCache::new();
3423 let policy = all_policy();
3424
3425 let log = deps_core::test_util::capture_tracing_output_at(tracing::Level::DEBUG, || {
3426 let config = resolve_ctx(&repo, &cache, &policy, None, false);
3427 assert_eq!(
3428 config.resolve_source_for(&pkg("CorpFeed.Package")),
3429 DependencySource::Registry,
3430 "a disabled-but-not-cleared source still leaves the implicit nuget.org tail reachable"
3431 );
3432 });
3433
3434 let line = log
3435 .lines()
3436 .find(|l| l.contains("fails closed on credential binding"))
3437 .unwrap_or_else(|| panic!("expected a fail-closed log line at DEBUG level: {log:?}"));
3438 assert!(
3439 line.contains("MachineDisabled"),
3440 "expected the MachineDisabled cause on the fail-closed line: {line}"
3441 );
3442 assert!(
3443 line.contains("DEBUG"),
3444 "MachineDisabled must log at debug!: {line}"
3445 );
3446 assert!(
3447 !line.contains("WARN"),
3448 "MachineDisabled must not log at warn!: {line}"
3449 );
3450 }
3451
3452 #[test]
3459 fn test_dpapi_encrypted_password_does_not_double_log() {
3460 let root = tempfile::tempdir().unwrap();
3461 let repo = root.path().join("repo");
3462 std::fs::create_dir_all(&repo).unwrap();
3463 let user_profile = write_user_profile(
3464 root.path(),
3465 r#"<configuration>
3466 <packageSources>
3467 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3468 </packageSources>
3469 <packageSourceCredentials>
3470 <CorpFeed>
3471 <add key="Username" value="user" />
3472 <add key="Password" value="AQAAANCM...encrypted..." />
3473 </CorpFeed>
3474 </packageSourceCredentials>
3475 </configuration>"#,
3476 );
3477 write_config(
3478 &repo,
3479 r#"<configuration><packageSources>
3480 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3481 </packageSources></configuration>"#,
3482 );
3483 let cache = NuGetConfigCache::new();
3484 let policy = all_policy();
3485
3486 let log = deps_core::test_util::capture_tracing_output_at(tracing::Level::DEBUG, || {
3487 let _ = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3488 });
3489
3490 assert_eq!(
3491 log.matches("DPAPI-encrypted <Password> is not supported")
3492 .count(),
3493 1,
3494 "expected exactly one debug! from expand_credential: {log:?}"
3495 );
3496 assert!(
3497 !log.contains("fails closed on credential binding"),
3498 "fail_closed must not double-log the DPAPI case at any level: {log:?}"
3499 );
3500 }
3501
3502 #[test]
3505 fn test_public_index_carve_out_never_blocks_or_authenticates() {
3506 let root = tempfile::tempdir().unwrap();
3507 let repo = root.path().join("repo");
3508 std::fs::create_dir_all(&repo).unwrap();
3509 let user_profile = write_user_profile(
3510 root.path(),
3511 r#"<configuration>
3512 <packageSources>
3513 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
3514 </packageSources>
3515 <packageSourceCredentials>
3516 <nuget.org>
3517 <add key="Username" value="user" />
3518 <add key="ClearTextPassword" value="upstream-pat" />
3519 </nuget.org>
3520 </packageSourceCredentials>
3521 </configuration>"#,
3522 );
3523 write_config(
3524 &repo,
3525 r#"<configuration><packageSources>
3526 <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
3527 </packageSources></configuration>"#,
3528 );
3529 let cache = NuGetConfigCache::new();
3530 let policy = all_policy();
3531 let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3532
3533 assert_eq!(
3535 config.resolve_source_for(&pkg("Newtonsoft.Json")),
3536 DependencySource::Registry
3537 );
3538 }
3539
3540 #[test]
3544 fn test_flag_off_user_profile_routing_directives_have_zero_effect() {
3545 let root = tempfile::tempdir().unwrap();
3546 let repo = root.path().join("repo");
3547 std::fs::create_dir_all(&repo).unwrap();
3548 write_config(
3549 &repo,
3550 r#"<configuration><packageSources>
3551 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3552 </packageSources></configuration>"#,
3553 );
3554 let user_profile = write_user_profile(
3555 root.path(),
3556 r#"<configuration>
3557 <packageSources>
3558 <clear />
3559 <add key="EvilFeed" value="https://evil.example/v3/index.json" />
3560 </packageSources>
3561 <disabledPackageSources>
3562 <add key="CorpFeed" value="true" />
3563 </disabledPackageSources>
3564 <packageSourceMapping>
3565 <packageSource key="EvilFeed">
3566 <package pattern="*" />
3567 </packageSource>
3568 </packageSourceMapping>
3569 </configuration>"#,
3570 );
3571 let cache = NuGetConfigCache::new();
3572 let policy = all_policy();
3573
3574 let with_profile = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3575 let without_profile = resolve_ctx(&repo, &cache, &policy, None, false);
3576
3577 let hops_of = |c: &NuGetConfig| -> Vec<String> {
3578 c.valid_hops()
3579 .into_iter()
3580 .map(|h| h.url.as_str().to_string())
3581 .collect()
3582 };
3583 assert_eq!(hops_of(&with_profile), hops_of(&without_profile));
3584 assert_eq!(
3585 with_profile.resolve_source_for(&pkg("Any.Package")),
3586 without_profile.resolve_source_for(&pkg("Any.Package"))
3587 );
3588 }
3589
3590 #[test]
3593 fn test_flag_on_user_profile_only_source_becomes_routing_hop() {
3594 let root = tempfile::tempdir().unwrap();
3595 let repo = root.path().join("repo");
3596 std::fs::create_dir_all(&repo).unwrap();
3597 let user_profile = write_user_profile(
3598 root.path(),
3599 r#"<configuration><packageSources>
3600 <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3601 </packageSources></configuration>"#,
3602 );
3603 let cache = NuGetConfigCache::new();
3604 let policy = all_policy();
3605
3606 let flag_off = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3607 assert!(flag_off.resolved_chains().is_empty());
3608
3609 let flag_on = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), true);
3610 assert_matches!(
3611 flag_on.resolve_source_for(&pkg("Any.Package")),
3612 DependencySource::AlternateRegistry { .. }
3613 );
3614 }
3615
3616 #[test]
3621 fn test_unique_overlap_non_transitive_aliasing_is_ambiguous() {
3622 let policy = all_policy();
3623 let items = [
3624 source(
3625 "Corp_x005f_x0020_Feed",
3626 "https://a.example/v3/index.json",
3627 &policy,
3628 ),
3629 source("Corp Feed", "https://b.example/v3/index.json", &policy),
3630 ];
3631 assert!(unique_overlap("Corp_x0020_Feed", &items, |s| s.key.as_str()).is_none());
3632 }
3633}