Skip to main content

deps_pypi/
config.rs

1//! Private/custom PyPI index resolution — `--index-url`/`--extra-index-url`
2//! (`requirements.txt`), Poetry `[[tool.poetry.source]]`, and uv
3//! `[tool.uv.index]`/`[tool.uv.sources]`.
4//!
5//! # Security model (read before touching this module)
6//!
7//! A `requirements.txt`/`pyproject.toml` index declaration is attacker-controlled the moment a
8//! hostile repository is cloned and opened — this LSP parses on file open, before any build
9//! ever runs. Phase 1 carries no authentication at all (spec Out of Scope: no URL userinfo, no
10//! `keyring`/`.netrc`), which closes the credential half of the threat model this module would
11//! otherwise have to solve, but two things still apply:
12//!
13//! - **No credential-shaped value is ever parsed.** [`PypiIndexUrl::new`] rejects any URL
14//!   carrying `username()`/`password()` outright (FR-006/FR-011) — there is no expansion step
15//!   for PyPI config (unlike npm's `${VAR}`), so [`InvalidEntry::raw`] and every
16//!   `tracing::warn!` here name the as-written value with any embedded userinfo stripped first
17//!   (see `redact_userinfo`) — the raw value is otherwise preserved so a warning or a
18//!   [`DependencySource::CustomRegistry`] naming an unresolved primary/named source still shows
19//!   the user what they actually typed, minus the credential.
20//! - **FR-005's resolution order is the load-bearing security invariant of this whole
21//!   feature.** Case (a) (an explicit `--index-url`/Poetry primary/uv `default`): the
22//!   explicit primary is checked first, then extras — a deliberate user choice, no
23//!   disclosure risk. Case (b) (no explicit primary, extras only): declared extras are
24//!   checked **before** the implicit public `pypi.org` fallback, never the reverse — this is
25//!   what stops a private package's name from being sent to `pypi.org` before the user's own
26//!   declared index has had a chance, and what stops a same-named public package from
27//!   silently shadowing a private one. See [`PypiIndexConfig::resolve_source_for`] and
28//!   [`ResolvedChain`]'s docs.
29//!
30//! See `specs/033-pypi-private-index-support/spec.md` FR-001–FR-014 and
31//! `specs/033-pypi-private-index-support/plan.md` §1/§3 for the design review this module
32//! implements.
33
34use std::collections::HashMap;
35
36use deps_core::net_policy::{
37    PolicyGate, RegistryAccessPolicy, redact_userinfo, validate_index_url,
38};
39use deps_core::parser::DependencySource;
40
41/// Why a candidate index URL failed [`PypiIndexUrl::new`]'s validation.
42///
43/// An alias of the shared [`deps_core::net_policy::IndexUrlError`] — see that type's docs
44/// for the variants and their wording.
45pub use deps_core::net_policy::IndexUrlError as PypiIndexUrlError;
46
47/// A validated, normalized, https-only PyPI-protocol index URL with no embedded userinfo.
48///
49/// Mirrors `deps_npm::config::NpmRegistryIndex` (see FR-006/FR-011); kept `deps-pypi`-local
50/// rather than promoted to `deps-core` per this spec's Open Questions (consolidate only once a
51/// third near-identical type makes the duplication concrete).
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct PypiIndexUrl {
54    /// The validated URL, normalized by stripping a trailing `/` — matches
55    /// `simple_api_url`'s existing `{base}/{name}/` join convention (PEP 503).
56    normalized: String,
57}
58
59impl PypiIndexUrl {
60    /// Validates and normalizes `raw` against `policy`.
61    ///
62    /// # Errors
63    ///
64    /// Returns [`PypiIndexUrlError`] if `raw` does not parse as a URL, is not `https` (outside
65    /// the `cfg(test)`/`test-util` loopback carve-out), carries a userinfo component, or
66    /// resolves to a host class the current `policy` blocks.
67    ///
68    /// # Examples
69    ///
70    /// ```
71    /// use deps_core::net_policy::RegistryAccessPolicy;
72    /// use deps_pypi::config::PypiIndexUrl;
73    ///
74    /// let policy = RegistryAccessPolicy::default();
75    /// assert!(PypiIndexUrl::new("https://pypi.mycorp.example/simple", &policy).is_ok());
76    /// assert!(PypiIndexUrl::new("http://pypi.mycorp.example/simple", &policy).is_err());
77    /// assert!(PypiIndexUrl::new("https://user:pass@pypi.mycorp.example", &policy).is_err());
78    /// ```
79    pub fn new(raw: &str, policy: &RegistryAccessPolicy) -> Result<Self, PypiIndexUrlError> {
80        let url = validate_index_url(raw, raw, "pypi", PolicyGate::Enforce(policy))?;
81        let normalized = url.as_str().trim_end_matches('/').to_string();
82        Ok(Self { normalized })
83    }
84
85    /// The normalized index URL. Never carries a trailing `/`.
86    #[must_use]
87    pub fn as_str(&self) -> &str {
88        &self.normalized
89    }
90}
91
92impl std::fmt::Display for PypiIndexUrl {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(self.as_str())
95    }
96}
97
98/// A present-but-unusable index entry — an invalid URL, a policy-blocked host, or a
99/// well-formed-but-non-https/userinfo-bearing value.
100///
101/// Carries the raw value as written, **with any embedded userinfo redacted** (M1 fix — see
102/// [`deps_core::net_policy::redact_userinfo`]), so [`PypiIndexConfig::resolve_source_for`] can build
103/// [`DependencySource::CustomRegistry`] for an explicit primary/named source, or log a warning
104/// naming what the user wrote for a dropped extra, without ever holding or surfacing the
105/// credential itself: a `CustomRegistry.url` can reach hover/diagnostics text, and a
106/// `UserInfoPresent` rejection is exactly the case where `raw` would otherwise still contain
107/// `user:pass@`.
108#[derive(Debug, Clone)]
109pub struct InvalidEntry {
110    /// The raw index value, as written in the source file, with any `user:pass@`/`user@`
111    /// userinfo component stripped.
112    pub raw: String,
113    /// Why it was rejected.
114    pub reason: PypiIndexUrlError,
115}
116
117/// Validates and normalizes one raw index value, logging a `tracing::warn!` naming the raw
118/// value (userinfo redacted — see [`deps_core::net_policy::redact_userinfo`]) on failure.
119/// `pub(crate)`: every parser
120/// surface (`requirements.rs`, `pyproject.rs`) that discovers a candidate index value calls
121/// this before handing the result to a [`PypiIndexConfig`] setter.
122pub(crate) fn resolve_entry(
123    raw: &str,
124    policy: &RegistryAccessPolicy,
125) -> Result<PypiIndexUrl, InvalidEntry> {
126    PypiIndexUrl::new(raw, policy).map_err(|reason| {
127        let redacted = redact_userinfo(raw);
128        tracing::warn!(raw = %redacted, %reason, "PyPI index URL failed validation");
129        InvalidEntry {
130            raw: redacted,
131            reason,
132        }
133    })
134}
135
136/// One fully-resolved, ready-to-register routing chain — produced by
137/// [`PypiIndexConfig::resolved_chains`], consumed by
138/// `PypiRegistry::register_chain`/`register_named_source`.
139#[derive(Debug, Clone)]
140pub struct ResolvedChain {
141    /// Composite identity — becomes both the router's `alternates` map key and the
142    /// `DependencySource::AlternateRegistry.index` value for a plain (non-named-source)
143    /// dependency.
144    ///
145    /// For a primary/extras chain (case a/b): an opaque, single-line hashed token produced by
146    /// [`deps_core::hash_routing_key`] (`"pypi-chain"`) over the ordered hop strings plus the
147    /// [`Self::implicit_public_fallback`] flag — **never** a newline-joined or otherwise
148    /// URL-shaped value: `deps-core`'s `AlternateRegistry.index` doc describes this field as
149    /// "the resolved index URL" for Cargo/npm, and a value that merely *looks* like a URL
150    /// (but isn't one) would violate that contract for any future reader — see plan.md's N6
151    /// fix. An explicit chain `[A, B]` and an implicit chain that happens to resolve to the
152    /// same hops `[A, B]` plus the fallback flag produce **different** keys (this is what
153    /// closes the C2 aliasing defect: two files sharing a primary but differing extras never
154    /// collide, and editing a file's extras changes its key on the next reparse).
155    ///
156    /// For a named-source chain (Poetry `source =`/uv `index =`): the source's own literal
157    /// URL, matching Cargo/npm's convention for a single resolved index.
158    pub key: String,
159    /// Ordered, already-validated hops. Hop 0 becomes the registered client's own
160    /// `simple_base`; the rest become its `fallback_chain`. Never empty — see
161    /// [`PypiIndexConfig::resolved_chains`]'s zero-hop handling.
162    pub hops: Vec<PypiIndexUrl>,
163    /// `true` only for a case-(b) chain (spec FR-005(b)) whose final hop is the implicit
164    /// public `pypi.org` root, appended at registration time rather than present in
165    /// [`Self::hops`] — `PypiRegistry::register_chain` builds that hop itself.
166    pub implicit_public_fallback: bool,
167}
168
169impl ResolvedChain {
170    fn chain(hops: Vec<PypiIndexUrl>, implicit_public_fallback: bool) -> Self {
171        let flag = if implicit_public_fallback {
172            "true"
173        } else {
174            "false"
175        };
176        let key = deps_core::hash_routing_key(
177            "pypi-chain",
178            hops.iter()
179                .map(PypiIndexUrl::as_str)
180                .chain(std::iter::once(flag)),
181        );
182        Self {
183            key,
184            hops,
185            implicit_public_fallback,
186        }
187    }
188
189    fn named_source(url: PypiIndexUrl) -> Self {
190        Self {
191            key: url.as_str().to_string(),
192            hops: vec![url],
193            implicit_public_fallback: false,
194        }
195    }
196}
197
198/// The effective case-(b) hop list (spec FR-005(b)): declared extras, in order, plus a final
199/// hop that is either a concrete uv `default = true` index or the implicit public fallback.
200struct CaseBChain {
201    hops: Vec<PypiIndexUrl>,
202    implicit_public_fallback: bool,
203}
204
205/// Resolved index configuration for one `requirements.txt`/`pyproject.toml` file.
206///
207/// Built once per parse (two-pass for `requirements.txt` — see `parser::requirements`'s
208/// module doc; single-pass for `pyproject.toml`, whose TOML tree is fully available before any
209/// dependency is resolved), consulted per-dependency via [`Self::resolve_source_for`].
210#[derive(Debug, Default)]
211pub struct PypiIndexConfig {
212    /// Explicit `--index-url`, a Poetry `primary`/`default`-priority source (including one
213    /// with no `priority` key at all — FR-007), or a Poetry `explicit`/unrecognized-priority
214    /// source contributes nothing here. uv **never** populates this field (FR-013's r3
215    /// correction) — a pure-uv config always routes through [`Self::case_b_chain`] instead.
216    /// `None` when no explicit primary is declared (spec FR-005(b) applies).
217    primary: Option<Result<PypiIndexUrl, InvalidEntry>>,
218    /// `--extra-index-url` values (declaration order preserved) plus Poetry
219    /// `supplemental`/`secondary`-priority sources plus every non-`default`/non-`explicit` uv
220    /// `[tool.uv.index]` entry — FR-005's fallback chain. An `Err(InvalidEntry)` here is
221    /// dropped (with a warning already logged by [`resolve_entry`]) rather than escalated,
222    /// per FR-006's extra-specific rule.
223    extras: Vec<Result<PypiIndexUrl, InvalidEntry>>,
224    /// uv's `default = true` index, if any (uv permits at most one) — uv's lowest-priority,
225    /// last-resort hop, replacing the implicit public fallback in that final slot. `None` for
226    /// every non-uv config, and for a uv config that declares no `default` entry (the
227    /// implicit public fallback is used instead).
228    tail_hop: Option<Result<PypiIndexUrl, InvalidEntry>>,
229    /// Poetry `[[tool.poetry.source]]` entries (all priorities, including `explicit`) keyed
230    /// by `name`, plus every uv `[tool.uv.index]` entry keyed by its own `name` — consulted
231    /// only when a dependency declares `source = "<name>"` (Poetry) or an `index = "<name>"`
232    /// uv-sources binding (FR-013), never auto-included in the primary/extras/tail chain.
233    named_sources: HashMap<String, Result<PypiIndexUrl, InvalidEntry>>,
234}
235
236impl PypiIndexConfig {
237    /// An empty config — every dependency resolves to plain [`DependencySource::Registry`],
238    /// byte-identical to today (US-004).
239    #[must_use]
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    /// FR-002: sets (overwriting any prior value) the explicit `--index-url`/Poetry
245    /// `primary`-equivalent primary. Matches pip's own `argparse` `store` semantics for
246    /// `--index-url` (not `append`, unlike `--extra-index-url`) — the last occurrence in a
247    /// file wins.
248    pub fn set_primary(&mut self, raw: &str, policy: &RegistryAccessPolicy) {
249        self.primary = Some(resolve_entry(raw, policy));
250    }
251
252    /// Like [`Self::set_primary`], but for a caller that already resolved (or is re-using an
253    /// already-resolved) candidate — avoids re-validating and double-logging a value that
254    /// must also be reachable as a named source (Poetry/uv).
255    ///
256    /// **First registration wins** (validator finding S3, fixes a silent-overwrite bug): a
257    /// second call is ignored, with a `tracing::warn!` naming the raw value that got dropped
258    /// — unlike [`Self::set_primary`] (`requirements.txt`'s `--index-url`, where pip's own
259    /// `argparse` `store` semantics make *last*-wins correct), Poetry has no documented
260    /// ordering for multiple `primary`/`default`-priority (or unlabeled) `[[tool.poetry.source]]`
261    /// entries, so silently picking whichever happened to parse last is an arbitrary,
262    /// non-deterministic-looking outcome — keeping the first and logging every later one is
263    /// deterministic and discoverable instead.
264    pub(crate) fn set_primary_resolved(&mut self, result: Result<PypiIndexUrl, InvalidEntry>) {
265        if self.primary.is_some() {
266            let raw = match &result {
267                Ok(url) => url.as_str(),
268                Err(invalid) => invalid.raw.as_str(),
269            };
270            tracing::warn!(
271                raw,
272                "multiple primary-priority index sources declared; keeping the first, \
273                 ignoring this one"
274            );
275            return;
276        }
277        self.primary = Some(result);
278    }
279
280    /// FR-003: appends an `--extra-index-url`/Poetry supplemental-priority/uv non-default
281    /// entry to the fallback chain, in declaration order.
282    pub fn add_extra(&mut self, raw: &str, policy: &RegistryAccessPolicy) {
283        self.extras.push(resolve_entry(raw, policy));
284    }
285
286    /// Like [`Self::add_extra`], but for an already-resolved candidate — see
287    /// [`Self::set_primary_resolved`]'s rationale.
288    pub(crate) fn add_extra_resolved(&mut self, result: Result<PypiIndexUrl, InvalidEntry>) {
289        self.extras.push(result);
290    }
291
292    /// FR-013: sets (overwriting any prior value — uv permits at most one) uv's
293    /// `default = true` index, an already-resolved candidate.
294    pub(crate) fn set_tail_hop_resolved(&mut self, result: Result<PypiIndexUrl, InvalidEntry>) {
295        self.tail_hop = Some(result);
296    }
297
298    /// FR-007/FR-013: registers a Poetry/uv named source, reachable via
299    /// [`Self::resolve_source_for`] with `Some(name)`. An already-resolved candidate — see
300    /// [`Self::set_primary_resolved`]'s rationale.
301    pub(crate) fn add_named_source_resolved(
302        &mut self,
303        name: String,
304        result: Result<PypiIndexUrl, InvalidEntry>,
305    ) {
306        self.named_sources.insert(name, result);
307    }
308
309    /// The valid (`Ok`) subset of [`Self::extras`], in declaration order.
310    fn valid_extras(&self) -> Vec<PypiIndexUrl> {
311        self.extras
312            .iter()
313            .filter_map(|e| e.as_ref().ok())
314            .cloned()
315            .collect()
316    }
317
318    /// The effective case-(b) chain (spec FR-005(b)), or `None` when this config has no
319    /// case-(b) declaration at all (no extras, no uv tail hop) — the "nothing declared"
320    /// state, distinct from "declared but every hop turned out invalid" (the zero-hop case,
321    /// which returns `Some` with an empty `hops`).
322    ///
323    /// An invalid uv `default` entry (`tail_hop` is `Some(Err(_))`) degrades to the implicit
324    /// public fallback rather than being dropped with no replacement — the same
325    /// fail-toward-availability rule FR-006 applies to every other extra.
326    fn case_b_chain(&self) -> Option<CaseBChain> {
327        if self.extras.is_empty() && self.tail_hop.is_none() {
328            return None;
329        }
330        let mut hops = self.valid_extras();
331        let implicit_public_fallback = match &self.tail_hop {
332            Some(Ok(tail)) => {
333                hops.push(tail.clone());
334                false
335            }
336            Some(Err(_)) | None => true,
337        };
338        Some(CaseBChain {
339            hops,
340            implicit_public_fallback,
341        })
342    }
343
344    /// FR-002/FR-003/FR-005/FR-006/FR-007/FR-013: resolves one dependency's
345    /// [`DependencySource`].
346    ///
347    /// `named_source` is `Some("internal")` for a dependency declaring `source = "internal"`
348    /// (Poetry) or an `index = "internal"` uv-sources binding; `None` for every other
349    /// dependency (routes through `primary`/`extras`/`tail_hop` per FR-005 instead).
350    ///
351    /// - A named-source reference resolves to that source's own URL, or
352    ///   [`DependencySource::CustomRegistry`] if the name is unresolved or the source is
353    ///   invalid (fail-closed, never a silent `pypi.org` fallback — FR-006).
354    /// - No override anywhere (no primary, no case-(b) declaration) -> plain
355    ///   [`DependencySource::Registry`] (US-004).
356    /// - An explicit primary present but invalid -> [`DependencySource::CustomRegistry`]
357    ///   (fail-closed — never falls through to the extras chain, matching an explicit
358    ///   `--index-url`'s *replace*, not *add*, semantics).
359    /// - Otherwise -> [`DependencySource::AlternateRegistry`] pointing at the chain
360    ///   [`ResolvedChain::key`] this same config's [`Self::resolved_chains`] registers.
361    #[must_use]
362    pub fn resolve_source_for(&self, named_source: Option<&str>) -> DependencySource {
363        if let Some(name) = named_source {
364            return match self.named_sources.get(name) {
365                Some(Ok(url)) => DependencySource::AlternateRegistry {
366                    index: url.as_str().to_string(),
367                    mirrors_crates_io: false,
368                },
369                Some(Err(invalid)) => DependencySource::CustomRegistry {
370                    url: invalid.raw.clone(),
371                },
372                None => DependencySource::CustomRegistry {
373                    url: name.to_string(),
374                },
375            };
376        }
377
378        match &self.primary {
379            Some(Ok(primary)) => {
380                let mut hops = vec![primary.clone()];
381                hops.extend(self.valid_extras());
382                DependencySource::AlternateRegistry {
383                    index: ResolvedChain::chain(hops, false).key,
384                    mirrors_crates_io: false,
385                }
386            }
387            Some(Err(invalid)) => DependencySource::CustomRegistry {
388                url: invalid.raw.clone(),
389            },
390            None => match self.case_b_chain() {
391                Some(chain) if !chain.hops.is_empty() => DependencySource::AlternateRegistry {
392                    index: ResolvedChain::chain(chain.hops, chain.implicit_public_fallback).key,
393                    mirrors_crates_io: false,
394                },
395                _ => DependencySource::Registry,
396            },
397        }
398    }
399
400    /// Every chain this config implies, ready for registration — FR-005(a)/(b) resolved to
401    /// concrete hop lists, plus one single-hop chain per valid named source. Empty when the
402    /// file declares nothing (US-004) or when every case-(b) hop turned out invalid with no
403    /// explicit primary (N5's zero-hop case — [`Self::resolve_source_for`] returns plain
404    /// `Registry` in that case, and there is nothing to register).
405    #[must_use]
406    pub fn resolved_chains(&self) -> Vec<ResolvedChain> {
407        let mut chains = Vec::new();
408
409        match &self.primary {
410            Some(Ok(primary)) => {
411                let mut hops = vec![primary.clone()];
412                hops.extend(self.valid_extras());
413                chains.push(ResolvedChain::chain(hops, false));
414            }
415            Some(Err(_)) => {}
416            None => {
417                if let Some(chain) = self.case_b_chain()
418                    && !chain.hops.is_empty()
419                {
420                    chains.push(ResolvedChain::chain(
421                        chain.hops,
422                        chain.implicit_public_fallback,
423                    ));
424                }
425            }
426        }
427
428        for url in self.named_sources.values().flatten() {
429            chains.push(ResolvedChain::named_source(url.clone()));
430        }
431
432        chains
433    }
434}
435
436#[cfg(test)]
437mod tests {
438    use super::*;
439    use deps_core::net_policy::WorkspaceRegistryAccess;
440    use std::assert_matches;
441
442    fn all_policy() -> RegistryAccessPolicy {
443        RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
444    }
445
446    fn off_policy() -> RegistryAccessPolicy {
447        RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off)
448    }
449
450    // --- PypiIndexUrl ---
451
452    #[test]
453    fn test_index_url_accepts_https() {
454        let policy = all_policy();
455        assert!(PypiIndexUrl::new("https://pypi.mycorp.example/simple", &policy).is_ok());
456    }
457
458    #[test]
459    fn test_index_url_rejects_http_non_loopback() {
460        let policy = all_policy();
461        assert_matches!(
462            PypiIndexUrl::new("http://pypi.mycorp.example/simple", &policy),
463            Err(PypiIndexUrlError::NotHttps(_))
464        );
465    }
466
467    #[test]
468    fn test_index_url_accepts_http_loopback_under_test_cfg() {
469        let policy = all_policy();
470        assert!(PypiIndexUrl::new("http://127.0.0.1:9999/simple", &policy).is_ok());
471        assert!(PypiIndexUrl::new("http://localhost:9999/simple", &policy).is_ok());
472    }
473
474    #[test]
475    fn test_index_url_rejects_http_near_miss_loopback() {
476        let policy = all_policy();
477        assert_matches!(
478            PypiIndexUrl::new("http://localhost.evil.com/simple", &policy),
479            Err(PypiIndexUrlError::NotHttps(_))
480        );
481    }
482
483    #[test]
484    fn test_index_url_rejects_userinfo() {
485        let policy = all_policy();
486        assert_matches!(
487            PypiIndexUrl::new("https://user:pass@pypi.mycorp.example/simple", &policy),
488            Err(PypiIndexUrlError::UserInfoPresent)
489        );
490    }
491
492    #[test]
493    fn test_index_url_rejects_invalid_url() {
494        let policy = all_policy();
495        assert_matches!(
496            PypiIndexUrl::new("not-a-valid-url", &policy),
497            Err(PypiIndexUrlError::InvalidUrl(_))
498        );
499    }
500
501    #[test]
502    fn test_index_url_normalizes_trailing_slash() {
503        let policy = all_policy();
504        let with_slash = PypiIndexUrl::new("https://pypi.mycorp.example/simple/", &policy).unwrap();
505        let without_slash =
506            PypiIndexUrl::new("https://pypi.mycorp.example/simple", &policy).unwrap();
507        assert_eq!(with_slash, without_slash);
508        assert!(!with_slash.as_str().ends_with('/'));
509    }
510
511    #[test]
512    fn test_index_url_policy_matrix() {
513        assert!(PypiIndexUrl::new("https://127.0.0.1:9999/simple", &all_policy()).is_ok());
514        assert_matches!(
515            PypiIndexUrl::new("https://127.0.0.1:9999/simple", &off_policy()),
516            Err(PypiIndexUrlError::BlockedHost { .. })
517        );
518    }
519
520    // --- PypiIndexConfig::resolve_source_for / resolved_chains ---
521
522    #[test]
523    fn test_no_declaration_resolves_to_plain_registry() {
524        let config = PypiIndexConfig::new();
525        assert_eq!(config.resolve_source_for(None), DependencySource::Registry);
526        assert!(config.resolved_chains().is_empty());
527    }
528
529    /// FR-005(a): an explicit primary alone.
530    #[test]
531    fn test_case_a_primary_only() {
532        let policy = all_policy();
533        let mut config = PypiIndexConfig::new();
534        config.set_primary("https://pypi.mycorp.example/simple", &policy);
535
536        let source = config.resolve_source_for(None);
537        let DependencySource::AlternateRegistry {
538            index,
539            mirrors_crates_io,
540        } = source
541        else {
542            panic!("expected AlternateRegistry");
543        };
544        assert!(!mirrors_crates_io);
545        assert!(index.starts_with("pypi-chain:"));
546
547        let chains = config.resolved_chains();
548        assert_eq!(chains.len(), 1);
549        assert_eq!(chains[0].key, index);
550        assert_eq!(chains[0].hops.len(), 1);
551        assert!(!chains[0].implicit_public_fallback);
552    }
553
554    /// Validator finding S3: `set_primary_resolved` keeps the first registration when called
555    /// more than once — a second Poetry `primary`/`default`-priority source must not silently
556    /// overwrite the first.
557    #[test]
558    fn test_set_primary_resolved_keeps_first_on_duplicate() {
559        let policy = all_policy();
560        let mut config = PypiIndexConfig::new();
561        config.set_primary_resolved(resolve_entry("https://first.example/simple", &policy));
562        config.set_primary_resolved(resolve_entry("https://second.example/simple", &policy));
563
564        let chains = config.resolved_chains();
565        assert_eq!(chains.len(), 1);
566        assert_eq!(chains[0].hops.len(), 1);
567        assert_eq!(chains[0].hops[0].as_str(), "https://first.example/simple");
568    }
569
570    /// FR-005(a): primary + extras — no implicit public hop appended.
571    #[test]
572    fn test_case_a_primary_plus_extras() {
573        let policy = all_policy();
574        let mut config = PypiIndexConfig::new();
575        config.set_primary("https://primary.example/simple", &policy);
576        config.add_extra("https://extra.example/simple", &policy);
577
578        let chains = config.resolved_chains();
579        assert_eq!(chains.len(), 1);
580        assert_eq!(chains[0].hops.len(), 2);
581        assert_eq!(chains[0].hops[0].as_str(), "https://primary.example/simple");
582        assert_eq!(chains[0].hops[1].as_str(), "https://extra.example/simple");
583        assert!(!chains[0].implicit_public_fallback);
584    }
585
586    /// FR-005(b): extras only, no explicit primary — implicit public fallback is the last
587    /// hop.
588    #[test]
589    fn test_case_b_extras_only_no_primary() {
590        let policy = all_policy();
591        let mut config = PypiIndexConfig::new();
592        config.add_extra("https://extra.example/simple", &policy);
593
594        let source = config.resolve_source_for(None);
595        assert_matches!(source, DependencySource::AlternateRegistry { .. });
596
597        let chains = config.resolved_chains();
598        assert_eq!(chains.len(), 1);
599        assert_eq!(chains[0].hops.len(), 1);
600        assert!(chains[0].implicit_public_fallback);
601    }
602
603    /// N5/second critic pass: every extra dropped (e.g. policy-blocked) and no explicit
604    /// primary -> zero-hop case, degrades to plain `Registry`, nothing registered.
605    #[test]
606    fn test_zero_hop_case_degrades_to_plain_registry() {
607        let policy = off_policy();
608        let mut config = PypiIndexConfig::new();
609        config.add_extra("https://extra.example/simple", &policy);
610
611        assert_eq!(config.resolve_source_for(None), DependencySource::Registry);
612        assert!(config.resolved_chains().is_empty());
613    }
614
615    /// C2 regression: an explicit chain `[A, B]` and an implicit chain that resolves to the
616    /// same hops `[A, B]` (plus the fallback flag) must produce different keys.
617    #[test]
618    fn test_chain_key_distinguishes_explicit_from_implicit() {
619        let policy = all_policy();
620
621        let mut explicit = PypiIndexConfig::new();
622        explicit.set_primary("https://a.example/simple", &policy);
623        explicit.add_extra("https://b.example/simple", &policy);
624
625        let mut implicit = PypiIndexConfig::new();
626        implicit.add_extra("https://a.example/simple", &policy);
627        implicit.add_extra("https://b.example/simple", &policy);
628
629        let explicit_key = explicit.resolved_chains()[0].key.clone();
630        let implicit_key = implicit.resolved_chains()[0].key.clone();
631        assert_ne!(explicit_key, implicit_key);
632    }
633
634    /// C2 regression: two configs sharing a primary but differing extras produce different
635    /// keys.
636    #[test]
637    fn test_chain_key_differs_by_extras() {
638        let policy = all_policy();
639
640        let mut one = PypiIndexConfig::new();
641        one.set_primary("https://primary.example/simple", &policy);
642        one.add_extra("https://extra-a.example/simple", &policy);
643
644        let mut two = PypiIndexConfig::new();
645        two.set_primary("https://primary.example/simple", &policy);
646        two.add_extra("https://extra-b.example/simple", &policy);
647
648        assert_ne!(one.resolved_chains()[0].key, two.resolved_chains()[0].key);
649    }
650
651    /// An invalid explicit primary fails closed — never falls through to extras.
652    #[test]
653    fn test_invalid_primary_fails_closed() {
654        let policy = all_policy();
655        let mut config = PypiIndexConfig::new();
656        config.set_primary("not-a-valid-url", &policy);
657        config.add_extra("https://extra.example/simple", &policy);
658
659        assert_eq!(
660            config.resolve_source_for(None),
661            DependencySource::CustomRegistry {
662                url: "not-a-valid-url".to_string(),
663            }
664        );
665        assert!(config.resolved_chains().is_empty());
666    }
667
668    /// An invalid extra is dropped, not escalated to `CustomRegistry` — the primary still
669    /// resolves via its remaining valid hop(s) (S6-shaped: one bad extra must not break a
670    /// chain that still has a working hop).
671    #[test]
672    fn test_invalid_extra_dropped_not_escalated() {
673        let policy = all_policy();
674        let mut config = PypiIndexConfig::new();
675        config.set_primary("https://primary.example/simple", &policy);
676        config.add_extra("not-a-valid-url", &policy);
677
678        let chains = config.resolved_chains();
679        assert_eq!(chains.len(), 1);
680        assert_eq!(chains[0].hops.len(), 1);
681        assert_eq!(chains[0].hops[0].as_str(), "https://primary.example/simple");
682    }
683
684    /// Named source: resolves independently of primary/extras.
685    #[test]
686    fn test_named_source_resolves_independently() {
687        let policy = all_policy();
688        let mut config = PypiIndexConfig::new();
689        config.add_named_source_resolved(
690            "internal".to_string(),
691            resolve_entry("https://internal.example/simple", &policy),
692        );
693
694        let source = config.resolve_source_for(Some("internal"));
695        assert_eq!(
696            source,
697            DependencySource::AlternateRegistry {
698                index: "https://internal.example/simple".to_string(),
699                mirrors_crates_io: false,
700            }
701        );
702
703        let chains = config.resolved_chains();
704        assert_eq!(chains.len(), 1);
705        assert_eq!(chains[0].key, "https://internal.example/simple");
706    }
707
708    /// A `source = "<name>"` reference with no matching entry fails closed, not a silent
709    /// public-registry fallback.
710    #[test]
711    fn test_unresolved_named_source_fails_closed() {
712        let config = PypiIndexConfig::new();
713        assert_eq!(
714            config.resolve_source_for(Some("does-not-exist")),
715            DependencySource::CustomRegistry {
716                url: "does-not-exist".to_string(),
717            }
718        );
719    }
720
721    /// uv shape: a `default = true` entry is the last-resort hop, not checked first, and
722    /// never populates `primary`.
723    #[test]
724    fn test_uv_default_is_last_resort_not_primary() {
725        let policy = all_policy();
726        let mut config = PypiIndexConfig::new();
727        config.add_extra("https://non-default.example/simple", &policy);
728        config.set_tail_hop_resolved(resolve_entry("https://default.example/simple", &policy));
729
730        assert!(config.primary.is_none());
731
732        let chains = config.resolved_chains();
733        assert_eq!(chains.len(), 1);
734        assert_eq!(chains[0].hops.len(), 2);
735        assert_eq!(
736            chains[0].hops[0].as_str(),
737            "https://non-default.example/simple"
738        );
739        assert_eq!(chains[0].hops[1].as_str(), "https://default.example/simple");
740        assert!(!chains[0].implicit_public_fallback);
741    }
742
743    /// An invalid uv `default` entry degrades to the implicit public fallback rather than
744    /// leaving the chain with no final hop at all.
745    #[test]
746    fn test_uv_invalid_default_degrades_to_implicit_public() {
747        let policy = all_policy();
748        let mut config = PypiIndexConfig::new();
749        config.add_extra("https://non-default.example/simple", &policy);
750        config.set_tail_hop_resolved(resolve_entry("not-a-valid-url", &policy));
751
752        let chains = config.resolved_chains();
753        assert_eq!(chains.len(), 1);
754        assert_eq!(chains[0].hops.len(), 1);
755        assert!(chains[0].implicit_public_fallback);
756    }
757
758    /// NFR-001/SC-005 structural guarantee: a URL carrying userinfo is rejected at
759    /// construction (FR-011 — reject, never strip-and-proceed), so no [`PypiIndexUrl`] ever
760    /// exists whose `normalized` field holds a credential. `PypiIndexConfig`/`InvalidEntry`
761    /// have no `${VAR}`-expansion step (unlike npm) and no separate auth-shaped key field
762    /// anywhere in this module. See [`test_resolve_entry_redacts_userinfo_from_raw_and_log`]
763    /// below for the M1 fix: `InvalidEntry::raw` and the `tracing::warn!` line built from it
764    /// also never retain the credential itself, even though both otherwise preserve the raw,
765    /// as-written value.
766    #[test]
767    fn test_userinfo_rejected_never_retained_in_index_url() {
768        let policy = all_policy();
769        let err =
770            PypiIndexUrl::new("https://user:hunter2@pypi.example/simple", &policy).unwrap_err();
771        assert_eq!(err, PypiIndexUrlError::UserInfoPresent);
772    }
773
774    /// M1 fix: `resolve_entry`'s `InvalidEntry::raw` and its `tracing::warn!` line must never
775    /// carry a userinfo-bearing URL's credential through — both are built from
776    /// [`redact_userinfo`], not the untouched raw string, even though the raw value is
777    /// otherwise preserved (FR-006) so a warning or a `CustomRegistry.url` a user might see in
778    /// hover/diagnostics still names what they typed, minus the secret.
779    #[test]
780    fn test_resolve_entry_redacts_userinfo_from_raw_and_log() {
781        let policy = all_policy();
782        let log = deps_core::test_util::capture_tracing_output(|| {
783            let invalid =
784                resolve_entry("https://user:hunter2@pypi.example/simple", &policy).unwrap_err();
785            assert_matches!(invalid.reason, PypiIndexUrlError::UserInfoPresent);
786            assert!(
787                !invalid.raw.contains("hunter2"),
788                "InvalidEntry::raw leaked the credential: {}",
789                invalid.raw
790            );
791            assert!(
792                !invalid.raw.contains("user:"),
793                "InvalidEntry::raw leaked the username: {}",
794                invalid.raw
795            );
796            assert!(
797                invalid.raw.contains("pypi.example"),
798                "host should survive redaction"
799            );
800        });
801        assert!(
802            !log.contains("hunter2"),
803            "tracing output leaked the credential: {log:?}"
804        );
805    }
806
807    /// [`redact_userinfo`] is a no-op for a value with no userinfo component (the common
808    /// case), and for `"not-a-valid-url"` specifically — no `://` at all, so there is no
809    /// authority for even the parse-independent fallback to inspect (see
810    /// `deps_core::net_policy`'s own `test_redact_userinfo_redacts_unparseable_url_with_userinfo`
811    /// for the case where an unparseable value *does* still carry userinfo).
812    #[test]
813    fn test_redact_userinfo_noop_cases() {
814        assert_eq!(
815            redact_userinfo("https://pypi.mycorp.example/simple"),
816            "https://pypi.mycorp.example/simple"
817        );
818        assert_eq!(redact_userinfo("not-a-valid-url"), "not-a-valid-url");
819    }
820
821    #[test]
822    fn test_redact_userinfo_strips_username_and_password() {
823        let redacted = redact_userinfo("https://user:hunter2@pypi.example/simple");
824        assert!(!redacted.contains("hunter2"));
825        assert!(!redacted.contains("user:"));
826        // Spec's exact example format: a fixed `***@` marker, not a bare stripped host — this
827        // keeps a redacted value visibly distinct from a URL that never carried userinfo.
828        assert_eq!(redacted, "https://***@pypi.example/simple");
829    }
830
831    /// S1: a userinfo-bearing index value that also fails `Url::parse` for an unrelated reason
832    /// (an invalid port here) lands in `PypiIndexUrlError::InvalidUrl`, not `UserInfoPresent` —
833    /// the shape `redact_userinfo`'s original parse-gated no-op missed. Checks every channel:
834    /// `InvalidEntry::raw`, the `%reason` `Display`, and the captured log.
835    #[test]
836    fn test_resolve_entry_redacts_literal_userinfo_from_unparseable_raw() {
837        let policy = all_policy();
838        let log = deps_core::test_util::capture_tracing_output(|| {
839            let invalid = resolve_entry("https://user:hunter2@pypi.example:99999/simple", &policy)
840                .unwrap_err();
841            assert_matches!(invalid.reason, PypiIndexUrlError::InvalidUrl(_));
842            assert!(
843                !invalid.raw.contains("hunter2"),
844                "InvalidEntry::raw leaked the credential: {}",
845                invalid.raw
846            );
847            assert!(
848                !invalid.reason.to_string().contains("hunter2"),
849                "reason Display leaked the credential: {}",
850                invalid.reason
851            );
852        });
853        assert!(
854            !log.contains("hunter2"),
855            "tracing output leaked the credential: {log:?}"
856        );
857    }
858}