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}