1use std::collections::{HashMap, HashSet};
39use std::path::{Path, PathBuf};
40use std::sync::Arc;
41use toml_span::value::Table;
42
43use deps_core::net_policy::{
44 HostClass, PolicyGate, RegistryAccessPolicy, redact_userinfo, validate_index_url,
45};
46use deps_core::{DEFAULT_MAX_CACHED_FILES, MtimeFileCache};
47
48#[derive(Clone, PartialEq, Eq)]
57pub struct AuthToken(deps_core::secret::Redacted);
58
59impl AuthToken {
60 pub(crate) fn new(token: String) -> Self {
64 Self(deps_core::secret::Redacted::new(token))
65 }
66
67 pub(crate) fn expose_secret(&self) -> &str {
70 self.0.expose_secret()
71 }
72}
73
74impl std::fmt::Debug for AuthToken {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 f.write_str("AuthToken(***)")
77 }
78}
79
80impl std::fmt::Display for AuthToken {
81 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 f.write_str("***")
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Provenance {
95 CargoHome,
99 Workspace,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum IndexTrust {
114 Trusted,
117 WorkspaceDeclared,
121}
122
123impl IndexTrust {
124 #[must_use]
134 pub(crate) const fn min(self, other: Self) -> Self {
135 match (self, other) {
136 (Self::WorkspaceDeclared, _) | (_, Self::WorkspaceDeclared) => Self::WorkspaceDeclared,
137 (Self::Trusted, Self::Trusted) => Self::Trusted,
138 }
139 }
140}
141
142#[derive(Debug, Clone, PartialEq, Eq, Hash)]
151pub struct RegistryIndex {
152 url: url::Url,
153 trust: IndexTrust,
159}
160
161pub use deps_core::net_policy::IndexUrlError as RegistryIndexError;
166
167impl RegistryIndex {
168 pub fn new(
207 raw: &str,
208 trust: IndexTrust,
209 policy: &RegistryAccessPolicy,
210 ) -> Result<Self, RegistryIndexError> {
211 let stripped = raw.strip_prefix("sparse+").unwrap_or(raw);
212 let gate = match trust {
213 IndexTrust::Trusted => PolicyGate::Skip,
214 IndexTrust::WorkspaceDeclared => PolicyGate::Enforce(policy),
215 };
216 let url = validate_index_url(stripped, stripped, "cargo", gate)?;
217 Ok(Self { url, trust })
218 }
219
220 #[must_use]
231 pub(crate) fn builtin(raw: &'static str) -> Self {
232 let policy = RegistryAccessPolicy::default();
233 Self::new(raw, IndexTrust::Trusted, &policy).unwrap_or_else(|error| {
234 panic!("builtin registry index {raw:?} failed validation: {error}")
235 })
236 }
237
238 #[must_use]
241 pub fn as_str(&self) -> &str {
242 self.url.as_str()
243 }
244
245 #[must_use]
247 pub const fn trust(&self) -> IndexTrust {
248 self.trust
249 }
250}
251
252impl std::fmt::Display for RegistryIndex {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.write_str(self.as_str())
255 }
256}
257
258#[derive(Debug, Clone)]
260pub struct ResolvedRegistryEntry {
261 pub index: RegistryIndex,
263 pub auth: Option<AuthToken>,
267 pub provenance: Provenance,
269}
270
271#[derive(Debug, Default)]
278pub struct CargoConfig {
279 registries: HashMap<String, ResolvedRegistryEntry>,
280 blocked: HashMap<String, HostClass>,
286}
287
288impl CargoConfig {
289 #[must_use]
291 pub fn get(&self, alias: &str) -> Option<&ResolvedRegistryEntry> {
292 self.registries.get(alias)
293 }
294
295 #[must_use]
298 pub(crate) fn blocked_class(&self, alias: &str) -> Option<HostClass> {
299 self.blocked.get(alias).copied()
300 }
301}
302
303#[derive(Debug, Clone, PartialEq)]
306pub enum SourceReplacement {
307 None,
312 SparseMirror {
314 index: RegistryIndex,
316 auth: Option<AuthToken>,
322 },
323}
324
325#[derive(Debug, Clone, PartialEq, Eq)]
329struct SourceEntry {
330 kind: Option<SourceKind>,
335 replace_with: Option<String>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
346enum SourceKind {
347 SparseRegistry {
349 raw: String,
351 },
352 NonSparse,
356}
357
358fn classify_source_kind(entry: &Table<'_>) -> Option<SourceKind> {
360 if let Some(registry) = entry.get("registry").and_then(|v| v.as_str()) {
361 return Some(if registry.starts_with("sparse+") {
362 SourceKind::SparseRegistry {
363 raw: registry.to_string(),
364 }
365 } else {
366 SourceKind::NonSparse
367 });
368 }
369 let has_non_sparse_field = ["directory", "local-registry", "git"]
370 .iter()
371 .any(|field| entry.get(*field).and_then(|v| v.as_str()).is_some());
372 if has_non_sparse_field {
373 return Some(SourceKind::NonSparse);
374 }
375 None
376}
377
378const MAX_SOURCE_ENTRIES: usize = 256;
383
384fn parse_source_entries_raw(content: &str) -> HashMap<String, SourceEntry> {
391 let mut out = HashMap::new();
392 if deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH).is_err() {
393 tracing::warn!("skipping [source] tables: nesting depth exceeds maximum");
394 return out;
395 }
396 let Ok(doc) = toml_span::parse(content) else {
397 return out;
398 };
399 let Some(sources) = doc
400 .as_table()
401 .and_then(|t| t.get("source"))
402 .and_then(|v| v.as_table())
403 else {
404 return out;
405 };
406 for (key, value) in sources {
407 if out.len() >= MAX_SOURCE_ENTRIES {
408 tracing::warn!(
409 cap = MAX_SOURCE_ENTRIES,
410 "[source] table count exceeds maximum; ignoring remaining entries"
411 );
412 break;
413 }
414 let Some(entry_table) = value.as_table() else {
415 continue;
416 };
417 let kind = classify_source_kind(entry_table);
418 let replace_with = entry_table
419 .get("replace-with")
420 .and_then(|v| v.as_str())
421 .map(String::from);
422 out.insert(key.name.to_string(), SourceEntry { kind, replace_with });
423 }
424 out
425}
426
427fn parse_raw_index_field(entry: &Table<'_>) -> Option<String> {
432 entry
433 .get("index")
434 .and_then(|v| v.as_str())
435 .map(String::from)
436}
437
438fn parse_workspace_registries_raw(content: &str) -> HashMap<String, String> {
446 let mut out = HashMap::new();
447 if deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH).is_err() {
448 tracing::warn!("skipping .cargo/config.toml: nesting depth exceeds maximum");
449 return out;
450 }
451 let Ok(doc) = toml_span::parse(content) else {
452 return out;
453 };
454 let Some(registries) = doc
455 .as_table()
456 .and_then(|t| t.get("registries"))
457 .and_then(|v| v.as_table())
458 else {
459 return out;
460 };
461 for (key, value) in registries {
462 let Some(entry) = value.as_table() else {
463 continue;
464 };
465 if let Some(raw_index) = parse_raw_index_field(entry) {
466 out.insert(key.name.to_string(), raw_index);
467 }
468 }
469 out
470}
471
472fn parse_cargo_home_registries_raw(content: &str) -> HashMap<String, (String, Option<AuthToken>)> {
476 let mut out = HashMap::new();
477 if deps_core::check_toml_nesting_depth(content, deps_core::MAX_TOML_NESTING_DEPTH).is_err() {
478 tracing::warn!("skipping $CARGO_HOME/config.toml: nesting depth exceeds maximum");
479 return out;
480 }
481 let Ok(doc) = toml_span::parse(content) else {
482 return out;
483 };
484 let Some(registries) = doc
485 .as_table()
486 .and_then(|t| t.get("registries"))
487 .and_then(|v| v.as_table())
488 else {
489 return out;
490 };
491 for (key, value) in registries {
492 let Some(entry) = value.as_table() else {
493 continue;
494 };
495 let Some(raw_index) = parse_raw_index_field(entry) else {
496 continue;
497 };
498 let token = entry
499 .get("token")
500 .and_then(|v| v.as_str())
501 .map(|t| AuthToken::new(t.to_string()));
502 out.insert(key.name.to_string(), (raw_index, token));
503 }
504 out
505}
506
507fn env_var_name(alias: &str, suffix: &str) -> String {
511 let screaming = alias.to_uppercase().replace('-', "_");
512 format!("CARGO_REGISTRIES_{screaming}_{suffix}")
513}
514
515#[derive(Debug)]
524struct ParsedConfigFile {
525 tier: CachedTier,
526 sources: HashMap<String, SourceEntry>,
527}
528
529#[derive(Debug)]
538enum CachedTier {
539 Workspace(HashMap<String, String>),
541 CargoHome(HashMap<String, (String, Option<AuthToken>)>),
543}
544
545#[derive(Debug)]
559pub struct ConfigFileCache(MtimeFileCache<ParsedConfigFile>);
560
561impl Default for ConfigFileCache {
562 fn default() -> Self {
563 Self::new()
564 }
565}
566
567impl ConfigFileCache {
568 #[must_use]
570 pub fn new() -> Self {
571 Self(MtimeFileCache::new(
572 DEFAULT_MAX_CACHED_FILES,
573 "cargo config",
574 ))
575 }
576
577 fn get_or_parse_workspace(&self, path: &Path) -> Option<Arc<ParsedConfigFile>> {
580 self.0.get_or_parse(path, |content| ParsedConfigFile {
581 tier: CachedTier::Workspace(parse_workspace_registries_raw(content)),
582 sources: parse_source_entries_raw(content),
583 })
584 }
585
586 fn get_or_parse_cargo_home(&self, path: &Path) -> Option<Arc<ParsedConfigFile>> {
588 self.0.get_or_parse(path, |content| ParsedConfigFile {
589 tier: CachedTier::CargoHome(parse_cargo_home_registries_raw(content)),
590 sources: parse_source_entries_raw(content),
591 })
592 }
593}
594
595#[must_use]
601pub fn cargo_home_config_path() -> Option<PathBuf> {
602 cargo_home_config_path_with_env(|name| std::env::var_os(name))
603}
604
605fn cargo_home_config_path_with_env(
609 env: impl Fn(&str) -> Option<std::ffi::OsString>,
610) -> Option<PathBuf> {
611 env("CARGO_HOME").map(|home| PathBuf::from(home).join("config.toml"))
612}
613
614struct LoadedTiers {
618 workspace: Vec<Arc<ParsedConfigFile>>,
619 cargo_home: Option<Arc<ParsedConfigFile>>,
620}
621
622fn load_tiers(
623 workspace_config_paths: &[PathBuf],
624 cargo_home_config_path: Option<&Path>,
625 config_cache: &ConfigFileCache,
626) -> LoadedTiers {
627 let cargo_home_canonical = cargo_home_config_path.and_then(|p| std::fs::canonicalize(p).ok());
636
637 let workspace = workspace_config_paths
638 .iter()
639 .filter(|path| {
640 std::fs::canonicalize(path).ok().as_deref() != cargo_home_canonical.as_deref()
641 })
642 .filter_map(|path| config_cache.get_or_parse_workspace(path))
643 .collect();
644
645 let cargo_home =
646 cargo_home_config_path.and_then(|path| config_cache.get_or_parse_cargo_home(path));
647
648 LoadedTiers {
649 workspace,
650 cargo_home,
651 }
652}
653
654#[must_use]
692pub fn resolve(
693 referenced_aliases: &HashSet<String>,
694 workspace_config_paths: &[PathBuf],
695 cargo_home_config_path: Option<&Path>,
696 config_cache: &ConfigFileCache,
697 policy: &RegistryAccessPolicy,
698) -> (CargoConfig, SourceReplacement) {
699 resolve_with_env(
700 referenced_aliases,
701 workspace_config_paths,
702 cargo_home_config_path,
703 config_cache,
704 policy,
705 &|name| std::env::var(name).ok(),
706 )
707}
708
709fn resolve_with_env(
715 referenced_aliases: &HashSet<String>,
716 workspace_config_paths: &[PathBuf],
717 cargo_home_config_path: Option<&Path>,
718 config_cache: &ConfigFileCache,
719 policy: &RegistryAccessPolicy,
720 env: &dyn Fn(&str) -> Option<String>,
721) -> (CargoConfig, SourceReplacement) {
722 let tiers = load_tiers(workspace_config_paths, cargo_home_config_path, config_cache);
723
724 let registries = resolve_registries(referenced_aliases, &tiers, policy, env);
725 let source_replacement = resolve_source_chain(&tiers, policy);
726
727 (registries, source_replacement)
728}
729
730fn resolve_registries(
731 referenced_aliases: &HashSet<String>,
732 tiers: &LoadedTiers,
733 policy: &RegistryAccessPolicy,
734 env: &dyn Fn(&str) -> Option<String>,
735) -> CargoConfig {
736 let mut env_name_to_aliases: HashMap<String, Vec<&String>> = HashMap::new();
742 for alias in referenced_aliases {
743 env_name_to_aliases
744 .entry(env_var_name(alias, "INDEX"))
745 .or_default()
746 .push(alias);
747 }
748 let env_collided: HashSet<&str> = env_name_to_aliases
749 .values()
750 .filter(|aliases| aliases.len() > 1)
751 .flat_map(|aliases| {
752 let names: Vec<&str> = aliases.iter().map(|s| s.as_str()).collect();
753 let redacted: Vec<String> = names.iter().map(|name| redact_userinfo(name)).collect();
758 tracing::warn!(
759 aliases = ?redacted,
760 "two aliases derive the same CARGO_REGISTRIES_*_INDEX/_TOKEN environment \
761 variable name; ignoring the environment override for all of them"
762 );
763 names
764 })
765 .collect();
766
767 let mut registries = HashMap::new();
768 let mut blocked = HashMap::new();
769 for alias in referenced_aliases {
770 if let Some(entry) = tiers.workspace.iter().find_map(|file| match &file.tier {
771 CachedTier::Workspace(map) => map.get(alias).map(|raw_index| (raw_index, file)),
772 CachedTier::CargoHome(_) => None,
773 }) {
774 let (raw_index, _file) = entry;
775 match RegistryIndex::new(raw_index, IndexTrust::WorkspaceDeclared, policy) {
776 Ok(index) => {
777 registries.insert(
778 alias.clone(),
779 ResolvedRegistryEntry {
780 index,
781 auth: None,
782 provenance: Provenance::Workspace,
783 },
784 );
785 }
786 Err(RegistryIndexError::BlockedHost { class }) => {
787 blocked.insert(alias.clone(), class);
788 }
789 Err(error) => {
790 tracing::warn!(alias, %error, "registry index failed validation");
791 }
792 }
793 continue;
794 }
795
796 if let Some(entry) = resolve_cargo_home_tier(
797 alias,
798 tiers.cargo_home.as_deref(),
799 !env_collided.contains(alias.as_str()),
800 policy,
801 env,
802 ) {
803 registries.insert(alias.clone(), entry);
804 }
805 }
806
807 CargoConfig {
808 registries,
809 blocked,
810 }
811}
812
813fn resolve_cargo_home_tier(
816 alias: &str,
817 cargo_home_file: Option<&ParsedConfigFile>,
818 env_allowed: bool,
819 policy: &RegistryAccessPolicy,
820 env: &dyn Fn(&str) -> Option<String>,
821) -> Option<ResolvedRegistryEntry> {
822 let cargo_home_map = cargo_home_file.and_then(|file| match &file.tier {
823 CachedTier::CargoHome(map) => Some(map),
824 CachedTier::Workspace(_) => None,
825 });
826
827 if env_allowed && let Some(index_override) = env(&env_var_name(alias, "INDEX")) {
828 match RegistryIndex::new(&index_override, IndexTrust::Trusted, policy) {
829 Ok(index) => {
830 let auth = env(&env_var_name(alias, "TOKEN"))
831 .map(AuthToken::new)
832 .or_else(|| {
833 cargo_home_map
834 .and_then(|map| map.get(alias))
835 .and_then(|(_, token)| token.clone())
836 });
837 return Some(ResolvedRegistryEntry {
838 index,
839 auth,
840 provenance: Provenance::CargoHome,
841 });
842 }
843 Err(error) => {
844 tracing::warn!(alias, %error, "CARGO_REGISTRIES_*_INDEX environment override failed validation");
845 }
846 }
847 }
848
849 let (raw_index, mut auth) = cargo_home_map.and_then(|map| map.get(alias)).cloned()?;
850 if env_allowed && let Some(token_override) = env(&env_var_name(alias, "TOKEN")) {
851 auth = Some(AuthToken::new(token_override));
852 }
853 match RegistryIndex::new(&raw_index, IndexTrust::Trusted, policy) {
854 Ok(index) => Some(ResolvedRegistryEntry {
855 index,
856 auth,
857 provenance: Provenance::CargoHome,
858 }),
859 Err(error) => {
860 tracing::warn!(alias, %error, "registry index failed validation");
861 None
862 }
863 }
864}
865
866const MAX_SOURCE_REPLACEMENT_HOPS: usize = 16;
871
872fn lookup_raw_registry_index<'a>(
882 id: &str,
883 tiers: &'a LoadedTiers,
884) -> Option<(&'a str, IndexTrust)> {
885 for file in &tiers.workspace {
886 if let CachedTier::Workspace(map) = &file.tier
887 && let Some(raw) = map.get(id)
888 {
889 return Some((raw.as_str(), IndexTrust::WorkspaceDeclared));
890 }
891 }
892 if let Some(file) = &tiers.cargo_home
893 && let CachedTier::CargoHome(map) = &file.tier
894 && let Some((raw, _token)) = map.get(id)
895 {
896 return Some((raw.as_str(), IndexTrust::Trusted));
897 }
898 None
899}
900
901fn cargo_home_token_for(tiers: &LoadedTiers, id: &str) -> Option<AuthToken> {
906 let file = tiers.cargo_home.as_deref()?;
907 let CachedTier::CargoHome(map) = &file.tier else {
908 return None;
909 };
910 map.get(id).and_then(|(_, token)| token.clone())
911}
912
913fn resolve_source_chain(tiers: &LoadedTiers, policy: &RegistryAccessPolicy) -> SourceReplacement {
920 let mut current_id = "crates-io".to_string();
921 let mut visited: HashSet<String> = HashSet::new();
922 let mut chain_trust = IndexTrust::Trusted;
923
924 for _hop in 0..MAX_SOURCE_REPLACEMENT_HOPS {
925 if !visited.insert(current_id.clone()) {
926 tracing::warn!(
927 id = %current_id,
928 "[source] replace-with chain is cyclic; leaving crates-io unresolved"
929 );
930 return SourceReplacement::None;
931 }
932
933 let found_source = tiers
934 .workspace
935 .iter()
936 .find_map(|file| {
937 file.sources
938 .get(¤t_id)
939 .map(|entry| (entry, IndexTrust::WorkspaceDeclared))
940 })
941 .or_else(|| {
942 tiers
943 .cargo_home
944 .as_deref()
945 .and_then(|file| file.sources.get(¤t_id))
946 .map(|entry| (entry, IndexTrust::Trusted))
947 });
948
949 if let Some((entry, entry_trust)) = found_source {
950 chain_trust = chain_trust.min(entry_trust);
951 if let Some(next_id) = &entry.replace_with {
960 current_id = next_id.clone();
961 continue;
962 }
963 match &entry.kind {
964 Some(SourceKind::SparseRegistry { raw }) => {
965 return finalize_source_replacement(
966 raw,
967 chain_trust,
968 ¤t_id,
969 tiers,
970 policy,
971 );
972 }
973 Some(SourceKind::NonSparse) | None => {
974 return SourceReplacement::None;
975 }
976 }
977 }
978
979 if let Some((raw_index, crossover_trust)) = lookup_raw_registry_index(¤t_id, tiers) {
982 chain_trust = chain_trust.min(crossover_trust);
983 if raw_index.starts_with("sparse+") {
984 return finalize_source_replacement(
985 raw_index,
986 chain_trust,
987 ¤t_id,
988 tiers,
989 policy,
990 );
991 }
992 return SourceReplacement::None;
993 }
994
995 return SourceReplacement::None;
998 }
999
1000 tracing::warn!(
1001 max_hops = MAX_SOURCE_REPLACEMENT_HOPS,
1002 "[source] replace-with chain exceeded the maximum hop count; leaving crates-io unresolved"
1003 );
1004 SourceReplacement::None
1005}
1006
1007fn finalize_source_replacement(
1008 raw: &str,
1009 chain_trust: IndexTrust,
1010 terminal_id: &str,
1011 tiers: &LoadedTiers,
1012 policy: &RegistryAccessPolicy,
1013) -> SourceReplacement {
1014 match RegistryIndex::new(raw, chain_trust, policy) {
1015 Ok(index) => {
1016 let auth = if chain_trust == IndexTrust::Trusted {
1020 cargo_home_token_for(tiers, terminal_id)
1021 } else {
1022 None
1023 };
1024 SourceReplacement::SparseMirror { index, auth }
1025 }
1026 Err(error) => {
1027 tracing::warn!(
1028 id = terminal_id,
1029 %error,
1030 "[source] replace-with terminal index failed validation/policy; leaving crates-io unresolved"
1031 );
1032 SourceReplacement::None
1033 }
1034 }
1035}
1036
1037#[must_use]
1042pub fn referenced_aliases(dependencies: &[crate::types::ParsedDependency]) -> HashSet<String> {
1043 dependencies
1044 .iter()
1045 .filter_map(|dep| match &dep.source {
1046 deps_core::parser::DependencySource::CustomRegistry { url } => Some(url.clone()),
1047 _ => None,
1048 })
1049 .collect()
1050}
1051
1052#[cfg(test)]
1053mod tests {
1054 use super::*;
1055 use deps_core::net_policy::WorkspaceRegistryAccess;
1056 use std::assert_matches;
1057
1058 fn public_only_policy() -> RegistryAccessPolicy {
1059 RegistryAccessPolicy::new(WorkspaceRegistryAccess::PublicOnly)
1060 }
1061
1062 fn all_policy() -> RegistryAccessPolicy {
1063 RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
1064 }
1065
1066 fn off_policy() -> RegistryAccessPolicy {
1067 RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off)
1068 }
1069
1070 #[test]
1071 fn test_registry_index_strips_sparse_prefix() {
1072 let policy = all_policy();
1073 let index = RegistryIndex::new(
1074 "sparse+https://index.mycorp.dev",
1075 IndexTrust::Trusted,
1076 &policy,
1077 )
1078 .unwrap();
1079 assert_eq!(index.as_str(), "https://index.mycorp.dev/");
1080 }
1081
1082 #[test]
1083 fn test_registry_index_rejects_http() {
1084 let policy = all_policy();
1085 assert_matches!(
1086 RegistryIndex::new("http://index.mycorp.dev", IndexTrust::Trusted, &policy),
1087 Err(RegistryIndexError::NotHttps(_))
1088 );
1089 }
1090
1091 #[test]
1092 fn test_registry_index_rejects_userinfo() {
1093 let policy = all_policy();
1094 assert_matches!(
1095 RegistryIndex::new(
1096 "https://user:pass@index.mycorp.dev",
1097 IndexTrust::Trusted,
1098 &policy
1099 ),
1100 Err(RegistryIndexError::UserInfoPresent)
1101 );
1102 }
1103
1104 #[test]
1105 fn test_registry_index_rejects_bare_username() {
1106 let policy = all_policy();
1107 assert_matches!(
1108 RegistryIndex::new(
1109 "https://user@index.mycorp.dev",
1110 IndexTrust::Trusted,
1111 &policy
1112 ),
1113 Err(RegistryIndexError::UserInfoPresent)
1114 );
1115 }
1116
1117 #[test]
1118 fn test_registry_index_rejects_invalid_url() {
1119 let policy = all_policy();
1120 assert_matches!(
1121 RegistryIndex::new("not a url", IndexTrust::Trusted, &policy),
1122 Err(RegistryIndexError::InvalidUrl(_))
1123 );
1124 }
1125
1126 #[test]
1133 fn test_registry_index_invalid_url_error_redacts_userinfo() {
1134 let policy = all_policy();
1135 let err = RegistryIndex::new(
1136 "https://user:hunter2@index.mycorp.dev:99999",
1137 IndexTrust::Trusted,
1138 &policy,
1139 )
1140 .unwrap_err();
1141 assert_matches!(err, RegistryIndexError::InvalidUrl(_));
1142 assert!(!err.to_string().contains("hunter2"), "Display: {err}");
1143 }
1144
1145 #[test]
1146 fn test_registry_index_accepts_https_without_sparse_prefix() {
1147 let policy = all_policy();
1148 assert!(
1149 RegistryIndex::new("https://index.mycorp.dev", IndexTrust::Trusted, &policy).is_ok()
1150 );
1151 }
1152
1153 #[test]
1154 fn test_registry_index_builtin_crates_io() {
1155 let index = RegistryIndex::builtin("https://index.crates.io");
1157 assert_eq!(index.as_str(), "https://index.crates.io/");
1158 }
1159
1160 #[test]
1162 fn test_registry_index_trust_round_trips_new_argument() {
1163 let policy = all_policy();
1164 let trusted =
1165 RegistryIndex::new("https://index.mycorp.dev", IndexTrust::Trusted, &policy).unwrap();
1166 assert_eq!(trusted.trust(), IndexTrust::Trusted);
1167
1168 let workspace_declared = RegistryIndex::new(
1169 "https://index.mycorp.dev",
1170 IndexTrust::WorkspaceDeclared,
1171 &policy,
1172 )
1173 .unwrap();
1174 assert_eq!(workspace_declared.trust(), IndexTrust::WorkspaceDeclared);
1175 }
1176
1177 #[test]
1179 fn test_registry_index_builtin_is_trusted() {
1180 let index = RegistryIndex::builtin("https://index.crates.io");
1181 assert_eq!(index.trust(), IndexTrust::Trusted);
1182 }
1183
1184 #[test]
1189 fn test_registry_index_trusted_metadata_ip_always_allowed() {
1190 for policy in [off_policy(), public_only_policy(), all_policy()] {
1191 assert!(
1192 RegistryIndex::new("https://169.254.169.254/", IndexTrust::Trusted, &policy)
1193 .is_ok(),
1194 "a Trusted candidate must never be policy-checked"
1195 );
1196 }
1197 }
1198
1199 #[test]
1200 fn test_registry_index_workspace_declared_metadata_ip_blocked_under_public_only() {
1201 let policy = public_only_policy();
1202 assert_matches!(
1203 RegistryIndex::new(
1204 "https://169.254.169.254/",
1205 IndexTrust::WorkspaceDeclared,
1206 &policy
1207 ),
1208 Err(RegistryIndexError::BlockedHost { .. })
1209 );
1210 }
1211
1212 #[test]
1213 fn test_registry_index_workspace_declared_global_allowed_under_public_only() {
1214 let policy = public_only_policy();
1215 assert!(
1216 RegistryIndex::new(
1217 "https://index.mycorp.dev",
1218 IndexTrust::WorkspaceDeclared,
1219 &policy
1220 )
1221 .is_ok()
1222 );
1223 }
1224
1225 #[test]
1226 fn test_registry_index_workspace_declared_blocked_under_off() {
1227 let policy = off_policy();
1228 assert_matches!(
1229 RegistryIndex::new(
1230 "https://index.mycorp.dev",
1231 IndexTrust::WorkspaceDeclared,
1232 &policy
1233 ),
1234 Err(RegistryIndexError::BlockedHost { .. })
1235 );
1236 }
1237
1238 #[test]
1239 fn test_registry_index_workspace_declared_rfc1918_allowed_under_all() {
1240 let policy = all_policy();
1241 assert!(
1242 RegistryIndex::new("https://10.0.0.1/", IndexTrust::WorkspaceDeclared, &policy).is_ok()
1243 );
1244 }
1245
1246 #[test]
1247 fn test_auth_token_debug_and_display_redact() {
1248 let token = AuthToken::new("super-secret-value".to_string());
1249 assert_eq!(format!("{token:?}"), "AuthToken(***)");
1250 assert_eq!(format!("{token}"), "***");
1251 assert!(!format!("{token:?}").contains("super-secret-value"));
1252 }
1253
1254 #[test]
1255 fn test_parse_workspace_registries_raw_never_populates_auth() {
1256 let content = r#"
1257[registries.my-corp]
1258index = "sparse+https://index.mycorp.dev"
1259token = "should-be-ignored"
1260"#;
1261 let result = parse_workspace_registries_raw(content);
1262 assert_eq!(
1265 result.get("my-corp").map(String::as_str),
1266 Some("sparse+https://index.mycorp.dev")
1267 );
1268 }
1269
1270 #[test]
1271 fn test_parse_cargo_home_registries_raw_reads_token() {
1272 let content = r#"
1273[registries.my-corp]
1274index = "sparse+https://index.mycorp.dev"
1275token = "secret-token"
1276"#;
1277 let result = parse_cargo_home_registries_raw(content);
1278 let (raw_index, token) = result.get("my-corp").unwrap();
1279 assert_eq!(raw_index, "sparse+https://index.mycorp.dev");
1280 assert_eq!(token.as_ref().unwrap().expose_secret(), "secret-token");
1281 }
1282
1283 #[test]
1284 fn test_parse_registries_raw_malformed_toml_fails_closed() {
1285 let content = "this is [ not valid toml";
1286 assert!(parse_workspace_registries_raw(content).is_empty());
1287 assert!(parse_cargo_home_registries_raw(content).is_empty());
1288 }
1289
1290 #[test]
1291 fn test_parse_registries_raw_rejects_excessive_nesting() {
1292 let content = format!("a = {}1{}", "[".repeat(300), "]".repeat(300));
1293 assert!(parse_workspace_registries_raw(&content).is_empty());
1294 assert!(parse_cargo_home_registries_raw(&content).is_empty());
1295 }
1296
1297 #[test]
1298 fn test_cargo_home_config_path_none_when_unset() {
1299 assert!(cargo_home_config_path_with_env(|_| None).is_none());
1300 }
1301
1302 #[test]
1303 fn test_cargo_home_config_path_some_when_set() {
1304 let path = cargo_home_config_path_with_env(|name| {
1305 (name == "CARGO_HOME").then(|| std::ffi::OsString::from("/home/user/.cargo"))
1306 });
1307 assert_eq!(path, Some(PathBuf::from("/home/user/.cargo/config.toml")));
1308 }
1309
1310 #[test]
1311 fn test_resolve_workspace_wins_over_cargo_home() {
1312 let root = tempfile::tempdir().unwrap();
1313 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1314 std::fs::write(
1315 root.path().join(".cargo/config.toml"),
1316 "[registries.my-corp]\nindex = \"sparse+https://workspace.example\"\n",
1317 )
1318 .unwrap();
1319
1320 let cargo_home = tempfile::tempdir().unwrap();
1321 std::fs::write(
1322 cargo_home.path().join("config.toml"),
1323 "[registries.my-corp]\nindex = \"sparse+https://real.example\"\ntoken = \"real-token\"\n",
1324 )
1325 .unwrap();
1326
1327 let aliases: HashSet<String> = std::iter::once("my-corp".to_string()).collect();
1328 let cache = ConfigFileCache::new();
1329 let policy = all_policy();
1330 let (config, _) = resolve(
1331 &aliases,
1332 &[root.path().join(".cargo/config.toml")],
1333 Some(&cargo_home.path().join("config.toml")),
1334 &cache,
1335 &policy,
1336 );
1337
1338 let entry = config.get("my-corp").unwrap();
1339 assert_eq!(entry.index.as_str(), "https://workspace.example/");
1340 assert!(
1341 entry.auth.is_none(),
1342 "workspace-shadowed entry must never carry the cargo-home token"
1343 );
1344 assert_eq!(entry.provenance, Provenance::Workspace);
1345 }
1346
1347 #[test]
1354 fn test_resolve_home_nested_project_does_not_lose_cargo_home_token() {
1355 let home = tempfile::tempdir().unwrap();
1356 std::fs::create_dir_all(home.path().join(".cargo")).unwrap();
1357 let cargo_home_config = home.path().join(".cargo/config.toml");
1358 std::fs::write(
1359 &cargo_home_config,
1360 "[registries.my-corp]\nindex = \"sparse+https://real.example\"\ntoken = \"real-token\"\n",
1361 )
1362 .unwrap();
1363
1364 let workspace_paths = vec![cargo_home_config.clone()];
1367
1368 let aliases: HashSet<String> = std::iter::once("my-corp".to_string()).collect();
1369 let cache = ConfigFileCache::new();
1370 let policy = all_policy();
1371 let (config, _) = resolve(
1372 &aliases,
1373 &workspace_paths,
1374 Some(&cargo_home_config),
1375 &cache,
1376 &policy,
1377 );
1378
1379 let entry = config.get("my-corp").unwrap();
1380 assert_eq!(entry.index.as_str(), "https://real.example/");
1381 assert_eq!(entry.provenance, Provenance::CargoHome);
1382 assert_eq!(
1383 entry.auth.as_ref().map(AuthToken::expose_secret),
1384 Some("real-token"),
1385 "the CARGO_HOME token must not be lost just because the project lives \
1386 under $HOME"
1387 );
1388 }
1389
1390 #[test]
1391 fn test_resolve_falls_back_to_cargo_home_when_no_workspace_entry() {
1392 let cargo_home = tempfile::tempdir().unwrap();
1393 std::fs::write(
1394 cargo_home.path().join("config.toml"),
1395 "[registries.my-corp]\nindex = \"sparse+https://real.example\"\ntoken = \"real-token\"\n",
1396 )
1397 .unwrap();
1398
1399 let aliases: HashSet<String> = std::iter::once("my-corp".to_string()).collect();
1400 let cache = ConfigFileCache::new();
1401 let policy = all_policy();
1402 let (config, _) = resolve(
1403 &aliases,
1404 &[],
1405 Some(&cargo_home.path().join("config.toml")),
1406 &cache,
1407 &policy,
1408 );
1409
1410 let entry = config.get("my-corp").unwrap();
1411 assert_eq!(entry.index.as_str(), "https://real.example/");
1412 assert_eq!(entry.auth.as_ref().unwrap().expose_secret(), "real-token");
1413 assert_eq!(entry.provenance, Provenance::CargoHome);
1414 }
1415
1416 #[test]
1417 fn test_resolve_unconfigured_alias_stays_unresolved() {
1418 let aliases: HashSet<String> = std::iter::once("unknown".to_string()).collect();
1419 let cache = ConfigFileCache::new();
1420 let policy = all_policy();
1421 let (config, _) = resolve(&aliases, &[], None, &cache, &policy);
1422 assert!(config.get("unknown").is_none());
1423 }
1424
1425 #[test]
1426 fn test_resolve_env_var_index_override() {
1427 let aliases: HashSet<String> = std::iter::once("env-only-corp".to_string()).collect();
1428 let env = |name: &str| match name {
1429 "CARGO_REGISTRIES_ENV_ONLY_CORP_INDEX" => {
1430 Some("sparse+https://env.example".to_string())
1431 }
1432 "CARGO_REGISTRIES_ENV_ONLY_CORP_TOKEN" => Some("env-token".to_string()),
1433 _ => None,
1434 };
1435
1436 let cache = ConfigFileCache::new();
1437 let policy = all_policy();
1438 let (config, _) = resolve_with_env(&aliases, &[], None, &cache, &policy, &env);
1439 let entry = config.get("env-only-corp").unwrap();
1440 assert_eq!(entry.index.as_str(), "https://env.example/");
1441 assert_eq!(entry.auth.as_ref().unwrap().expose_secret(), "env-token");
1442 assert_eq!(entry.provenance, Provenance::CargoHome);
1443 }
1444
1445 #[test]
1448 fn test_resolve_env_var_name_collision_disables_both() {
1449 let aliases: HashSet<String> = ["my-corp".to_string(), "my_corp".to_string()]
1450 .into_iter()
1451 .collect();
1452 let env = |name: &str| {
1453 (name == "CARGO_REGISTRIES_MY_CORP_INDEX")
1454 .then(|| "sparse+https://ambiguous.example".to_string())
1455 };
1456
1457 let cache = ConfigFileCache::new();
1458 let policy = all_policy();
1459 let (config, _) = resolve_with_env(&aliases, &[], None, &cache, &policy, &env);
1460 assert!(config.get("my-corp").is_none());
1461 assert!(config.get("my_corp").is_none());
1462 }
1463
1464 #[test]
1468 fn test_resolve_env_token_never_attaches_to_workspace_shadowed_alias() {
1469 let root = tempfile::tempdir().unwrap();
1470 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1471 std::fs::write(
1472 root.path().join(".cargo/config.toml"),
1473 "[registries.github]\nindex = \"sparse+https://attacker.example\"\n",
1474 )
1475 .unwrap();
1476
1477 let env = |name: &str| {
1478 (name == "CARGO_REGISTRIES_GITHUB_TOKEN").then(|| "legitimate-token".to_string())
1479 };
1480
1481 let aliases: HashSet<String> = std::iter::once("github".to_string()).collect();
1482 let cache = ConfigFileCache::new();
1483 let policy = all_policy();
1484 let (config, _) = resolve_with_env(
1485 &aliases,
1486 &[root.path().join(".cargo/config.toml")],
1487 None,
1488 &cache,
1489 &policy,
1490 &env,
1491 );
1492
1493 let entry = config.get("github").unwrap();
1494 assert_eq!(entry.index.as_str(), "https://attacker.example/");
1495 assert!(
1496 entry.auth.is_none(),
1497 "the legitimate env token must never attach to the attacker-controlled index"
1498 );
1499 }
1500
1501 #[test]
1502 fn test_referenced_aliases_collects_custom_registry_urls() {
1503 use crate::types::{DependencySection, ParsedDependency};
1504 use deps_core::parser::DependencySource;
1505 use tower_lsp_server::ls_types::Range;
1506
1507 let deps = vec![
1508 ParsedDependency {
1509 name: "a".into(),
1510 name_range: Range::default(),
1511 version_req: None,
1512 version_range: None,
1513 features: vec![],
1514 features_range: None,
1515 source: DependencySource::CustomRegistry {
1516 url: "my-corp".into(),
1517 },
1518 section: DependencySection::Dependencies,
1519 },
1520 ParsedDependency {
1521 name: "b".into(),
1522 name_range: Range::default(),
1523 version_req: None,
1524 version_range: None,
1525 features: vec![],
1526 features_range: None,
1527 source: DependencySource::Registry,
1528 section: DependencySection::Dependencies,
1529 },
1530 ];
1531
1532 let aliases = referenced_aliases(&deps);
1533 assert_eq!(aliases.len(), 1);
1534 assert!(aliases.contains("my-corp"));
1535 }
1536
1537 #[test]
1540 fn test_config_file_cache_hit_reuses_parsed_arc_without_reparsing() {
1541 let dir = tempfile::tempdir().unwrap();
1542 let path = dir.path().join("config.toml");
1543 std::fs::write(
1544 &path,
1545 "[registries.a]\nindex = \"sparse+https://a.example\"\n",
1546 )
1547 .unwrap();
1548
1549 let cache = ConfigFileCache::new();
1550 let first = cache.get_or_parse_workspace(&path).unwrap();
1551 let second = cache.get_or_parse_workspace(&path).unwrap();
1552
1553 assert!(
1554 Arc::ptr_eq(&first, &second),
1555 "a cache hit must return the same Arc, not re-parse"
1556 );
1557 }
1558
1559 #[test]
1564 fn test_config_file_cache_hit_does_zero_reads_and_exactly_one_stat() {
1565 let dir = tempfile::tempdir().unwrap();
1566 let path = dir.path().join("config.toml");
1567 std::fs::write(
1568 &path,
1569 "[registries.a]\nindex = \"sparse+https://a.example\"\n",
1570 )
1571 .unwrap();
1572
1573 let cache = ConfigFileCache::new();
1574 cache.get_or_parse_workspace(&path).unwrap();
1576
1577 let (stats_before, reads_before) = deps_core::fs_probe::snapshot();
1578 let hit = cache.get_or_parse_workspace(&path).unwrap();
1579 let (stats_after, reads_after) = deps_core::fs_probe::snapshot();
1580
1581 assert_eq!(
1582 reads_after - reads_before,
1583 0,
1584 "a cache hit must perform zero content reads"
1585 );
1586 assert_eq!(
1587 stats_after - stats_before,
1588 1,
1589 "a cache hit still pays exactly one mtime stat"
1590 );
1591 match &hit.tier {
1592 CachedTier::Workspace(map) => assert!(map.contains_key("a")),
1593 CachedTier::CargoHome(_) => panic!("expected Workspace tier"),
1594 }
1595 }
1596
1597 #[test]
1601 fn test_resolve_new_alias_resolves_without_config_file_change() {
1602 let root = tempfile::tempdir().unwrap();
1603 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1604 std::fs::write(
1605 root.path().join(".cargo/config.toml"),
1606 "[registries.a]\nindex = \"sparse+https://a.example\"\n\
1607 [registries.b]\nindex = \"sparse+https://b.example\"\n",
1608 )
1609 .unwrap();
1610
1611 let cache = ConfigFileCache::new();
1612 let policy = all_policy();
1613 let workspace_paths = vec![root.path().join(".cargo/config.toml")];
1614
1615 let first_aliases: HashSet<String> = std::iter::once("a".to_string()).collect();
1616 let (first, _) = resolve(&first_aliases, &workspace_paths, None, &cache, &policy);
1617 assert!(first.get("a").is_some());
1618 assert!(first.get("b").is_none(), "b was not yet referenced");
1619
1620 let second_aliases: HashSet<String> =
1623 ["a".to_string(), "b".to_string()].into_iter().collect();
1624 let (second, _) = resolve(&second_aliases, &workspace_paths, None, &cache, &policy);
1625 assert!(
1626 second.get("b").is_some(),
1627 "newly-referenced alias b must resolve immediately"
1628 );
1629 }
1630
1631 #[test]
1634 fn test_resolve_policy_change_takes_effect_with_no_cache_invalidation() {
1635 let root = tempfile::tempdir().unwrap();
1636 std::fs::create_dir_all(root.path().join(".cargo")).unwrap();
1637 std::fs::write(
1638 root.path().join(".cargo/config.toml"),
1639 "[registries.metadata]\nindex = \"https://169.254.169.254\"\n",
1640 )
1641 .unwrap();
1642
1643 let cache = ConfigFileCache::new();
1644 let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::All);
1645 let workspace_paths = vec![root.path().join(".cargo/config.toml")];
1646 let aliases: HashSet<String> = std::iter::once("metadata".to_string()).collect();
1647
1648 let (first, _) = resolve(&aliases, &workspace_paths, None, &cache, &policy);
1649 assert!(first.get("metadata").is_some(), "allowed under All");
1650
1651 policy.set(WorkspaceRegistryAccess::PublicOnly);
1652 let (second, _) = resolve(&aliases, &workspace_paths, None, &cache, &policy);
1653 assert!(
1654 second.get("metadata").is_none(),
1655 "blocked under PublicOnly, same cache"
1656 );
1657 }
1658
1659 fn write_config(dir: &Path, content: &str) -> PathBuf {
1662 std::fs::create_dir_all(dir.join(".cargo")).unwrap();
1663 let path = dir.join(".cargo/config.toml");
1664 std::fs::write(&path, content).unwrap();
1665 path
1666 }
1667
1668 #[test]
1669 fn test_source_chain_single_hop_to_sparse() {
1670 let root = tempfile::tempdir().unwrap();
1671 let path = write_config(
1672 root.path(),
1673 "[source.crates-io]\nreplace-with = \"my-mirror\"\n\
1674 [source.my-mirror]\nregistry = \"sparse+https://mirror.example\"\n",
1675 );
1676
1677 let cache = ConfigFileCache::new();
1678 let policy = all_policy();
1679 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1680
1681 match replacement {
1682 SourceReplacement::SparseMirror { index, auth } => {
1683 assert_eq!(index.as_str(), "https://mirror.example/");
1684 assert!(auth.is_none());
1685 }
1686 SourceReplacement::None => panic!("expected a resolved mirror"),
1687 }
1688 }
1689
1690 #[test]
1691 fn test_source_chain_two_hops() {
1692 let root = tempfile::tempdir().unwrap();
1693 let path = write_config(
1694 root.path(),
1695 "[source.crates-io]\nreplace-with = \"intermediate\"\n\
1696 [source.intermediate]\nreplace-with = \"terminal\"\n\
1697 [source.terminal]\nregistry = \"sparse+https://terminal.example\"\n",
1698 );
1699
1700 let cache = ConfigFileCache::new();
1701 let policy = all_policy();
1702 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1703
1704 assert_matches!(replacement, SourceReplacement::SparseMirror { .. });
1705 }
1706
1707 #[test]
1708 fn test_source_chain_directory_falls_back_to_none() {
1709 let root = tempfile::tempdir().unwrap();
1710 let path = write_config(
1711 root.path(),
1712 "[source.crates-io]\nreplace-with = \"vendored\"\n\
1713 [source.vendored]\ndirectory = \"vendor\"\n",
1714 );
1715
1716 let cache = ConfigFileCache::new();
1717 let policy = all_policy();
1718 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1719
1720 assert_eq!(replacement, SourceReplacement::None);
1721 }
1722
1723 #[test]
1724 fn test_source_chain_local_registry_falls_back_to_none() {
1725 let root = tempfile::tempdir().unwrap();
1726 let path = write_config(
1727 root.path(),
1728 "[source.crates-io]\nreplace-with = \"local\"\n\
1729 [source.local]\nlocal-registry = \"local-registry\"\n",
1730 );
1731
1732 let cache = ConfigFileCache::new();
1733 let policy = all_policy();
1734 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1735
1736 assert_eq!(replacement, SourceReplacement::None);
1737 }
1738
1739 #[test]
1740 fn test_source_chain_bare_https_git_index_falls_back_to_none() {
1741 let root = tempfile::tempdir().unwrap();
1742 let path = write_config(
1743 root.path(),
1744 "[source.crates-io]\nreplace-with = \"git-mirror\"\n\
1745 [source.git-mirror]\nregistry = \"https://github.com/rust-lang/crates.io-index\"\n",
1746 );
1747
1748 let cache = ConfigFileCache::new();
1749 let policy = all_policy();
1750 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1751
1752 assert_eq!(replacement, SourceReplacement::None);
1753 }
1754
1755 #[test]
1756 fn test_source_chain_self_referential_stops() {
1757 let root = tempfile::tempdir().unwrap();
1758 let path = write_config(
1759 root.path(),
1760 "[source.crates-io]\nreplace-with = \"crates-io\"\n",
1761 );
1762
1763 let cache = ConfigFileCache::new();
1764 let policy = all_policy();
1765 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1766
1767 assert_eq!(replacement, SourceReplacement::None);
1768 }
1769
1770 #[test]
1771 fn test_source_chain_three_cycle_stops() {
1772 let root = tempfile::tempdir().unwrap();
1773 let path = write_config(
1774 root.path(),
1775 "[source.crates-io]\nreplace-with = \"a\"\n\
1776 [source.a]\nreplace-with = \"b\"\n\
1777 [source.b]\nreplace-with = \"crates-io\"\n",
1778 );
1779
1780 let cache = ConfigFileCache::new();
1781 let policy = all_policy();
1782 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1783
1784 assert_eq!(replacement, SourceReplacement::None);
1785 }
1786
1787 #[test]
1788 fn test_source_chain_seventeen_hops_exceeds_bound() {
1789 let root = tempfile::tempdir().unwrap();
1790 let mut toml = String::from("[source.crates-io]\nreplace-with = \"hop0\"\n");
1791 for i in 0..16 {
1792 toml.push_str(&format!(
1793 "[source.hop{i}]\nreplace-with = \"hop{}\"\n",
1794 i + 1
1795 ));
1796 }
1797 toml.push_str("[source.hop16]\nregistry = \"sparse+https://terminal.example\"\n");
1798 let path = write_config(root.path(), &toml);
1799
1800 let cache = ConfigFileCache::new();
1801 let policy = all_policy();
1802 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1803
1804 assert_eq!(replacement, SourceReplacement::None);
1806 }
1807
1808 #[test]
1809 fn test_source_chain_terminal_blocked_by_policy() {
1810 let root = tempfile::tempdir().unwrap();
1811 let path = write_config(
1812 root.path(),
1813 "[source.crates-io]\nreplace-with = \"metadata\"\n\
1814 [source.metadata]\nregistry = \"sparse+https://169.254.169.254/\"\n",
1815 );
1816
1817 let cache = ConfigFileCache::new();
1818 let policy = public_only_policy();
1819 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1820
1821 assert_eq!(replacement, SourceReplacement::None);
1822 }
1823
1824 #[test]
1827 fn test_source_chain_stage_one_registries_crossover() {
1828 let root = tempfile::tempdir().unwrap();
1829 let path = write_config(
1830 root.path(),
1831 "[source.crates-io]\nreplace-with = \"my-corp\"\n\
1832 [registries.my-corp]\nindex = \"sparse+https://index.mycorp.dev\"\n",
1833 );
1834
1835 let cache = ConfigFileCache::new();
1836 let policy = all_policy();
1837 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1838
1839 match replacement {
1840 SourceReplacement::SparseMirror { index, .. } => {
1841 assert_eq!(index.as_str(), "https://index.mycorp.dev/");
1842 }
1843 SourceReplacement::None => panic!("expected the [registries] crossover to resolve"),
1844 }
1845 }
1846
1847 #[test]
1853 fn test_source_chain_replace_with_wins_over_explicit_kind_on_same_table() {
1854 let root = tempfile::tempdir().unwrap();
1855 let path = write_config(
1856 root.path(),
1857 "[source.crates-io]\n\
1858 registry = \"https://github.com/rust-lang/crates.io-index\"\n\
1859 replace-with = \"mirror\"\n\
1860 [source.mirror]\nregistry = \"sparse+https://mirror.example/index/\"\n",
1861 );
1862
1863 let cache = ConfigFileCache::new();
1864 let policy = all_policy();
1865 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1866
1867 match replacement {
1868 SourceReplacement::SparseMirror { index, .. } => {
1869 assert_eq!(index.as_str(), "https://mirror.example/index/");
1870 }
1871 SourceReplacement::None => panic!(
1872 "replace-with must apply even though [source.crates-io] also declares an explicit kind"
1873 ),
1874 }
1875 }
1876
1877 #[test]
1882 fn test_source_chain_replace_with_wins_over_own_sparse_registry() {
1883 let root = tempfile::tempdir().unwrap();
1884 let path = write_config(
1885 root.path(),
1886 "[source.crates-io]\n\
1887 registry = \"sparse+https://a.example/index/\"\n\
1888 replace-with = \"b\"\n\
1889 [source.b]\nregistry = \"sparse+https://b.example/index/\"\n",
1890 );
1891
1892 let cache = ConfigFileCache::new();
1893 let policy = all_policy();
1894 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1895
1896 match replacement {
1897 SourceReplacement::SparseMirror { index, .. } => {
1898 assert_eq!(
1899 index.as_str(),
1900 "https://b.example/index/",
1901 "replace-with must win over the table's own sparse `registry` value"
1902 );
1903 }
1904 SourceReplacement::None => panic!("expected the replace-with target to resolve"),
1905 }
1906 }
1907
1908 #[test]
1913 fn test_source_chain_coupled_trust_trap_workspace_crossover_never_carries_cargo_home_token() {
1914 let root = tempfile::tempdir().unwrap();
1915 let workspace_path = write_config(
1916 root.path(),
1917 "[source.crates-io]\nreplace-with = \"my-corp\"\n",
1918 );
1919
1920 let cargo_home = tempfile::tempdir().unwrap();
1921 std::fs::write(
1922 cargo_home.path().join("config.toml"),
1923 "[registries.my-corp]\nindex = \"sparse+https://index.mycorp.dev\"\ntoken = \"leaked-if-buggy\"\n",
1924 )
1925 .unwrap();
1926
1927 let cache = ConfigFileCache::new();
1928 let policy = all_policy();
1929 let (_, replacement) = resolve(
1930 &HashSet::new(),
1931 &[workspace_path],
1932 Some(&cargo_home.path().join("config.toml")),
1933 &cache,
1934 &policy,
1935 );
1936
1937 match replacement {
1938 SourceReplacement::SparseMirror { index, auth } => {
1939 assert_eq!(index.as_str(), "https://index.mycorp.dev/");
1940 assert!(
1941 auth.is_none(),
1942 "a workspace-tier chain link must never let a $CARGO_HOME token ride along"
1943 );
1944 }
1945 SourceReplacement::None => panic!("expected the mirror to resolve, just without auth"),
1946 }
1947 }
1948
1949 #[test]
1952 fn test_source_chain_fully_trusted_chain_attaches_cargo_home_token() {
1953 let cargo_home = tempfile::tempdir().unwrap();
1954 std::fs::write(
1955 cargo_home.path().join("config.toml"),
1956 "[source.crates-io]\nreplace-with = \"my-corp\"\n\
1957 [registries.my-corp]\nindex = \"sparse+https://index.mycorp.dev\"\ntoken = \"real-token\"\n",
1958 )
1959 .unwrap();
1960
1961 let cache = ConfigFileCache::new();
1962 let policy = all_policy();
1963 let (_, replacement) = resolve(
1964 &HashSet::new(),
1965 &[],
1966 Some(&cargo_home.path().join("config.toml")),
1967 &cache,
1968 &policy,
1969 );
1970
1971 match replacement {
1972 SourceReplacement::SparseMirror { auth, .. } => {
1973 assert_eq!(
1974 auth.as_ref().map(AuthToken::expose_secret),
1975 Some("real-token")
1976 );
1977 }
1978 SourceReplacement::None => panic!("expected the fully-trusted chain to resolve"),
1979 }
1980 }
1981
1982 #[test]
1983 fn test_source_chain_no_source_section_resolves_none() {
1984 let root = tempfile::tempdir().unwrap();
1985 let path = write_config(
1986 root.path(),
1987 "[registries.other]\nindex = \"sparse+https://other.example\"\n",
1988 );
1989
1990 let cache = ConfigFileCache::new();
1991 let policy = all_policy();
1992 let (_, replacement) = resolve(&HashSet::new(), &[path], None, &cache, &policy);
1993
1994 assert_eq!(replacement, SourceReplacement::None);
1995 }
1996}