Skip to main content

deps_cargo/
config.rs

1//! `.cargo/config.toml` discovery and `[registries.*]`/`[source.*]` resolution.
2//!
3//! Resolves a Cargo `registry = "<alias>"` dependency's alias into a concrete, fetchable
4//! sparse index URL by reading the same `.cargo/config.toml` hierarchy (and
5//! `$CARGO_HOME/config.toml`) Cargo itself consults, plus the
6//! `CARGO_REGISTRIES_<NAME>_INDEX`/`_TOKEN` environment variable overrides Cargo
7//! documents. Also resolves a `[source.crates-io] replace-with` chain into a mirror index
8//! for plain (`Registry`-sourced) dependencies (spec FR-005/006/007).
9//!
10//! # Security model (read before touching this module)
11//!
12//! A workspace's own `Cargo.toml`/`.cargo/config.toml` is attacker-controlled the moment a
13//! hostile repository is cloned and opened — this LSP parses on file open, before any build
14//! ever runs. Two, related, threats this module closes:
15//!
16//! - **Credential exfiltration.** [`AuthToken`] must never be attachable to a request whose
17//!   destination URL provenance traces to a workspace file. This is enforced
18//!   **structurally**, not by a runtime check:
19//!   - `parse_workspace_registries_raw` has no return type capable of expressing a token —
20//!     its value type is a bare `String`, with no token field anywhere. There is no `token`
21//!     field lookup anywhere in that function's body.
22//!   - Only `parse_cargo_home_registries_raw` (fed `$CARGO_HOME/config.toml`'s content) and
23//!     the environment-variable lookup in [`resolve`] ever construct `Some(AuthToken)`.
24//!   - [`Provenance`] exists purely for logging/diagnostics. Nothing in this crate branches
25//!     on it to decide whether to attach a credential — grepping for `Provenance` outside
26//!     this module should find no such branch (verified in this PR's security review).
27//! - **Internal-network reachability (SSRF-adjacent, #443).** [`RegistryIndex::new`] requires
28//!   an [`IndexTrust`] and a [`deps_core::net_policy::RegistryAccessPolicy`]: a
29//!   `WorkspaceDeclared` URL is checked against the live policy before it can ever become a
30//!   fetchable index, while a `Trusted` (`$CARGO_HOME`-provenance) URL is never
31//!   policy-checked at all — it is the user's own configuration, not something a cloned
32//!   repository controls. See `.local/specs/023-cargo-custom-registries/plan-1b.md` §1-§2.
33//!
34//! See spec `.local/specs/023-cargo-custom-registries/spec.md` FR-008/FR-009 and the design
35//! review handoffs cited there for the two rounds of critique the credential boundary
36//! survived.
37
38use 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/// A registry bearer-token credential, redacted everywhere except the one call site that
49/// formats it into an `Authorization` header.
50///
51/// Constructible only from within this module (see the module-level security-model docs) —
52/// no other code path in this crate has the means to produce one. A thin wrapper over
53/// [`deps_core::secret::Redacted`] rather than a bare type alias: `Debug` prints
54/// `AuthToken(***)`, not `Redacted(***)`, so a panic message or log line still names which
55/// credential leaked its type.
56#[derive(Clone, PartialEq, Eq)]
57pub struct AuthToken(deps_core::secret::Redacted);
58
59impl AuthToken {
60    /// Wraps `token`. Kept `pub(crate)` rather than `pub`: the module-level security-model
61    /// docs above are the enforcement, and widening this to `pub` would let any other crate
62    /// construct one with no [`Provenance`]/[`IndexTrust`] to account for at all.
63    pub(crate) fn new(token: String) -> Self {
64        Self(deps_core::secret::Redacted::new(token))
65    }
66
67    /// The raw token value, for building an `Authorization` header. Never logged, printed,
68    /// or otherwise surfaced — callers must not pass this to anything but a header value.
69    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/// Where a [`ResolvedRegistryEntry`] came from.
87///
88/// **Diagnostics and logging only.** Never gates whether [`ResolvedRegistryEntry::auth`]
89/// is populated — that is a structural property of which parsing function produced the
90/// entry (see the module-level docs), not a runtime branch on this enum. A future change
91/// that starts branching on this to decide whether to attach a credential reintroduces the
92/// exact vulnerability class this design closed.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Provenance {
95    /// Resolved from `$CARGO_HOME/config.toml` or a `CARGO_REGISTRIES_*` environment
96    /// variable — the user's own trusted environment, not something a cloned repository
97    /// controls.
98    CargoHome,
99    /// Resolved from a `.cargo/config.toml` found while walking up from the opened
100    /// manifest's directory — a file a repository being opened can fully control.
101    Workspace,
102}
103
104/// Whose input a candidate registry index URL is, for [`RegistryIndex::new`]'s
105/// [`deps_core::net_policy::RegistryAccessPolicy`] gate.
106///
107/// A new enum rather than a reuse of [`Provenance`], even though the variants map 1:1:
108/// `Provenance`'s doc comment is an explicit "nothing ever branches on this" invariant
109/// protecting the auth boundary; adding a policy branch on it would make that sentence false
110/// and invite a future reader to add an auth branch too. Two small enums, one invariant
111/// each.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
113pub enum IndexTrust {
114    /// `$CARGO_HOME/config.toml` or a `CARGO_REGISTRIES_*` environment variable — the
115    /// user's own environment. Never policy-checked (see [`RegistryIndex::new`]).
116    Trusted,
117    /// A workspace file: the `Cargo.toml` alias target itself, or any ancestor
118    /// `.cargo/config.toml`/`[source]` chain link within the workspace. Checked against the
119    /// live [`deps_core::net_policy::RegistryAccessPolicy`].
120    WorkspaceDeclared,
121}
122
123impl IndexTrust {
124    /// The less-trusted of `self` and `other` — `WorkspaceDeclared` if either is, `Trusted`
125    /// only if both are.
126    ///
127    /// Used to fold a `[source]` replace-with chain's trust (plan-1b §1.4 step 3): one
128    /// workspace-tier link anywhere in the chain makes the whole chain `WorkspaceDeclared`,
129    /// closing the shape where a hostile `[source.crates-io] replace-with = "corp"` in the
130    /// repo borrows a `$CARGO_HOME`-defined source's credential. Also used by
131    /// [`crate::registry::CargoRegistry::register_alternate`] (issue #455, C3) to fold a
132    /// re-registration of the same index URL to the stricter of its old and new trust tier.
133    #[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/// A validated, `sparse+`-prefix-stripped sparse-index URL: `https` scheme, no userinfo, and
143/// (for a `WorkspaceDeclared` candidate) a host the live
144/// [`deps_core::net_policy::RegistryAccessPolicy`] allows.
145///
146/// Validated at construction so an invalid or unsafe URL (`http://`, a scheme other than
147/// `sparse+https`, a `user:pass@` component, or a workspace-declared host the policy blocks)
148/// can never reach a network call — this is SSRF-adjacent input, since a workspace file
149/// controls a network destination (spec NFR-002, plan-1b §1.1-§1.2).
150#[derive(Debug, Clone, PartialEq, Eq, Hash)]
151pub struct RegistryIndex {
152    url: url::Url,
153    /// This candidate's [`IndexTrust`] tier, carried alongside the validated URL so a
154    /// consumer (issue #455's [`crate::sparse::SparseIndexClient`] fail-closed auth gate, C2;
155    /// [`crate::registry::CargoRegistry::register_alternate`]'s trust fold, C3) never needs a
156    /// second, disconnected argument that could drift from the tier this URL was actually
157    /// validated under.
158    trust: IndexTrust,
159}
160
161/// Why a candidate index URL failed [`RegistryIndex::new`]'s validation.
162///
163/// An alias of the shared [`deps_core::net_policy::IndexUrlError`] — see that type's docs
164/// for the variants and their wording.
165pub use deps_core::net_policy::IndexUrlError as RegistryIndexError;
166
167impl RegistryIndex {
168    /// Validates and wraps `raw` — a `registry-index` manifest value, a
169    /// `.cargo/config.toml` `[registries.<name>].index` value, or a `[source.<name>]
170    /// registry` value, either optionally prefixed with `sparse+`.
171    ///
172    /// `trust` states whose input `raw` is; for [`IndexTrust::WorkspaceDeclared`], `policy`'s
173    /// current [`deps_core::net_policy::WorkspaceRegistryAccess`] setting is consulted — a
174    /// [`IndexTrust::Trusted`] candidate is never policy-checked at all, since it is the
175    /// user's own `$CARGO_HOME` configuration, not something a cloned repository controls.
176    ///
177    /// # Errors
178    ///
179    /// Returns [`RegistryIndexError`] if `raw` does not parse as a URL, is not `https`,
180    /// carries a userinfo component, or (for a `WorkspaceDeclared` candidate) resolves to a
181    /// host class the current policy blocks.
182    ///
183    /// # Examples
184    ///
185    /// ```
186    /// use deps_cargo::config::{IndexTrust, RegistryIndex};
187    /// use deps_core::net_policy::RegistryAccessPolicy;
188    ///
189    /// let policy = RegistryAccessPolicy::default();
190    /// assert!(
191    ///     RegistryIndex::new("sparse+https://index.mycorp.dev", IndexTrust::Trusted, &policy)
192    ///         .is_ok()
193    /// );
194    /// assert!(
195    ///     RegistryIndex::new("http://index.mycorp.dev", IndexTrust::Trusted, &policy).is_err()
196    /// );
197    /// assert!(
198    ///     RegistryIndex::new(
199    ///         "https://user:pass@index.mycorp.dev",
200    ///         IndexTrust::Trusted,
201    ///         &policy
202    ///     )
203    ///     .is_err()
204    /// );
205    /// ```
206    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    /// Wraps a compile-time-known-safe literal (e.g. crates.io's own sparse index base),
221    /// bypassing [`IndexTrust`]/policy entirely — equivalent to [`Self::new`] with
222    /// [`IndexTrust::Trusted`], since a `Trusted` candidate is never policy-checked.
223    ///
224    /// `pub(crate)` — for a literal known at compile time, not a workspace-provenance value.
225    ///
226    /// # Panics
227    ///
228    /// Panics if `raw` fails [`Self::new`]'s validation. Covered by a unit test so this
229    /// panic is unreachable in practice.
230    #[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    /// The validated URL as a string, with no trailing slash guarantee either way (callers
239    /// splicing a path onto this must trim as needed — see `sparse::sparse_index_url`).
240    #[must_use]
241    pub fn as_str(&self) -> &str {
242        self.url.as_str()
243    }
244
245    /// The [`IndexTrust`] tier this index was validated under.
246    #[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/// One resolved `[registries.<name>]` entry.
259#[derive(Debug, Clone)]
260pub struct ResolvedRegistryEntry {
261    /// The validated, fetchable index URL.
262    pub index: RegistryIndex,
263    /// The bearer token to attach to requests against `index`, if any. `Some` only when
264    /// `provenance` is [`Provenance::CargoHome`] — see the module-level security-model
265    /// docs for why this is a structural, not runtime, guarantee.
266    pub auth: Option<AuthToken>,
267    /// Where this entry was resolved from. Diagnostics/logging only.
268    pub provenance: Provenance,
269}
270
271/// The merged, resolved view of a workspace's `.cargo/config.toml` hierarchy plus
272/// `$CARGO_HOME/config.toml`, for the aliases a manifest actually referenced.
273///
274/// Built by [`resolve`] — this type itself is a plain lookup table with no resolution
275/// logic of its own, so a caller holding one can check [`Self::get`] without needing to
276/// know anything about tiers, precedence, or the environment.
277#[derive(Debug, Default)]
278pub struct CargoConfig {
279    registries: HashMap<String, ResolvedRegistryEntry>,
280    /// Aliases whose resolution failed *specifically* because the current
281    /// [`deps_core::net_policy::RegistryAccessPolicy`] blocked the candidate host (spec
282    /// #443, plan-1b §1.7) — as opposed to "no matching config entry" or any other
283    /// validation failure. Surfaced by `crate::parser::resolve_alternate_registries` as a
284    /// positional diagnostic on the offending dependency's line.
285    blocked: HashMap<String, HostClass>,
286}
287
288impl CargoConfig {
289    /// The resolved entry for `alias`, if it resolved successfully.
290    #[must_use]
291    pub fn get(&self, alias: &str) -> Option<&ResolvedRegistryEntry> {
292        self.registries.get(alias)
293    }
294
295    /// The host class that blocked `alias`'s resolution, if that (and specifically that) is
296    /// why it did not resolve.
297    #[must_use]
298    pub(crate) fn blocked_class(&self, alias: &str) -> Option<HostClass> {
299        self.blocked.get(alias).copied()
300    }
301}
302
303/// Where a `[source.crates-io] replace-with` chain resolved to, for plain (`Registry`-sourced)
304/// dependencies (spec FR-005/FR-006/FR-007).
305#[derive(Debug, Clone, PartialEq)]
306pub enum SourceReplacement {
307    /// No `[source]` override applies — no `[source.crates-io]` table, a `directory`/
308    /// `local-registry`/non-sparse-git terminal (FR-006), a cyclic/unbounded chain
309    /// (FR-007), or a terminal that failed [`RegistryIndex::new`]'s validation/policy gate.
310    /// Plain dependencies keep resolving against crates.io, unchanged from today.
311    None,
312    /// The chain terminated at a `sparse+https://` index (FR-005).
313    SparseMirror {
314        /// The validated, fetchable mirror index.
315        index: RegistryIndex,
316        /// The bearer token to attach to requests against `index`, if any — populated only
317        /// when the whole chain's [`IndexTrust`] folded to [`IndexTrust::Trusted`] (plan-1b
318        /// §1.4's "coupled trust trap" guard): a single workspace-tier link anywhere in the
319        /// chain forces this to `None`, even if the terminal itself is a `$CARGO_HOME`
320        /// `[registries]` entry carrying a token.
321        auth: Option<AuthToken>,
322    },
323}
324
325/// A raw (unvalidated) `[source.<name>]` table entry, as parsed from one config file —
326/// unvalidated because validation ([`RegistryIndex::new`]'s policy gate, in particular)
327/// must run per parse against the live policy, never cached (plan-1b §1.5(b)).
328#[derive(Debug, Clone, PartialEq, Eq)]
329struct SourceEntry {
330    /// The table's own kind, if it declares one recognizable by [`classify_source_kind`].
331    /// `None` for a table with neither a `registry`/`directory`/`local-registry`/`git`
332    /// field nor (therefore) any classification — e.g. a bare `[source.crates-io]
333    /// replace-with = "..."` table, which has no kind of its own.
334    kind: Option<SourceKind>,
335    /// This table's own `replace-with` value, if any.
336    replace_with: Option<String>,
337}
338
339/// The kind of source a `[source.<name>]` table declares — classified purely by the
340/// `sparse+` prefix on a `registry` value (spec FR-005/FR-006), never carrying an
341/// [`IndexTrust`] of its own: trust is derived from the [`CachedTier`] the containing file
342/// belongs to at merge time, not tagged per entry (plan-1b §1.4/§1.5(c) — the same
343/// "per-entry tag a later reader must get right" hazard §1.5(c) already eliminated for
344/// `[registries]` entries).
345#[derive(Debug, Clone, PartialEq, Eq)]
346enum SourceKind {
347    /// `registry = "sparse+https://…"` — a terminal FR-005 candidate.
348    SparseRegistry {
349        /// The raw (unvalidated, `sparse+`-prefixed) index value.
350        raw: String,
351    },
352    /// `registry = "<bare https git index>"`, `directory = "…"`, `git = "…"`, or
353    /// `local-registry = "…"` — a terminal that keeps resolving plain dependencies against
354    /// crates.io, unchanged (FR-006/US-003).
355    NonSparse,
356}
357
358/// Classifies a `[source.<name>]` table's own kind, per [`SourceKind`]'s doc.
359fn 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
378/// Upper bound on the number of `[source.<name>]` tables read from a single config file
379/// (M6): the nesting-depth guard already bounds table *depth*, not table *count*, so this
380/// exists to bound that separately-unbounded dimension. `visited`-set cycle detection
381/// already bounds chain-following iteration on top of this.
382const MAX_SOURCE_ENTRIES: usize = 256;
383
384/// Parses one config file's `[source.<name>]` tables into raw (unvalidated) entries.
385///
386/// Shared by both tiers — a `[source]` table carries no token concept of its own (unlike
387/// `[registries]`), so there is nothing tier-specific about this extraction; the tier only
388/// matters when this file's [`SourceEntry`]s are later merged into a chain-resolution walk
389/// (see [`resolve_source_chain`]).
390fn 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
427/// Parses a table value's `index` field into a raw (unvalidated) string, warning and
428/// returning `None` only when the field is missing or not a string — actual URL validation
429/// happens per parse in [`RegistryIndex::new`], never here (plan-1b §1.5(b)): caching a
430/// validated `RegistryIndex` would go stale across a `didChangeConfiguration` policy change.
431fn 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
438/// Parses a workspace-declared `.cargo/config.toml`'s `[registries.<name>]` table into
439/// alias -> raw index string entries.
440///
441/// **No `token` field exists anywhere in this function's return type.** This is the
442/// structural half of the auth-provenance guarantee described in the module docs — a
443/// workspace-tier [`CachedTier::Workspace`] cannot represent a token at all, so no later
444/// reader can populate one for a workspace-sourced entry even by mistake (plan-1b §1.5(c)).
445fn 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
472/// Parses `$CARGO_HOME/config.toml`'s `[registries.<name>]` table into alias -> (raw index,
473/// token) entries — the one function in this module permitted to construct a populated
474/// [`AuthToken`], since its input is, by construction, always `$CARGO_HOME`-sourced.
475fn 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
507/// Cargo's env-var naming convention for a registry setting: uppercase the alias and
508/// replace every `-` with `_` (Cargo does the same substitution, which is exactly why two
509/// spellings of "the same" alias can collide — see spec FR-015).
510fn env_var_name(alias: &str, suffix: &str) -> String {
511    let screaming = alias.to_uppercase().replace('-', "_");
512    format!("CARGO_REGISTRIES_{screaming}_{suffix}")
513}
514
515/// One tier's raw registries table, plus the raw `[source]` tables from the same file —
516/// cached per config-file path by [`deps_core::MtimeFileCache`], keyed on mtime for
517/// invalidation (plan-1b §1.5(a)).
518///
519/// **Absence is never cached** (N5): only a file that existed, was a regular file, and
520/// parsed successfully gets an entry — a `.cargo/config.toml` created after the cache was
521/// first populated is picked up on the very next parse with no extra bookkeeping, since the
522/// ancestor walk re-checks existence every parse regardless.
523#[derive(Debug)]
524struct ParsedConfigFile {
525    tier: CachedTier,
526    sources: HashMap<String, SourceEntry>,
527}
528
529/// Which tier a [`ParsedConfigFile`] belongs to, chosen once — by the same
530/// canonicalized-path comparison against `$CARGO_HOME/config.toml` that already decides
531/// precedence in [`resolve`] — rather than becoming a per-entry tag later code must read
532/// correctly (plan-1b §1.5(c)).
533///
534/// The `Workspace` variant's map is `HashMap<String, String>`, with **no token field
535/// anywhere in its type** — [`parse_workspace_registries_raw`] keeps the guarantee its doc
536/// comment already claims: a function whose body has no code path that could populate one.
537#[derive(Debug)]
538enum CachedTier {
539    /// A `.cargo/config.toml` found by the ancestor walk. Alias -> raw index string.
540    Workspace(HashMap<String, String>),
541    /// `$CARGO_HOME/config.toml`. Alias -> (raw index string, token).
542    CargoHome(HashMap<String, (String, Option<AuthToken>)>),
543}
544
545/// Per-config-file memoization for `.cargo/config.toml`/`$CARGO_HOME/config.toml` parsing
546/// (spec NFR-005, plan-1b §1.5).
547///
548/// Caches **raw**, unvalidated tables — alias filtering, env overrides,
549/// [`RegistryIndex::new`] validation, and `[source]` chain walking all run **per parse**
550/// against these cached tables, never cached themselves. This is what keeps a policy change
551/// (`didChangeConfiguration`), a newly-referenced alias, or an env-var change taking effect
552/// immediately without any cache invalidation of their own.
553///
554/// Owned by `crate::parser::CargoParseContext` and shared across every document this
555/// ecosystem parses, so hundreds of workspace members sharing one `.cargo/config.toml`
556/// collapse to a single cached entry. A thin newtype over [`deps_core::MtimeFileCache`] —
557/// the mtime-gated caching mechanism itself lives there, shared with `deps-npm`.
558#[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    /// Creates an empty cache.
569    #[must_use]
570    pub fn new() -> Self {
571        Self(MtimeFileCache::new(
572            DEFAULT_MAX_CACHED_FILES,
573            "cargo config",
574        ))
575    }
576
577    /// Returns `path`'s parsed workspace-tier contents, from cache if `path`'s mtime is
578    /// unchanged, else re-reading and re-parsing.
579    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    /// [`Self::get_or_parse_workspace`], but for `$CARGO_HOME/config.toml`.
587    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/// `$CARGO_HOME/config.toml`'s path, or `None` if `$CARGO_HOME` is not set.
596///
597/// Deliberately reads only the `CARGO_HOME` environment variable — no fallback to
598/// `$HOME`/`%USERPROFILE%` when it is unset (spec FR-004), and no `dirs`/`home` crate
599/// dependency added to compute one.
600#[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
605/// [`cargo_home_config_path`], but reading the environment through `env` instead of
606/// [`std::env::var_os`] directly — lets tests inject a fake environment instead of
607/// mutating the real (`unsafe`-only, since Rust 2024) process environment.
608fn 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
614/// The loaded, per-file tiers a [`resolve`] call operates against — workspace tiers
615/// closest-first, plus the (at most one) `$CARGO_HOME` tier, both already excluding the
616/// canonicalized-path duplicate case (see [`resolve_with_env`]'s doc).
617struct 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    // A project living under `$HOME` (the default `CARGO_HOME=~/.cargo` layout) has
628    // `$HOME` as an ancestor directory, so the workspace-tier ancestor walk finds
629    // `~/.cargo/config.toml` too — the *same file* as `$CARGO_HOME/config.toml`. Left
630    // uncompared, that file would be double-counted as a workspace-tier entry, which wins
631    // outright over the real `$CARGO_HOME` tier and silently drops its token: the registry
632    // still resolves, just unauthenticated, so the bug looks like success. Comparing
633    // canonicalized paths (not just string equality) also catches a symlinked
634    // `$CARGO_HOME`.
635    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/// Resolves `referenced_aliases` against the `.cargo/config.toml` hierarchy and
655/// `$CARGO_HOME/config.toml`.
656///
657/// Separately resolves the `[source.crates-io] replace-with` chain (if any) for plain
658/// dependencies.
659///
660/// `referenced_aliases` is every distinct `registry = "<alias>"` value this manifest's
661/// dependencies declared; `workspace_config_paths` and `cargo_home_config_path` come from
662/// `crate::parser`'s merged ancestor walk (closest-first); `config_cache` memoizes each
663/// distinct config file's raw contents (spec NFR-005); `policy` gates every
664/// `WorkspaceDeclared` [`RegistryIndex`] this call constructs.
665///
666/// # Precedence
667///
668/// For one alias (or the `[source]` chain): the closest workspace `.cargo/config.toml`
669/// entry wins outright — if it resolves, the `$CARGO_HOME` tier (config file and
670/// environment variables alike) is never consulted for that alias at all. This is a
671/// deliberate divergence from Cargo's own env-beats-all-config-files precedence: since
672/// environment variables and `$CARGO_HOME/config.toml` are folded into one
673/// `$CARGO_HOME`-provenance tier here, an environment variable can never resurrect a
674/// credential for an alias a workspace file has shadowed (spec FR-009/US-004) — see the
675/// module-level security-model docs.
676///
677/// # Examples
678///
679/// ```
680/// use deps_cargo::config::{ConfigFileCache, SourceReplacement, resolve};
681/// use deps_core::net_policy::RegistryAccessPolicy;
682/// use std::collections::HashSet;
683///
684/// let aliases: HashSet<String> = std::iter::once("unconfigured".to_string()).collect();
685/// let cache = ConfigFileCache::new();
686/// let policy = RegistryAccessPolicy::default();
687/// let (config, source_replacement) = resolve(&aliases, &[], None, &cache, &policy);
688/// assert!(config.get("unconfigured").is_none());
689/// assert_eq!(source_replacement, SourceReplacement::None);
690/// ```
691#[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
709/// [`resolve`], but reading environment variables through `env` instead of
710/// [`std::env::var`] directly — lets tests inject a fake environment instead of mutating
711/// the real process environment (this workspace forbids `unsafe`, and Rust 2024 made
712/// `std::env::set_var`/`remove_var` `unsafe fn`s, so a test cannot do that mutation at
713/// all). Production callers always go through [`resolve`].
714fn 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    // FR-015: two distinct alias spellings deriving the same env-var name (e.g.
737    // "my-corp"/"my_corp" both -> CARGO_REGISTRIES_MY_CORP_INDEX) must not let either one
738    // pick up an env override meant for the other. Detected once, up front, over the whole
739    // referenced-alias set, rather than per-alias — a per-alias check would have nothing to
740    // compare against.
741    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            // Same as `resolve_alternate_registries`' unresolved-alias WARN (#536): `alias`
754            // here is a raw manifest `registry-index`/`registry` value, not a config-file
755            // alias name, so it may itself carry `user:pass@` userinfo — redact each entry
756            // before logging.
757            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
813/// Resolves one alias against the `$CARGO_HOME` tier: an environment-variable override
814/// first (when `env_allowed`), then `$CARGO_HOME/config.toml`'s own entry.
815fn 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
866/// Upper bound on `[source]` replace-with chain hops, on top of the `visited`-set cycle
867/// check — belt-and-braces (plan-1b §6 M6): the `visited` set alone already bounds a chain
868/// to at most the number of distinct source ids ever declared, but a small explicit cap
869/// keeps a pathological (though non-cyclic) chain from doing unbounded work in one parse.
870const MAX_SOURCE_REPLACEMENT_HOPS: usize = 16;
871
872/// Looks up `id` in the merged, raw `[registries]` tables (stage 1 of `[source]` id
873/// resolution, spec FR-005's "two-stage: alias -> source id", critic S4) — closest
874/// workspace tier first, then `$CARGO_HOME`.
875///
876/// Returns **only** the raw index string and its tier's [`IndexTrust`] — never the
877/// `$CARGO_HOME` tier's token, so a workspace-tier chain crossing into a `[registries]`
878/// entry has no way to carry that entry's credential forward even by accident (plan-1b
879/// §1.4's "coupled trust trap" guard, critic N3): the drop is enforced by this function's
880/// signature, not by a caller remembering to discard it.
881fn 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
901/// Looks `id` up as a `[registries]` alias in the `$CARGO_HOME` tier *specifically for its
902/// token* — used only once a chain's overall trust has already folded to
903/// [`IndexTrust::Trusted`], to re-derive the credential to attach rather than ever reading
904/// one off [`lookup_raw_registry_index`]'s result (plan-1b §1.4's coupled-trust-trap guard).
905fn 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
913/// Resolves the `[source.crates-io] replace-with` chain (spec FR-005/006/007, plan-1b §1.4).
914///
915/// Two-stage id resolution at every hop: the merged `[source]` tables first, then (stage 1,
916/// critic S4) the merged `[registries]` tables via [`lookup_raw_registry_index`] — a
917/// `[registries]` hit is terminal by construction, since a `[registries]` entry has no
918/// `replace-with` of its own.
919fn 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(&current_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(&current_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            // Cargo resolves `replace-with` **before** consulting the table's own kind
952            // (critic S2): `[source.crates-io]` carries an implicit builtin definition, and
953            // an explicit `registry =`/`directory =`/etc. alongside `replace-with` does not
954            // disable the replacement — the shape every large public mirror's setup
955            // instructions publish verbatim (`[source.crates-io] registry = "…git-index…"`
956            // *and* `replace-with = "mirror"` in the same table). Checking `kind` first, as
957            // an earlier revision of this function did, silently dropped the replacement for
958            // exactly that case.
959            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                        &current_id,
969                        tiers,
970                        policy,
971                    );
972                }
973                Some(SourceKind::NonSparse) | None => {
974                    return SourceReplacement::None;
975                }
976            }
977        }
978
979        // Stage 1 (critic S4): not declared as a `[source]` entry — try a `[registries]`
980        // crossover before giving up on this id.
981        if let Some((raw_index, crossover_trust)) = lookup_raw_registry_index(&current_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                    &current_id,
988                    tiers,
989                    policy,
990                );
991            }
992            return SourceReplacement::None;
993        }
994
995        // Unknown id (most commonly: no `[source.crates-io]` table declared at all, the
996        // common no-`[source]`-section case).
997        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            // Never read a token off the `[registries]` crossover lookup itself (see
1017            // `lookup_raw_registry_index`'s docs) — re-derive it here, gated purely on
1018            // whether the *whole chain* folded to `Trusted` (plan-1b §1.4).
1019            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/// Every distinct alias `dependencies` declares via `registry = "<alias>"`.
1038///
1039/// This is the input [`resolve`] needs to know which aliases are actually worth
1040/// resolving, so config discovery is skipped entirely (spec NFR-004) when this is empty.
1041#[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    /// S1: a userinfo-bearing `index = "…"` value that also fails `Url::parse` for an
1127    /// unrelated reason (an invalid port here) lands in `RegistryIndexError::InvalidUrl`, not
1128    /// `UserInfoPresent` — every call site here logs this error via `tracing::warn!(alias,
1129    /// %error, …)`, so the credential must never survive into `InvalidUrl`'s payload or its
1130    /// `Display`. Fixed once inside `deps_core::net_policy::validate_index_url`, which every
1131    /// `RegistryIndex::new` call routes through — no separate redaction needed here.
1132    #[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        // Also the coverage that makes `builtin`'s panic path unreachable in practice.
1156        let index = RegistryIndex::builtin("https://index.crates.io");
1157        assert_eq!(index.as_str(), "https://index.crates.io/");
1158    }
1159
1160    // Issue #455, test-plan item 11: `RegistryIndex::trust()` round-trips `new`'s argument.
1161    #[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    // Issue #455, test-plan item 11: `builtin` is always `Trusted`.
1178    #[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    /// Policy gate matrix (plan-1b §4): every `WorkspaceRegistryAccess` x `IndexTrust`
1185    /// combination against a metadata-IP URL. `Trusted` is always allowed (it is the
1186    /// user's own `$CARGO_HOME` config, never policy-checked); `WorkspaceDeclared` follows
1187    /// the policy exactly.
1188    #[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        // The return type itself has no token field — this just also confirms the `index`
1263        // value is captured correctly alongside the ignored `token` key.
1264        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    /// Regression: a project living under `$HOME` (the default `CARGO_HOME=~/.cargo`
1348    /// layout) has the ancestor walk pick up `~/.cargo/config.toml` — the *same file* as
1349    /// `$CARGO_HOME/config.toml` — as a workspace-tier candidate. Before the
1350    /// canonicalized-path exclusion, that duplicate entry won the workspace-tier-always-wins
1351    /// precedence and silently dropped the token: the alias still resolved, just
1352    /// unauthenticated, which looks like success.
1353    #[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        // Reproduces the ancestor-walk collision directly, without depending on
1365        // `crate::parser`'s merged walk (tested separately in `parser.rs`).
1366        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    /// FR-015: two distinct alias spellings deriving the same env-var name must both be
1446    /// skipped for env resolution, not have one arbitrarily win.
1447    #[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    /// The env-var TOKEN override must never resurrect a credential for an alias a
1465    /// workspace file has shadowed — the exact US-004 scenario, exercised through
1466    /// `resolve` end-to-end rather than only at the raw-parse unit level.
1467    #[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    // ---- ConfigFileCache ----
1538
1539    #[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    /// P1 (plan-1b §4 Performance/M4, flagged missing by the tester validator): the real
1560    /// bound is "at most two stats per ancestor directory... and zero filesystem reads per
1561    /// parse on a cache hit" — `Arc::ptr_eq` alone proves the *value* is reused, not that no
1562    /// syscall ran. This counts actual `stat`/`read` calls via `fs_probe`.
1563    #[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        // Prime the cache — the first call is necessarily a miss (one stat, one read).
1575        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    /// S3: adding a new `registry = "…"` alias to the manifest must resolve without any
1598    /// config-file change — the raw tables are cached, but alias *filtering* runs per
1599    /// parse.
1600    #[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        // No config-file write between these two calls — only the referenced-alias set
1621        // changed, simulating a manifest edit that adds `registry = "b"`.
1622        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    /// A `didChangeConfiguration`-driven policy change must take effect immediately, with
1632    /// no cache invalidation of its own — the policy is not part of the cache at all.
1633    #[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    // ---- [source] chain resolution ----
1660
1661    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        // 17 hops (crates-io -> hop0 -> ... -> hop16) exceeds MAX_SOURCE_REPLACEMENT_HOPS (16).
1805        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    /// Stage-1 crossover (critic S4): a `replace-with` naming a `[registries]` entry, not
1825    /// a `[source]` entry, must still resolve.
1826    #[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    /// S2 regression: `[source.crates-io]` carrying an explicit definition (here, a bare
1848    /// git-index `registry =`, exactly the shape Cargo treats as the implicit builtin
1849    /// crates.io definition) *and* `replace-with` in the same table — the shape every large
1850    /// public mirror's setup instructions publish verbatim. Cargo applies `replace-with`
1851    /// regardless of the explicit definition; this must resolve the mirror, not `None`.
1852    #[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    /// The inverse S2 shape: a table declaring both a *sparse* `registry =` of its own AND a
1878    /// `replace-with` pointing elsewhere. Cargo still follows `replace-with`, never the
1879    /// table's own `registry` value — asserting the resolved index is the replacement
1880    /// target, not the table's own (differently-hosted) sparse registry.
1881    #[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    /// The named regression test for the coupled-trust trap (critic N3): a workspace-tier
1909    /// `replace-with` crossing into a `$CARGO_HOME` `[registries]` entry that carries a
1910    /// token must resolve with `auth: None` — the workspace-tier link in the chain must
1911    /// never let a `$CARGO_HOME` credential ride along.
1912    #[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    /// The positive counterpart: when the *whole* chain is `$CARGO_HOME`-declared, the
1950    /// terminal `[registries]` entry's token is legitimately attached.
1951    #[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}