Skip to main content

deps_nuget/
config.rs

1//! `NuGet.Config` `<packageSources>`/`<packageSourceMapping>` discovery and resolution —
2//! private/custom feed support (issue #523).
3//!
4//! # Security model (read before touching this module)
5//!
6//! A repository's `NuGet.Config` is attacker-controlled the moment a hostile repository is
7//! cloned and opened — this LSP parses on file open, before any build ever runs.
8//!
9//! - **No credential is ever parsed.** `<packageSourceCredentials>` is walked for *child
10//!   element names only* (each names a source key) into a set — no `Username`/
11//!   `ClearTextPassword`/`Password` value is ever deserialized into a field, so no code path
12//!   in this module can hold a credential (NFR-001 is then structurally provable). A source
13//!   named there becomes [`NuGetFeedUrlError::HasCredentials`] and is dropped, fail-closed.
14//! - **`<clear/>` is merged root→leaf, not "nearest file wins".** A root `NuGet.Config`
15//!   `<clear/>` that removes the implicit `nuget.org` hop must stay removed for every
16//!   descendant project, even one whose own `NuGet.Config` adds a feed without repeating the
17//!   `<clear/>` — otherwise a leaf file would silently resurrect the public hop the root
18//!   explicitly cleared (the #248 bug class). See [`resolve_with_context`]'s accumulation loop.
19//! - **`<packageSourceMapping>` is merged across every level too**, not "nearest file wins":
20//!   a root mapping `{CorpFeed: ["MyCompany.*"], nuget.org: ["*"]}` combined with a leaf
21//!   mapping `{nuget.org: ["*"]}` must still route `MyCompany.Internal` to `CorpFeed` — taking
22//!   only the leaf's `*` entire would leak the private package name to `nuget.org`, exactly
23//!   the dependency-confusion attack this feature exists to close. See
24//!   `PackageSourceMapping::resolve_keys_for`.
25//! - **A `packageSourceMapping` key resolving to more than one distinct declared source is
26//!   treated as unresolvable, not fanned out to every match.** Source-key matching is
27//!   deliberately case/XML-name-insensitive (union of the raw and decoded forms — FR-009), and
28//!   growing an *exclusion* set (disabled/credentialed) that way is fail-closed, but growing
29//!   an *inclusion* set (which feed a mapped package routes to) the same way is fail-open — see
30//!   `resolve_mapping_source_key`.
31//! - **The public `nuget.org` source is identified by normalized URL, never by a source's
32//!   configured `key`.** A hostile config can name a private feed `"nuget.org"`; only an exact
33//!   match against `crate::registry::NUGET_ORG_INDEX_URL` restores the OSV/deps.dev/hover
34//!   trust signal a genuine public-registry dependency gets — see
35//!   `crate::registry::is_public_registry_url`.
36//! - **A config chain that clears every source down to zero, with nothing re-added, is an
37//!   explicit fail-closed state**, never a silent fallback to `nuget.org` — see
38//!   `NO_SOURCES_CONFIGURED_SENTINEL`.
39//!
40//! See `specs/035-nuget-private-feed-support/spec.md` for the full requirements.
41
42use std::collections::HashSet;
43use std::hash::{Hash, Hasher};
44use std::path::{Path, PathBuf};
45use std::sync::Arc;
46use std::sync::atomic::AtomicBool;
47
48use base64::Engine;
49use deps_core::PackageName;
50use deps_core::fs_probe::MAX_CONFIG_ANCESTOR_DEPTH;
51use deps_core::net_policy::{
52    IndexUrlError, PolicyGate, RegistryAccessPolicy, redact_userinfo, validate_index_url,
53};
54use deps_core::parser::DependencySource;
55use quick_xml::Reader;
56use quick_xml::events::Event;
57use zeroize::Zeroizing;
58
59/// Candidate filenames checked per directory, in order — real NuGet is case-insensitive on
60/// Linux/macOS and all three spellings occur in the wild. `is_file()` stats, not `read_dir`.
61const CONFIG_FILENAMES: &[&str] = &["NuGet.Config", "nuget.config", "NuGet.config"];
62
63/// Sentinel [`DependencySource::CustomRegistry`] URL for a config chain that clears every
64/// package source down to zero with nothing re-added — R4: this must be a distinct,
65/// explicitly-named fail-closed state, never allowed to fall through to plain
66/// [`DependencySource::Registry`] (the #248 bug re-entering through the empty-set door).
67/// Never fetched — informational text only, safe to render in hover/diagnostics.
68const NO_SOURCES_CONFIGURED_SENTINEL: &str = "<clear/> removed every NuGet package source";
69
70/// Why a candidate `<add value="...">` failed validation, or why it was dropped as
71/// disabled/credentialed/unsupported.
72#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
73pub enum NuGetFeedUrlError {
74    /// The value did not parse as a URL at all.
75    #[error("not a valid URL: {0}")]
76    InvalidUrl(String),
77    /// The URL's scheme is not `https`.
78    #[error("registry feed must use https, got scheme {0:?}")]
79    NotHttps(String),
80    /// The URL carries a `user:pass@`/`user@` component.
81    #[error("registry feed URL must not carry userinfo")]
82    UserInfoPresent,
83    /// The candidate's host is blocked by the current
84    /// [`deps_core::net_policy::WorkspaceRegistryAccess`] policy.
85    #[error("registry feed host class {class} blocked by registries.workspace_registries policy")]
86    BlockedHost {
87        /// The blocked host's classification.
88        class: deps_core::net_policy::HostClass,
89    },
90    /// The source has an entry under `<packageSourceCredentials>` — credentials are never
91    /// read, so the source is dropped rather than queried unauthenticated (FR-009).
92    #[error("source has packageSourceCredentials configured; credentials are never read")]
93    HasCredentials,
94    /// The source is named in `<disabledPackageSources>` with a `true` value (FR-004).
95    #[error("source is disabled via disabledPackageSources")]
96    Disabled,
97    /// A `protocolVersion="2"` (NuGet V2) source — only V3 feeds are supported.
98    #[error("unsupported NuGet protocolVersion {0:?}; only V3 feeds are supported")]
99    UnsupportedProtocolVersion(String),
100    /// A local filesystem/UNC path feed (e.g. `../packages`, `\\server\share`) — legitimate
101    /// and common, but out of scope; logged at `debug!`, not `warn!` (unlike a genuinely
102    /// malformed value), so a normal local-feed setup doesn't warn on every parse.
103    #[error("local/UNC feed paths are not supported")]
104    LocalFeedUnsupported,
105    /// A DPAPI-encrypted `<Password>` credential value (issue #561, FR-003) — Windows-only,
106    /// `CryptUnprotectData`-dependent, not portably decryptable. Permanently out of scope;
107    /// rejected at parse time rather than attempting decryption or silently dropping it.
108    #[error("DPAPI-encrypted <Password> credentials are not supported")]
109    EncryptedPasswordUnsupported,
110}
111
112impl From<IndexUrlError> for NuGetFeedUrlError {
113    fn from(error: IndexUrlError) -> Self {
114        match error {
115            IndexUrlError::InvalidUrl(raw) => Self::InvalidUrl(raw),
116            IndexUrlError::NotHttps(scheme) => Self::NotHttps(scheme),
117            IndexUrlError::UserInfoPresent => Self::UserInfoPresent,
118            IndexUrlError::BlockedHost { class } => Self::BlockedHost { class },
119        }
120    }
121}
122
123/// A validated, normalized, https-only NuGet V3 service index URL with no embedded userinfo.
124///
125/// Mirrors `deps_pypi::config::PypiIndexUrl`/`deps_npm::config::NpmRegistryIndex`.
126#[derive(Debug, Clone, PartialEq, Eq, Hash)]
127pub struct NuGetFeedUrl {
128    /// The validated URL, normalized by stripping a trailing `/`.
129    normalized: String,
130}
131
132impl NuGetFeedUrl {
133    /// Validates and normalizes `raw` against `policy`.
134    ///
135    /// # Errors
136    ///
137    /// Returns [`NuGetFeedUrlError`] if `raw` does not parse as a URL, is not `https` (outside
138    /// the `cfg(test)`/`test-util` loopback carve-out), carries a userinfo component, or
139    /// resolves to a host class the current `policy` blocks.
140    pub fn new(raw: &str, policy: &RegistryAccessPolicy) -> Result<Self, NuGetFeedUrlError> {
141        let url = validate_index_url(raw, raw, "nuget", PolicyGate::Enforce(policy))?;
142        Ok(Self {
143            normalized: url.as_str().trim_end_matches('/').to_string(),
144        })
145    }
146
147    /// The normalized feed URL. Never carries a trailing `/`.
148    #[must_use]
149    pub fn as_str(&self) -> &str {
150        &self.normalized
151    }
152
153    /// The real public NuGet service index, trusted unconditionally — never policy-gated,
154    /// since it is a hardcoded constant this LSP already queries ungated for every project
155    /// declaring no `NuGet.Config` override at all (FR-010's carve-out), not workspace
156    /// provenance. Used only for S1 (impl-critic): a `<packageSourceMapping>` key literally
157    /// naming `nuget.org` that does not resolve to any declared `<packageSources>` entry —
158    /// the near-universal real shape where `nuget.org` itself is declared in the
159    /// machine/user-profile config this feature deliberately does not read.
160    fn trusted_public() -> Self {
161        Self {
162            normalized: crate::registry::NUGET_ORG_INDEX_URL.to_string(),
163        }
164    }
165}
166
167impl std::fmt::Display for NuGetFeedUrl {
168    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// Which tier a parsed `NuGet.Config` file came from (issue #561, FR-001).
174///
175/// Diagnostics/gating metadata only — mirrors `deps_cargo::config::Provenance`'s "nothing
176/// branches on this to *widen* trust" invariant. In particular, [`PackageSourceEntry::tier`]
177/// is **not** consulted by the credential-binding logic in [`resolve_with_context`] (see that function's
178/// docs, §C2) — only by the [`resolve_with_context`] accumulation loop's own gate (which contribution half
179/// applies) and by `tracing::debug!` output.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
181pub enum ConfigTier {
182    /// A user-profile-tier `NuGet.Config` (issue #561, FR-001) — not something a cloned
183    /// repository controls.
184    UserProfile,
185    /// An in-repo `NuGet.Config`, discovered by the ancestor walk (spec 035 FR-001) —
186    /// attacker-controlled the moment a hostile repository is opened.
187    Repo,
188}
189
190/// A pre-formatted `Basic base64(username:password)` `Authorization` header value (issue #561).
191///
192/// Redacted everywhere except the one call site (`crate::registry::NuGetRegistry::fetch`) that
193/// reads it into a request header. Constructible only from within this crate (see this
194/// module's security-model doc) and deliberately does **not** derive `Hash` — making "a fully
195/// expanded, ready-to-send `Authorization` credential inside a hash key" a compile error rather
196/// than a review item for *this type specifically* (NFR-001/FR-016), not a blanket
197/// no-credential-derives-`Hash` rule for the module — see this module's private
198/// `RedactedSecret` type's own `Hash` derive for the (deliberately different, and
199/// narrower-risk) case that one exists for. Never stores the username/password separately once
200/// constructed.
201///
202/// A thin wrapper over [`deps_core::secret::Redacted`] rather than a bare type alias:
203/// `Debug` prints `NuGetAuth(***)`, not `Redacted(***)`, so a panic message or log line
204/// still names which credential leaked its type.
205#[derive(Clone, PartialEq, Eq)]
206pub struct NuGetAuth(deps_core::secret::Redacted);
207
208impl NuGetAuth {
209    /// Formats `username`/`password` into a `Basic` header value. `pub(crate)`: only
210    /// [`resolve_with_context`]'s final C2 pass, gated on [`ConfigTier::UserProfile`], ever constructs one.
211    ///
212    /// Every intermediate (the raw `user:pass` string, and the base64 encoding of it —
213    /// reversible, not encryption) is held in [`Zeroizing`] from the point of construction,
214    /// not just the final header value, so no un-zeroized plaintext copy is left behind.
215    pub(crate) fn new(username: &str, password: &str) -> Self {
216        let mut user_pass =
217            Zeroizing::new(String::with_capacity(username.len() + 1 + password.len()));
218        user_pass.push_str(username);
219        user_pass.push(':');
220        user_pass.push_str(password);
221        let encoded = Zeroizing::new(base64::engine::general_purpose::STANDARD.encode(&*user_pass));
222        Self(deps_core::secret::Redacted::new(format!(
223            "Basic {}",
224            *encoded
225        )))
226    }
227
228    /// The pre-formatted header value. Never logged, printed, or otherwise surfaced — callers
229    /// must not pass this to anything but an `Authorization` header.
230    pub(crate) fn header_value(&self) -> &str {
231        self.0.expose_secret()
232    }
233}
234
235impl std::fmt::Debug for NuGetAuth {
236    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
237        f.write_str("NuGetAuth(***)")
238    }
239}
240
241impl std::fmt::Display for NuGetAuth {
242    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
243        f.write_str("***")
244    }
245}
246
247/// A `<packageSourceCredentials>` `Username`/`ClearTextPassword` literal, held **pre-%ENV_VAR%-
248/// expansion** (issue #561, FR-002) — redacted everywhere except [`resolve_with_context`]'s final pass,
249/// which expands and consumes it into a [`NuGetAuth`].
250///
251/// Unlike [`NuGetAuth`] (which deliberately does not derive `Hash`, see its doc), this type
252/// does — exempt from that concern because its only `Hash` use is
253/// `resolve_with_context`'s `config_fingerprint` (via `RawCredential`/`RawNuGetConfigFile`),
254/// a process-local `u64` debounce key for [`fail_closed`]'s warning dedup. That key is never
255/// logged, serialized, sent over the wire, or compared across processes — only inserted into an
256/// in-memory `DashSet` for the lifetime of one server run — so hashing a still-unexpanded,
257/// pre-`%ENV_VAR%` literal into it carries none of the "credential reaches a place it
258/// shouldn't" risk the `NuGetAuth` restriction guards against.
259///
260/// A thin wrapper over [`deps_core::secret::Redacted`] rather than a bare type alias:
261/// `Debug` prints `RedactedSecret(***)`, not `Redacted(***)`. `Redacted<T>`'s own `Hash` impl
262/// (opt-in via `T: Hash`) is what makes the derive below possible without reaching around the
263/// wrapper's redaction/zeroize guarantees.
264#[derive(Clone, PartialEq, Eq, Hash)]
265struct RedactedSecret(deps_core::secret::Redacted);
266
267impl RedactedSecret {
268    fn new(value: String) -> Self {
269        Self(deps_core::secret::Redacted::new(value))
270    }
271
272    fn expose_secret(&self) -> &str {
273        self.0.expose_secret()
274    }
275}
276
277impl std::fmt::Debug for RedactedSecret {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.write_str("RedactedSecret(***)")
280    }
281}
282
283impl std::fmt::Display for RedactedSecret {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.write_str("***")
286    }
287}
288
289/// A present-but-unusable `<add>` entry — an invalid URL, a policy-blocked host, a
290/// disabled/credentialed source, or an unsupported protocol/local-feed value.
291#[derive(Debug, Clone)]
292pub struct InvalidEntry {
293    /// The raw value, as written (or the source's resolved URL if it was invalidated only
294    /// after passing URL validation, e.g. disabled/credentialed), with any embedded userinfo
295    /// redacted.
296    pub raw: String,
297    /// Why it was rejected.
298    pub reason: NuGetFeedUrlError,
299}
300
301/// One resolved `<packageSources>` entry, keyed by its declared `key` (case preserved, but
302/// every comparison against it goes through `key_candidates`).
303#[derive(Debug, Clone)]
304pub struct PackageSourceEntry {
305    pub key: String,
306    pub value: Result<NuGetFeedUrl, InvalidEntry>,
307    /// Which tier's file last set [`Self::value`] (issue #561) — diagnostics/gating metadata
308    /// only, see [`ConfigTier`]'s doc for the "never a credential gate" invariant.
309    pub tier: ConfigTier,
310    /// Set only by [`resolve_with_context`]'s final C2 pass, gated on [`ConfigTier::UserProfile`] — never
311    /// during accumulation (`upsert_source` always writes `None` here; see its doc).
312    pub auth: Option<NuGetAuth>,
313}
314
315/// One resolved hop in a [`NuGetSourceChain`] (issue #561, FR-016).
316///
317/// Replaces the plain `NuGetFeedUrl` a hop used to be, so `NuGetConfig::resolve_source_for` and
318/// `NuGetConfig::resolved_chains` (both of which reach `NuGetSourceChain::chain` exclusively
319/// through `NuGetConfig::valid_hops`/`NuGetConfig::hops_for_mapping_keys`) necessarily agree on
320/// each hop's credential data — there is no second, independently-maintained argument for it to
321/// disagree with.
322#[derive(Debug, Clone)]
323pub struct ResolvedHop {
324    pub url: NuGetFeedUrl,
325    /// The lowercased declared `<add key>` that supplied [`Self::auth`], or `None` when this
326    /// hop carries no credential. Used (not the credential value) by
327    /// `NuGetSourceChain::chain`'s hash and by `NuGetRegistry::register_chain`'s
328    /// rotation-detection, so a chain's identity is stable across a credential *value*
329    /// rotation under the same declared key.
330    pub slot: Option<String>,
331    /// Never hashed (see `NuGetSourceChain::chain`) and never fully `Debug`-printed
332    /// ([`NuGetAuth`] redacts).
333    pub auth: Option<NuGetAuth>,
334}
335
336impl ResolvedHop {
337    /// Two-part `&str` encoding of [`Self::slot`] for [`NuGetSourceChain::chain`]'s hash — a
338    /// presence marker (`"slot"`/`"no-slot"`) followed by the slot string itself (`""` when
339    /// absent). Keeping the per-hop part count constant, rather than folding presence into a
340    /// single sentinel value, means no possible `<add key>` value can ever collide with the
341    /// no-slot case. A dedicated accessor rather than `format!("{:?}", self.slot)`: chain
342    /// identity must not depend on `Option`'s `Debug` formatting, and writing one narrow
343    /// accessor per hashed field (instead of reaching for `Debug` on whatever is convenient)
344    /// keeps `NuGetSourceChain::chain` from ever needing to `Debug`-format [`Self::auth`],
345    /// which deliberately does not derive `Hash` (see its doc) precisely so that a credential
346    /// cannot be added to this hash without a visible, deliberate type change.
347    fn slot_key_parts(&self) -> [&str; 2] {
348        self.slot
349            .as_deref()
350            .map_or(["no-slot", ""], |slot| ["slot", slot])
351    }
352}
353
354/// One fully-resolved routing chain, produced by [`NuGetConfig::resolved_chains`], consumed by
355/// `NuGetRegistry::register_chain`. Mirrors `deps_pypi::config::ResolvedChain` exactly.
356#[derive(Debug, Clone)]
357pub struct NuGetSourceChain {
358    /// Opaque, hashed identity produced by [`deps_core::hash_routing_key`] (`"nuget-chain"`)
359    /// over each hop's URL and [`ResolvedHop::slot`] (**never** [`ResolvedHop::auth`] — FR-016)
360    /// plus [`Self::implicit_public_fallback`]. [`NuGetConfig::resolve_source_for`] and
361    /// [`NuGetConfig::resolved_chains`] recompute this independently and must agree.
362    pub key: String,
363    /// Ordered, already-validated hops. Never empty.
364    pub hops: Vec<ResolvedHop>,
365    /// `true` only for the plain (non-mapping) chain when no ancestor `<clear/>` removed the
366    /// implicit public fallback — the public hop is appended at registration time, never
367    /// present in [`Self::hops`]. Always `false` for a `<packageSourceMapping>`-derived
368    /// chain: a mapping is authoritative once it names a package, so the chain never appends
369    /// the public hop even when `<clear/>` is absent (this is the dependency-confusion leak
370    /// fix — see this module's doc).
371    pub implicit_public_fallback: bool,
372}
373
374impl NuGetSourceChain {
375    fn chain(hops: Vec<ResolvedHop>, implicit_public_fallback: bool) -> Self {
376        let flag = if implicit_public_fallback {
377            "true"
378        } else {
379            "false"
380        };
381        let key = deps_core::hash_routing_key(
382            "nuget-chain",
383            hops.iter()
384                .flat_map(|hop| {
385                    let [presence, slot] = hop.slot_key_parts();
386                    [hop.url.as_str(), presence, slot]
387                })
388                .chain(std::iter::once(flag)),
389        );
390        Self {
391            key,
392            hops,
393            implicit_public_fallback,
394        }
395    }
396}
397
398/// `<packageSourceMapping>` rules, merged across every level of the config chain (R1 — see
399/// this module's doc). Grouped by normalized pattern: `patterns[i] = (pattern, source_keys)`.
400#[derive(Debug, Clone, Default)]
401struct PackageSourceMapping {
402    patterns: Vec<(String, Vec<String>)>,
403}
404
405/// A pattern's match specificity: `Wildcard` < `Prefix(len)` < `Exact(len)`, so the derived
406/// `Ord` implements NuGet's real tie-break rule (exact always beats prefix, regardless of
407/// either's character length; among same-kind matches, the longer one wins).
408#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
409enum MatchScore {
410    Wildcard,
411    Prefix(usize),
412    Exact(usize),
413}
414
415impl PackageSourceMapping {
416    fn is_empty(&self) -> bool {
417        self.patterns.is_empty()
418    }
419
420    /// Adds one accumulated `(source_key, patterns)` declaration — called once per ancestor
421    /// file, root-to-leaf, so a pattern declared at multiple levels accumulates every source
422    /// key that ever named it (R1's merge, replacing "nearest file wins").
423    fn extend(&mut self, source_key: &str, patterns: &[String]) {
424        for pattern in patterns {
425            let normalized_pattern = pattern.to_lowercase();
426            if let Some((_, keys)) = self
427                .patterns
428                .iter_mut()
429                .find(|(p, _)| *p == normalized_pattern)
430            {
431                let dup = keys.iter().any(|k| key_candidates_overlap(k, source_key));
432                if !dup {
433                    keys.push(source_key.to_string());
434                }
435            } else {
436                self.patterns
437                    .push((normalized_pattern, vec![source_key.to_string()]));
438            }
439        }
440    }
441
442    /// Real NuGet `<packageSourceMapping>` matching: bare `*`, a trailing-`*` prefix glob, or
443    /// an exact id — case-insensitive, longest/most-specific match wins, ties make every tied
444    /// pattern's source keys eligible (declaration order, deduplicated by `key_candidates`).
445    /// `None` when no pattern matches `name_lower` at all (FR-008: fail closed, never falls
446    /// through to an unmapped registry).
447    fn resolve_keys_for(&self, name_lower: &str) -> Option<Vec<&str>> {
448        let mut best: Option<MatchScore> = None;
449        let mut winners: Vec<&(String, Vec<String>)> = Vec::new();
450
451        for entry @ (pattern, _) in &self.patterns {
452            let score = if pattern == "*" {
453                Some(MatchScore::Wildcard)
454            } else if let Some(prefix) = pattern.strip_suffix('*') {
455                name_lower
456                    .starts_with(prefix)
457                    .then(|| MatchScore::Prefix(prefix.chars().count()))
458            } else {
459                (name_lower == pattern).then(|| MatchScore::Exact(pattern.chars().count()))
460            };
461            let Some(score) = score else { continue };
462            match best {
463                Some(b) if score < b => {}
464                Some(b) if score == b => winners.push(entry),
465                _ => {
466                    best = Some(score);
467                    winners = vec![entry];
468                }
469            }
470        }
471
472        if winners.is_empty() {
473            return None;
474        }
475        let mut keys: Vec<&str> = Vec::new();
476        for (_, group_keys) in winners {
477            for k in group_keys {
478                if !keys
479                    .iter()
480                    .any(|existing| key_candidates_overlap(existing, k))
481                {
482                    keys.push(k.as_str());
483                }
484            }
485        }
486        Some(keys)
487    }
488}
489
490/// Inverse of .NET `XmlConvert.EncodeLocalName`: decodes `_xHHHH_` escapes (4 hex digits) back
491/// to their UTF-16 code unit — `_x005F_` decodes to a literal `_`. NuGet XML-encodes
492/// non-alphanumeric characters in a `<packageSourceCredentials>` child element name (a source
493/// named `Corp Feed` appears as `<Corp_x0020_Feed>`).
494///
495/// Malformed sequences and lone surrogates are left literal rather than erroring — the raw
496/// candidate (see `key_candidates`) still covers them, so nothing is ever dropped, only
497/// possibly not perfectly reconstructed.
498fn decode_xml_name(raw: &str) -> String {
499    let chars: Vec<char> = raw.chars().collect();
500    let mut out = String::with_capacity(raw.len());
501    let mut i = 0;
502    while i < chars.len() {
503        if chars[i] == '_' && chars.get(i + 1) == Some(&'x') && chars.get(i + 6) == Some(&'_') {
504            let hex: String = chars[i + 2..i + 6].iter().collect();
505            if hex.chars().all(|c| c.is_ascii_hexdigit())
506                && let Ok(code) = u32::from_str_radix(&hex, 16)
507                && let Some(ch) = char::from_u32(code)
508            {
509                out.push(ch);
510                i += 7;
511                continue;
512            }
513        }
514        out.push(chars[i]);
515        i += 1;
516    }
517    out
518}
519
520/// Union comparison funnel for **every** source key comparison (FR-009): a source key
521/// matches `other` if either its raw-lowercased or XML-name-decoded-lowercased form matches
522/// the same for `other`. Used both as an *exclusion* filter (disabled/credentialed — where
523/// matching more is fail-closed) and, with an extra unambiguous-resolution requirement layered
524/// on top (see `resolve_mapping_source_key`), for `<packageSourceMapping>` key lookups.
525fn key_candidates(raw: &str) -> [String; 2] {
526    [raw.to_lowercase(), decode_xml_name(raw).to_lowercase()]
527}
528
529fn key_candidates_overlap(a: &str, b: &str) -> bool {
530    let ca = key_candidates(a);
531    let cb = key_candidates(b);
532    ca.iter().any(|x| cb.contains(x))
533}
534
535/// Resolves `key` to *exactly one* item of `items` whose `key_of` overlaps it (§3.6, FR-009).
536/// Zero matches and more-than-one match (an ambiguous union collision) both return `None` —
537/// contributing nothing, rather than fanning out to every candidate. Used at exactly three
538/// *inclusion* lookup sites: [`resolve_mapping_source_key`] (R2), and — in `resolve`'s final
539/// C2 pass — condition (1) (credential key -> resolved entry) and condition (2) (credential key
540/// -> `user_profile_add` entry). Every other [`key_candidates_overlap`] use in this module is an
541/// *exclusion* lookup (disabled/credentialed membership, the §3.4 suppression set,
542/// `file.removed`'s `retain`) and correctly stays a plain union match — union is the
543/// fail-closed direction for an exclusion, exactly-one is the fail-closed direction for an
544/// inclusion.
545fn unique_overlap<'s, T>(key: &str, items: &'s [T], key_of: impl Fn(&T) -> &str) -> Option<&'s T> {
546    let mut matches = items
547        .iter()
548        .filter(|t| key_candidates_overlap(key_of(t), key));
549    let first = matches.next()?;
550    if matches.next().is_some() {
551        return None;
552    }
553    Some(first)
554}
555
556/// R2 fix: resolves a `<packageSourceMapping>` key to *exactly one* declared source. Zero
557/// matches (the key names a source absent from `<packageSources>`, e.g. because it was never
558/// declared or was filtered out as disabled/credentialed) and more-than-one match (an
559/// ambiguous union collision) both return `None` — contributing nothing, rather than fanning a
560/// mapped package out to every candidate. Unlike the disabled/credentialed exclusion sets,
561/// this is an *inclusion* lookup, where growing the match set on a union comparison would be
562/// fail-open, not fail-closed.
563fn resolve_mapping_source_key<'s>(
564    mapping_key: &str,
565    sources: &'s [PackageSourceEntry],
566) -> Option<&'s PackageSourceEntry> {
567    let resolved = unique_overlap(mapping_key, sources, |s| s.key.as_str());
568    if resolved.is_none()
569        && sources
570            .iter()
571            .any(|s| key_candidates_overlap(&s.key, mapping_key))
572    {
573        tracing::debug!(
574            key = mapping_key,
575            "packageSourceMapping key resolves to more than one declared source; treating as unresolvable"
576        );
577    }
578    resolved
579}
580
581/// Resolved `NuGet.Config` view for one manifest's directory — the merged result of every
582/// in-repo ancestor `NuGet.Config` (root-to-leaf accumulation, see this module's doc).
583#[derive(Debug, Default)]
584pub struct NuGetConfig {
585    sources: Vec<PackageSourceEntry>,
586    /// Sticky across the whole ancestor walk: once any ancestor's `<clear/>` removed the
587    /// implicit public fallback, a leaf that adds a feed without repeating `<clear/>` does
588    /// not resurrect it.
589    cleared: bool,
590    /// Sticky across the whole ancestor walk, same rationale as `cleared`: once any ancestor
591    /// explicitly `<remove key="nuget.org"/>`s the implicit public source (S4, impl-critic),
592    /// the implicit fallback stays removed — distinct from `cleared`, which additionally
593    /// wipes every other accumulated source. An explicit `<add key="..."
594    /// value="https://api.nuget.org/v3/index.json"/>` still resurrects it as a normal
595    /// declared hop, exactly as it does after `<clear/>`.
596    nuget_org_removed: bool,
597    mapping: PackageSourceMapping,
598}
599
600impl NuGetConfig {
601    /// FR-002–FR-009: resolves one dependency's [`DependencySource`].
602    #[must_use]
603    pub fn resolve_source_for(&self, package: &PackageName) -> DependencySource {
604        if !self.mapping.is_empty() {
605            return self.resolve_via_mapping(package);
606        }
607        self.resolve_plain()
608    }
609
610    fn resolve_via_mapping(&self, package: &PackageName) -> DependencySource {
611        let name_lower = package.as_str().to_lowercase();
612        let Some(keys) = self.mapping.resolve_keys_for(&name_lower) else {
613            return no_source(package);
614        };
615        let hops = self.hops_for_mapping_keys(&keys);
616        if hops.is_empty() {
617            return no_source(package);
618        }
619        if hops.len() == 1 && crate::registry::is_public_registry_url(hops[0].url.as_str()) {
620            return DependencySource::Registry;
621        }
622        DependencySource::AlternateRegistry {
623            index: NuGetSourceChain::chain(hops, false).key,
624            mirrors_crates_io: false,
625        }
626    }
627
628    /// S1 fix (impl-critic): a mapping key naming the literal, well-known `nuget.org` source
629    /// that resolves to **no** declared `<packageSources>` entry falls back to the real
630    /// public feed instead of contributing nothing — real NuGet's implicit machine-tier
631    /// `nuget.org` source is exactly this shape (declared in the machine/user-profile config
632    /// this feature deliberately does not read, so it is never a `PackageSourceEntry` here).
633    /// Without this, the near-universal real-world config shape — `<packageSources>` adding
634    /// only a private feed, plus a `<packageSourceMapping>` whose `*` pattern names
635    /// `nuget.org` — would fail every public package closed. This does not weaken R3: R3
636    /// forbids trusting the *key* of a source that **is** declared and points elsewhere;
637    /// here nothing is declared under that key at all, so there is no spoofable entry to
638    /// misidentify.
639    fn hops_for_mapping_keys(&self, keys: &[&str]) -> Vec<ResolvedHop> {
640        let mut hops = Vec::new();
641        for key in keys {
642            let resolved = resolve_mapping_source_key(key, &self.sources)
643                .and_then(|entry| {
644                    entry.value.as_ref().ok().map(|url| ResolvedHop {
645                        url: url.clone(),
646                        slot: entry.auth.is_some().then(|| entry.key.to_lowercase()),
647                        auth: entry.auth.clone(),
648                    })
649                })
650                .or_else(|| {
651                    key.eq_ignore_ascii_case("nuget.org").then(|| ResolvedHop {
652                        url: NuGetFeedUrl::trusted_public(),
653                        slot: None,
654                        auth: None,
655                    })
656                });
657            if let Some(hop) = resolved
658                && !hops
659                    .iter()
660                    .any(|h: &ResolvedHop| h.url.as_str() == hop.url.as_str())
661            {
662                hops.push(hop);
663            }
664        }
665        hops
666    }
667
668    /// FR-002–FR-004/FR-008/R4: zero *usable* hops means two different things depending on
669    /// `cleared`. Without a `<clear/>` anywhere in the chain, a source that was declared but
670    /// then dropped (invalid, disabled, credentialed, or simply never declared at all) leaves
671    /// the implicit `nuget.org` tail exactly as reachable as if nothing had been configured —
672    /// plain `Registry`, byte-identical to today (US-004/NFR-004). With a `<clear/>`
673    /// somewhere in the chain, the implicit tail is gone too, so zero usable hops is an
674    /// explicit fail-closed state (R4) — never a silent fall-through to `Registry`, whether
675    /// there is a nameable invalid entry or genuinely nothing left to name.
676    fn resolve_plain(&self) -> DependencySource {
677        let valid_hops = self.valid_hops();
678        if valid_hops.is_empty() {
679            if self.implicit_public_fallback() {
680                return DependencySource::Registry;
681            }
682            let raw = self
683                .sources
684                .iter()
685                .find_map(|s| s.value.as_ref().err().map(|e| e.raw.clone()))
686                .unwrap_or_else(|| NO_SOURCES_CONFIGURED_SENTINEL.to_string());
687            return DependencySource::CustomRegistry { url: raw };
688        }
689        // M2 (impl-critic): mirrors `resolve_via_mapping`'s identical check — an explicit
690        // `<clear/>` + `<add key="nuget.org" value="https://api.nuget.org/v3/index.json"/>`
691        // (Microsoft's own canonical source-pinning pattern) resolves to plain `Registry`,
692        // keeping OSV/deps.dev/hover-trust, rather than an `AlternateRegistry` chain whose
693        // only hop happens to be the same URL.
694        if valid_hops.len() == 1
695            && crate::registry::is_public_registry_url(valid_hops[0].url.as_str())
696        {
697            return DependencySource::Registry;
698        }
699        DependencySource::AlternateRegistry {
700            index: NuGetSourceChain::chain(valid_hops, self.implicit_public_fallback()).key,
701            mirrors_crates_io: false,
702        }
703    }
704
705    /// Whether the implicit `nuget.org` tail hop is still in effect — `false` once either an
706    /// ancestor's `<clear/>` (`cleared`) or an explicit `<remove key="nuget.org"/>`
707    /// (`nuget_org_removed`, S4) has taken it out; both are sticky across the whole ancestor
708    /// walk.
709    fn implicit_public_fallback(&self) -> bool {
710        !self.cleared && !self.nuget_org_removed
711    }
712
713    fn valid_hops(&self) -> Vec<ResolvedHop> {
714        self.sources
715            .iter()
716            .filter_map(|s| {
717                let url = s.value.as_ref().ok()?.clone();
718                Some(ResolvedHop {
719                    url,
720                    slot: s.auth.is_some().then(|| s.key.to_lowercase()),
721                    auth: s.auth.clone(),
722                })
723            })
724            .collect()
725    }
726
727    /// Every chain this config implies, ready for `NuGetRegistry::register_chain` — one chain
728    /// per distinct `<packageSourceMapping>` hop-set (when a mapping is declared), or the
729    /// single plain accumulated chain otherwise. Empty when nothing is registrable (US-004,
730    /// R4's fail-closed states, or a mapping group that resolves to only the public source).
731    #[must_use]
732    pub fn resolved_chains(&self) -> Vec<NuGetSourceChain> {
733        let mut chains = Vec::new();
734        let mut seen = HashSet::new();
735
736        if self.mapping.is_empty() {
737            let valid_hops = self.valid_hops();
738            let is_public_only = valid_hops.len() == 1
739                && crate::registry::is_public_registry_url(valid_hops[0].url.as_str());
740            if !valid_hops.is_empty() && !is_public_only {
741                chains.push(NuGetSourceChain::chain(
742                    valid_hops,
743                    self.implicit_public_fallback(),
744                ));
745            }
746        } else {
747            for (_, group_keys) in &self.mapping.patterns {
748                let keys: Vec<&str> = group_keys.iter().map(String::as_str).collect();
749                let hops = self.hops_for_mapping_keys(&keys);
750                if hops.is_empty()
751                    || (hops.len() == 1
752                        && crate::registry::is_public_registry_url(hops[0].url.as_str()))
753                {
754                    continue;
755                }
756                let chain = NuGetSourceChain::chain(hops, false);
757                if seen.insert(chain.key.clone()) {
758                    chains.push(chain);
759                }
760            }
761        }
762        chains
763    }
764}
765
766fn no_source(package: &PackageName) -> DependencySource {
767    DependencySource::CustomRegistry {
768        url: package.as_str().to_string(),
769    }
770}
771
772/// One `<add key="..." value="..." protocolVersion="...">` entry, unvalidated.
773#[derive(Debug, Default, Clone, Hash)]
774struct RawSourceAdd {
775    key: String,
776    value: String,
777    protocol_version: Option<String>,
778}
779
780/// One `<packageSourceCredentials>` child element's raw, pre-expansion credential values
781/// (issue #561, FR-002) — parsed unconditionally (parsing is tier-blind and memoized), but only
782/// ever read by [`resolve_with_context`]'s final pass when the owning file is [`ConfigTier::UserProfile`].
783#[derive(Debug, Default, Clone, Hash)]
784struct RawCredential {
785    /// The credential element's raw (undecoded) name — a source key, compared via
786    /// [`key_candidates_overlap`] like every other source key in this module.
787    key: String,
788    username: Option<RedactedSecret>,
789    /// `<ClearTextPassword>`.
790    password: Option<RedactedSecret>,
791    /// Whether a DPAPI-encrypted `<Password>` child was present (FR-003) — a distinct fail
792    /// reason from a missing/absent password, never itself held as a value.
793    encrypted: bool,
794}
795
796/// One `NuGet.Config` file's raw, unvalidated, un-cross-referenced contents.
797///
798/// Derives `Hash` (C1 fix, impl-critic follow-up on issue #576's logging) so
799/// `resolve_with_context`'s `config_fingerprint` can hash the parsed *content* of every
800/// ancestor file directly (`Arc<T>`'s `Hash` impl forwards to `T`, not the pointer) rather than
801/// each file's `Arc` pointer address — the latter is vulnerable to allocator address reuse:
802/// once an ancestor's cached `Arc` is dropped (replaced on a genuine mtime change in
803/// `MtimeFileCache`), a later, *differently-content* `Arc` can be allocated at the exact same
804/// address, silently colliding two distinct config states onto one fingerprint.
805#[derive(Debug, Default, Clone, Hash)]
806struct RawNuGetConfigFile {
807    sources_cleared: bool,
808    sources: Vec<RawSourceAdd>,
809    /// Raw keys named by `<packageSources><remove key="..."/></packageSources>` (S4,
810    /// impl-critic) — applied after this file's own `<add>`s during accumulation, per-file,
811    /// the same "clear/add/remove processed as one file-level batch, not in strict document
812    /// order" approximation `sources_cleared` already makes for `<clear/>` (real configs
813    /// overwhelmingly put `<clear/>` first and `<remove>` after any local `<add>`, so this
814    /// matches the common case exactly).
815    removed: Vec<String>,
816    /// `(key, value)` from `<disabledPackageSources><add key=".." value=".."/></...>`.
817    disabled: Vec<(String, String)>,
818    /// Child element names under `<packageSourceCredentials>` — never their contents.
819    credentialed_keys: Vec<String>,
820    /// `(packageSource key, patterns)` from `<packageSourceMapping>`.
821    mapping: Vec<(String, Vec<String>)>,
822    /// Per-credential-element raw `Username`/`ClearTextPassword` literals (issue #561, FR-002)
823    /// — one entry per element under `<packageSourceCredentials>` that had a `Start` (not
824    /// self-closing) tag, in document order.
825    credentials: Vec<RawCredential>,
826}
827
828#[derive(Debug, Clone, Copy, PartialEq, Eq)]
829enum ConfigSection {
830    Sources,
831    Disabled,
832    Credentials,
833    Mapping,
834}
835
836/// Parses one `NuGet.Config` file's content into its raw sections. Never fails: a malformed
837/// XML document degrades to an all-default (empty) [`RawNuGetConfigFile`] rather than
838/// propagating a parse error — a syntactically broken config must not crash the LSP or block
839/// every other manifest's resolution.
840fn parse_nuget_config_raw(content: &str) -> RawNuGetConfigFile {
841    let mut out = RawNuGetConfigFile::default();
842    let mut reader = Reader::from_str(content);
843    reader.config_mut().trim_text(true);
844
845    let mut section: Option<ConfigSection> = None;
846    let mut credential_source: Option<String> = None;
847    let mut mapping_source_key: Option<String> = None;
848
849    loop {
850        // H1 fix (security review): a malformed document must degrade to the all-default
851        // (empty) `RawNuGetConfigFile` this function's own doc already promises — never the
852        // partially-accumulated value up to the parse error. `<packageSourceMapping>`,
853        // `<packageSourceCredentials>`, and `<disabledPackageSources>` conventionally follow
854        // `<packageSources>` in the file, so returning a partial result would silently drop
855        // every restriction declared after the malformed point.
856        let event = match reader.read_event() {
857            Ok(event) => event,
858            Err(error) => {
859                tracing::warn!(
860                    %error,
861                    "malformed NuGet.Config XML; ignoring this file's declarations entirely"
862                );
863                return RawNuGetConfigFile::default();
864            }
865        };
866        match event {
867            Event::Start(ref e) | Event::Empty(ref e) => {
868                let is_start = matches!(event, Event::Start(_));
869                let local: String = e.local_name().as_ref().to_string();
870
871                if section.is_none() {
872                    // S2 fix (impl-critic): a self-closing section element (`<packageSources
873                    // />`) emits no matching `Event::End`, so it must never latch `section` —
874                    // doing so would swallow every later element in the document (including
875                    // an unrelated `<packageSources><clear/>...`) into this section, silently
876                    // neutralizing the `<clear/>` protection. Only `Event::Start` opens a
877                    // section; an empty section has no children to process either way.
878                    if is_start {
879                        section = match local.as_str() {
880                            "packageSources" => Some(ConfigSection::Sources),
881                            "disabledPackageSources" => Some(ConfigSection::Disabled),
882                            "packageSourceCredentials" => Some(ConfigSection::Credentials),
883                            "packageSourceMapping" => Some(ConfigSection::Mapping),
884                            _ => None,
885                        };
886                    }
887                    continue;
888                }
889
890                match (section, local.as_str()) {
891                    (Some(ConfigSection::Sources), "clear") => out.sources_cleared = true,
892                    (Some(ConfigSection::Sources), "remove") => {
893                        for attr in e.attributes().flatten() {
894                            if attr.key.local_name().as_ref() == "key" {
895                                out.removed.push(decode_attr(&attr.value));
896                            }
897                        }
898                    }
899                    (Some(ConfigSection::Sources), "add") => {
900                        let mut add = RawSourceAdd::default();
901                        for attr in e.attributes().flatten() {
902                            match attr.key.local_name().as_ref() {
903                                "key" => add.key = decode_attr(&attr.value),
904                                "value" => add.value = decode_attr(&attr.value),
905                                "protocolVersion" => {
906                                    add.protocol_version = Some(decode_attr(&attr.value));
907                                }
908                                _ => {}
909                            }
910                        }
911                        if !add.key.is_empty() {
912                            out.sources.push(add);
913                        }
914                    }
915                    (Some(ConfigSection::Disabled), "add") => {
916                        let mut key = String::new();
917                        let mut value = String::new();
918                        for attr in e.attributes().flatten() {
919                            match attr.key.local_name().as_ref() {
920                                "key" => key = decode_attr(&attr.value),
921                                "value" => value = decode_attr(&attr.value),
922                                _ => {}
923                            }
924                        }
925                        if !key.is_empty() {
926                            out.disabled.push((key, value));
927                        }
928                    }
929                    (Some(ConfigSection::Credentials), _) if credential_source.is_none() => {
930                        out.credentialed_keys.push(local.clone());
931                        if is_start {
932                            out.credentials.push(RawCredential {
933                                key: local.clone(),
934                                ..Default::default()
935                            });
936                            credential_source = Some(local);
937                        }
938                    }
939                    // Issue #561, FR-002/FR-003: `Username`/`ClearTextPassword`/`Password`
940                    // children of an already-open credential element. Values are held
941                    // pre-expansion (`RedactedSecret`) — `%ENV_VAR%` expansion happens only in
942                    // `resolve`, never here (S5: the memoized raw-file parse must never hold an
943                    // expanded secret, so env-var rotation is visible without an mtime change).
944                    (Some(ConfigSection::Credentials), "add") if credential_source.is_some() => {
945                        let mut attr_key = String::new();
946                        let mut attr_value = String::new();
947                        for attr in e.attributes().flatten() {
948                            match attr.key.local_name().as_ref() {
949                                "key" => attr_key = decode_attr(&attr.value),
950                                "value" => attr_value = decode_attr(&attr.value),
951                                _ => {}
952                            }
953                        }
954                        if let Some(cred) = out.credentials.last_mut() {
955                            match attr_key.as_str() {
956                                "Username" => {
957                                    cred.username = Some(RedactedSecret::new(attr_value));
958                                }
959                                "ClearTextPassword" => {
960                                    cred.password = Some(RedactedSecret::new(attr_value));
961                                }
962                                "Password" => cred.encrypted = true,
963                                _ => {}
964                            }
965                        }
966                    }
967                    (Some(ConfigSection::Mapping), "packageSource") => {
968                        let mut key = String::new();
969                        for attr in e.attributes().flatten() {
970                            if attr.key.local_name().as_ref() == "key" {
971                                key = decode_attr(&attr.value);
972                            }
973                        }
974                        if !key.is_empty() {
975                            if is_start {
976                                mapping_source_key = Some(key.clone());
977                            }
978                            out.mapping.push((key, Vec::new()));
979                        }
980                    }
981                    (Some(ConfigSection::Mapping), "package") if mapping_source_key.is_some() => {
982                        for attr in e.attributes().flatten() {
983                            if attr.key.local_name().as_ref() == "pattern"
984                                && let Some(last) = out.mapping.last_mut()
985                            {
986                                last.1.push(decode_attr(&attr.value));
987                            }
988                        }
989                    }
990                    _ => {}
991                }
992            }
993            Event::End(ref e) => {
994                let local: String = e.local_name().as_ref().to_string();
995                match section {
996                    Some(ConfigSection::Sources) if local == "packageSources" => section = None,
997                    Some(ConfigSection::Disabled) if local == "disabledPackageSources" => {
998                        section = None;
999                    }
1000                    Some(ConfigSection::Credentials) if local == "packageSourceCredentials" => {
1001                        section = None;
1002                    }
1003                    Some(ConfigSection::Mapping) if local == "packageSourceMapping" => {
1004                        section = None;
1005                    }
1006                    Some(ConfigSection::Mapping) if local == "packageSource" => {
1007                        mapping_source_key = None;
1008                    }
1009                    Some(ConfigSection::Credentials)
1010                        if credential_source.as_deref() == Some(local.as_str()) =>
1011                    {
1012                        credential_source = None;
1013                    }
1014                    _ => {}
1015                }
1016            }
1017            Event::Eof => break,
1018            _ => {}
1019        }
1020    }
1021
1022    out
1023}
1024
1025fn decode_attr(raw: &str) -> String {
1026    quick_xml::escape::unescape(raw)
1027        .map(|c| c.into_owned())
1028        .unwrap_or_else(|_| raw.to_string())
1029}
1030
1031/// Validates one raw `<add>` entry, logging at `debug!` (an unsupported-but-legitimate shape:
1032/// V2 protocol, a local/UNC path) or `warn!` (a genuinely malformed/blocked value).
1033fn resolve_source_entry(add: &RawSourceAdd, policy: &RegistryAccessPolicy) -> InvalidOrValid {
1034    if add.protocol_version.as_deref() == Some("2") {
1035        tracing::debug!(
1036            key = %add.key,
1037            "skipping NuGet V2 (protocolVersion=\"2\") package source; only V3 feeds are supported"
1038        );
1039        return Err(InvalidEntry {
1040            raw: redact_userinfo(&add.value),
1041            reason: NuGetFeedUrlError::UnsupportedProtocolVersion("2".to_string()),
1042        });
1043    }
1044    if !add.value.contains("://") {
1045        tracing::debug!(
1046            key = %add.key,
1047            value = %add.value,
1048            "skipping local/UNC NuGet package source; only V3 http(s) feeds are supported"
1049        );
1050        return Err(InvalidEntry {
1051            raw: redact_userinfo(&add.value),
1052            reason: NuGetFeedUrlError::LocalFeedUnsupported,
1053        });
1054    }
1055    NuGetFeedUrl::new(&add.value, policy).map_err(|reason| {
1056        let redacted = redact_userinfo(&add.value);
1057        tracing::warn!(key = %add.key, raw = %redacted, %reason, "NuGet package source failed validation");
1058        InvalidEntry {
1059            raw: redacted,
1060            reason,
1061        }
1062    })
1063}
1064
1065type InvalidOrValid = Result<NuGetFeedUrl, InvalidEntry>;
1066
1067fn upsert_source(
1068    sources: &mut Vec<PackageSourceEntry>,
1069    add: &RawSourceAdd,
1070    policy: &RegistryAccessPolicy,
1071    tier: ConfigTier,
1072) {
1073    let value = resolve_source_entry(add, policy);
1074    // LOW (security review): use the same `key_candidates_overlap` union funnel as every
1075    // other key comparison in this module (documented invariant at this module's
1076    // `PackageSourceEntry` doc) — raw-lowercase-only comparison here would let
1077    // `key="Corp Feed"` in one ancestor file and `key="Corp_x0020_Feed"` in another produce
1078    // two entries instead of correctly upserting one.
1079    if let Some(existing) = sources
1080        .iter_mut()
1081        .find(|s| key_candidates_overlap(&s.key, &add.key))
1082    {
1083        // S1 (issue #561): whole-struct assignment — a future field addition is a compile
1084        // error to miss, not a silent gap. `key` is deliberately NOT rewritten (not
1085        // security-load-bearing; rewriting would perturb shipped `<packageSourceMapping>`
1086        // resolution). `auth` stays `None` here unconditionally — only `resolve`'s final C2
1087        // pass ever sets it, after every file's accumulation has already run.
1088        *existing = PackageSourceEntry {
1089            key: existing.key.clone(),
1090            value,
1091            tier,
1092            auth: None,
1093        };
1094    } else {
1095        sources.push(PackageSourceEntry {
1096            key: add.key.clone(),
1097            value,
1098            tier,
1099            auth: None,
1100        });
1101    }
1102}
1103
1104/// Per-`NuGet.Config`-file-path memoization.
1105///
1106/// Mirrors `deps_npm::config::NpmConfigCache` exactly in shape. Caches **raw, unvalidated**
1107/// entries — URL validation and policy gating re-run per parse against these cached entries,
1108/// so a `didChangeConfiguration` policy change takes effect immediately with no cache
1109/// invalidation of its own.
1110/// Caps [`NuGetConfigCache::warned`] — unlike `files` (evicted per-path by
1111/// [`deps_core::MtimeFileCache`]'s own capacity), nothing here ever removes an individual
1112/// entry, since a `(config state, source, reason)` triple isn't tied to a single path that
1113/// could later get its own eviction hook. Reusing `DEFAULT_MAX_CACHED_FILES` as the bound keeps
1114/// the two caches' working-set sizes comparable without inventing a second tuning knob.
1115const WARNED_CAPACITY: usize = deps_core::DEFAULT_MAX_CACHED_FILES;
1116
1117#[derive(Debug)]
1118pub struct NuGetConfigCache {
1119    files: deps_core::MtimeFileCache<RawNuGetConfigFile>,
1120    /// Dedups the fail-closed credential-binding warning (impl-critic S2 follow-up, issue
1121    /// #576) to once per distinct config state. Keyed on a hash combining
1122    /// `resolve_with_context`'s content-derived `config_fingerprint` (see
1123    /// [`RawNuGetConfigFile`]'s doc) with the source key and
1124    /// [`FailClosedCause`]/[`NuGetFeedUrlError`] discriminants — see `fail_closed`'s doc.
1125    ///
1126    /// Capped at [`WARNED_CAPACITY`] (impl-critic C1 follow-up): this set has no natural upper
1127    /// bound the way `files` does (one entry per path), since it grows one entry per distinct
1128    /// `(config state, source, reason)` triple ever observed, which for a long-lived server
1129    /// process is unbounded in principle. On overflow the whole set is cleared rather than
1130    /// LRU-evicted — simpler, and the only visible cost is a handful of warnings re-firing once
1131    /// after the clear, an acceptable tradeoff for a diagnostic-only signal.
1132    ///
1133    /// Deliberate, documented consequence of "once per distinct state" (impl-critic M2 note):
1134    /// reverting a config from X to Y and back to X does not re-warn on the revert to X, since
1135    /// that exact state was already seen and its hash is still in this set — only ever seeing
1136    /// a *new* state re-warns, not returning to an old one.
1137    warned: dashmap::DashSet<u64>,
1138}
1139
1140impl Default for NuGetConfigCache {
1141    fn default() -> Self {
1142        Self::new()
1143    }
1144}
1145
1146impl NuGetConfigCache {
1147    /// Creates an empty cache.
1148    #[must_use]
1149    pub fn new() -> Self {
1150        Self {
1151            files: deps_core::MtimeFileCache::new(
1152                deps_core::DEFAULT_MAX_CACHED_FILES,
1153                "nuget config",
1154            ),
1155            warned: dashmap::DashSet::new(),
1156        }
1157    }
1158
1159    fn get_or_parse(&self, path: &Path) -> Option<Arc<RawNuGetConfigFile>> {
1160        self.files.get_or_parse(path, parse_nuget_config_raw)
1161    }
1162
1163    /// Returns `true` the first time `key` is seen, `false` on every repeat — see
1164    /// [`Self::warned`]'s doc for the capacity/eviction policy.
1165    fn should_warn_once(&self, key: u64) -> bool {
1166        if self.warned.len() >= WARNED_CAPACITY && !self.warned.contains(&key) {
1167            self.warned.clear();
1168        }
1169        self.warned.insert(key)
1170    }
1171}
1172
1173/// Owned by `NuGetEcosystem`, shared across every document it parses.
1174#[derive(Debug, Clone, Default)]
1175pub struct NuGetParseContext {
1176    /// Gates every workspace-declared [`NuGetFeedUrl`] this parse constructs.
1177    pub policy: Arc<RegistryAccessPolicy>,
1178    /// Memoizes each distinct `NuGet.Config` file's raw, unvalidated contents.
1179    pub config_cache: Arc<NuGetConfigCache>,
1180    /// The resolved user-profile-tier `NuGet.Config` path (issue #561, FR-001), resolved once
1181    /// at construction — never re-walked per parse (a profile created after server start is
1182    /// picked up only on restart, a documented limitation, not a bug). `None` when no
1183    /// candidate exists; [`NuGetParseContext::default`] leaves this `None`, so tests that
1184    /// don't care about the user-profile tier are unaffected.
1185    pub user_profile_config: Option<PathBuf>,
1186    /// Live-updatable `registries.nuget_user_profile_sources` setting (FR-006) — gates only the
1187    /// *routing* half of a user-profile file's contribution (see [`resolve_with_context`]'s doc); the
1188    /// credential half always applies regardless of this flag.
1189    pub user_profile_sources: Arc<AtomicBool>,
1190}
1191
1192impl NuGetParseContext {
1193    /// Production constructor: discovers the user-profile-tier config path once (FR-001) and
1194    /// wires it alongside `policy`/`config_cache`/`user_profile_sources`.
1195    #[must_use]
1196    pub fn new(
1197        policy: Arc<RegistryAccessPolicy>,
1198        config_cache: Arc<NuGetConfigCache>,
1199        user_profile_sources: Arc<AtomicBool>,
1200    ) -> Self {
1201        Self {
1202            policy,
1203            config_cache,
1204            user_profile_config: discover_user_profile_config(),
1205            user_profile_sources,
1206        }
1207    }
1208}
1209
1210/// FR-001: the first-existing user-profile-tier `NuGet.Config` candidate, in order — Windows
1211/// `%APPDATA%\NuGet\NuGet.Config`; Unix `$XDG_CONFIG_HOME/NuGet/NuGet.Config` (if set) ->
1212/// `~/.config/NuGet/NuGet.Config` -> `~/.nuget/NuGet/NuGet.Config`. Exactly one file, never
1213/// merged — mirrors [`CONFIG_FILENAMES`]'s existing first-match idiom.
1214fn user_profile_config_candidates(home: Option<&Path>) -> Vec<PathBuf> {
1215    let mut candidates = Vec::new();
1216    if cfg!(windows) {
1217        if let Ok(appdata) = std::env::var("APPDATA") {
1218            candidates.push(PathBuf::from(appdata).join("NuGet").join("NuGet.Config"));
1219        }
1220    } else {
1221        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
1222            && !xdg.is_empty()
1223        {
1224            candidates.push(PathBuf::from(xdg).join("NuGet").join("NuGet.Config"));
1225        }
1226        if let Some(home) = home {
1227            candidates.push(home.join(".config").join("NuGet").join("NuGet.Config"));
1228            candidates.push(home.join(".nuget").join("NuGet").join("NuGet.Config"));
1229        }
1230    }
1231    candidates
1232}
1233
1234/// [`discover_user_profile_config`], but taking `home` explicitly instead of [`dirs::home_dir`]
1235/// — lets tests inject a fixture home directory, mirroring `deps_npm::config::resolve_with_home`.
1236fn discover_user_profile_config_with_home(home: Option<PathBuf>) -> Option<PathBuf> {
1237    user_profile_config_candidates(home.as_deref())
1238        .into_iter()
1239        .find(|p| p.is_file())
1240}
1241
1242/// Resolves the user-profile-tier `NuGet.Config` path once (FR-001) — call at
1243/// [`NuGetParseContext`] construction, never per manifest parse.
1244#[must_use]
1245fn discover_user_profile_config() -> Option<PathBuf> {
1246    discover_user_profile_config_with_home(dirs::home_dir())
1247}
1248
1249/// Test-only convenience: [`NuGetEcosystem::parse_manifest`] calls [`resolve_with_context`]
1250/// directly, so this 3-arg form (no user-profile tier, flag off) has no production caller —
1251/// it exists solely to keep the 28 tests that don't exercise the user-profile path from
1252/// repeating its two extra always-`None`/`false` arguments. See [`resolve_with_context`] for
1253/// the full resolution algorithm this delegates to.
1254#[cfg(test)]
1255#[must_use]
1256pub(crate) fn resolve(
1257    manifest_dir: &Path,
1258    config_cache: &NuGetConfigCache,
1259    policy: &RegistryAccessPolicy,
1260) -> NuGetConfig {
1261    resolve_with_context(
1262        manifest_dir,
1263        config_cache,
1264        policy,
1265        None,
1266        &AtomicBool::new(false),
1267    )
1268}
1269
1270/// Resolves `manifest_dir`'s in-repo `NuGet.Config` ancestor chain, plus the user-profile tier
1271/// (issue #561), into a merged [`NuGetConfig`] (FR-001/FR-002, C1's root-to-leaf accumulation).
1272///
1273/// The form `NuGetEcosystem::parse_manifest` calls, threading [`NuGetParseContext`]'s
1274/// `user_profile_config`/`user_profile_sources` fields through.
1275///
1276/// # Credential half vs. routing half (§3.8, FR-005/FR-006)
1277///
1278/// A [`ConfigTier::UserProfile`] file's contribution splits into two halves:
1279///
1280/// - **Credential half — always applied, regardless of `user_profile_sources`**:
1281///   `credentialed_keys`, the §3.4 credential-suppression set (from its own
1282///   `<disabledPackageSources>`), its raw `<packageSourceCredentials>` values, and
1283///   `user_profile_add` (its own `<clear/>`/`<add>`/`<remove>` batch, tracked separately from
1284///   the shared `sources` routing state).
1285/// - **Routing half — skipped entirely when `user_profile_sources` is false**: `sources`,
1286///   `sources_cleared`, `removed`/`nuget_org_removed`, `disabled`, `mapping` — all six,
1287///   together. With the flag off, a user-profile file's `<clear/>`/`<remove>`/
1288///   `<disabledPackageSources>`/`<packageSourceMapping>` reach no project at all (NFR-005).
1289///
1290/// A repo-tier file's contribution is unaffected by `user_profile_sources` and always applies
1291/// in full — byte-identical to spec 035.
1292///
1293/// # Credential binding (§3.2, FR-007)
1294///
1295/// The final pass, in `bind_credentials_and_finalize`, binds a user-profile credential to a
1296/// resolved entry `E` iff all of:
1297/// (0) `E.key` does not overlap the credential-suppression set (union, exclusion); (1) exactly
1298/// one user-profile credential's key-candidates overlap `E.key`; (2) exactly one
1299/// `user_profile_add` entry's key-candidates overlap that credential's own key; (3) `E`'s URL
1300/// equals `user_profile_add`'s URL, by normalized full-URL string equality (not origin
1301/// equality — see §3.2's rationale). Any credential-key match on `E` failing any condition
1302/// fails `E` closed as `HasCredentials`, except the FR-008 public-index carve-out. Repo-tier
1303/// `<packageSourceCredentials>` (FR-004) is checked first and wins unconditionally,
1304/// independent of any C2 outcome.
1305#[must_use]
1306pub fn resolve_with_context(
1307    manifest_dir: &Path,
1308    config_cache: &NuGetConfigCache,
1309    policy: &RegistryAccessPolicy,
1310    user_profile_config: Option<&Path>,
1311    user_profile_sources: &AtomicBool,
1312) -> NuGetConfig {
1313    let ancestors = collect_config_ancestors(manifest_dir, config_cache, user_profile_config);
1314
1315    let user_profile_sources_enabled =
1316        user_profile_sources.load(std::sync::atomic::Ordering::Relaxed);
1317
1318    // S2 fix (impl-critic, issue #576 follow-up): identity of the exact config state this
1319    // resolve is built from — see `config_ancestors_fingerprint`'s doc. Computed before the
1320    // tier-accumulation walk so `bind_credentials_and_finalize` can debounce its fail-closed
1321    // warnings against it below.
1322    let config_fingerprint = config_ancestors_fingerprint(&ancestors);
1323
1324    let accumulated = accumulate_config_tiers(&ancestors, policy, user_profile_sources_enabled);
1325
1326    bind_credentials_and_finalize(accumulated, config_cache, config_fingerprint)
1327}
1328
1329/// Walks `manifest_dir`'s ancestor directories collecting each `NuGet.Config` found (FR-001),
1330/// then resolves the user-profile-tier file, if any, dropping it when it is the same file
1331/// (by canonicalized path) as one already found in the repo walk — a user-profile candidate
1332/// reachable at both tiers is treated as `Repo` (lower trust wins) rather than loaded a
1333/// second time under the higher-trust tier. A `canonicalize` failure on the user-profile
1334/// candidate itself drops it entirely (fail closed).
1335///
1336/// Returns the merged chain in leaf-to-root discovery order with the user-profile file
1337/// appended last, so reversing it (as [`accumulate_config_tiers`] does) processes the
1338/// user-profile tier first and lets any repo-tier file override it (§3.8).
1339fn collect_config_ancestors(
1340    manifest_dir: &Path,
1341    config_cache: &NuGetConfigCache,
1342    user_profile_config: Option<&Path>,
1343) -> Vec<(ConfigTier, Arc<RawNuGetConfigFile>)> {
1344    let mut repo_ancestors: Vec<Arc<RawNuGetConfigFile>> = Vec::new();
1345    let mut repo_paths: Vec<PathBuf> = Vec::new();
1346    let mut current: Option<&Path> = Some(manifest_dir);
1347    let mut depth = 0usize;
1348    while let Some(dir) = current {
1349        if depth >= MAX_CONFIG_ANCESTOR_DEPTH {
1350            break;
1351        }
1352        depth += 1;
1353
1354        for name in CONFIG_FILENAMES {
1355            let candidate: PathBuf = dir.join(name);
1356            if candidate.is_file() {
1357                if let Some(parsed) = config_cache.get_or_parse(&candidate) {
1358                    repo_ancestors.push(parsed);
1359                    repo_paths.push(candidate);
1360                }
1361                break;
1362            }
1363        }
1364
1365        current = dir.parent();
1366    }
1367
1368    let user_profile_file: Option<Arc<RawNuGetConfigFile>> = user_profile_config.and_then(|upc| {
1369        let canon = std::fs::canonicalize(upc).ok()?;
1370        let is_repo_dup = repo_paths
1371            .iter()
1372            .any(|p| std::fs::canonicalize(p).ok().as_deref() == Some(canon.as_path()));
1373        if is_repo_dup {
1374            return None;
1375        }
1376        config_cache.get_or_parse(&canon)
1377    });
1378
1379    let mut ancestors: Vec<(ConfigTier, Arc<RawNuGetConfigFile>)> = repo_ancestors
1380        .into_iter()
1381        .map(|f| (ConfigTier::Repo, f))
1382        .collect();
1383    if let Some(user_file) = user_profile_file {
1384        ancestors.push((ConfigTier::UserProfile, user_file));
1385    }
1386    ancestors
1387}
1388
1389/// Content-derived identity of `ancestors`' exact config state (S2 fix, impl-critic, issue
1390/// #576 follow-up), derived from *content* rather than `Arc` pointer address (C1 fix,
1391/// impl-critic follow-up — pointer identity collides once an old `Arc` is dropped and a
1392/// later, differently-content `Arc` is allocated at the same freed address; see
1393/// `RawNuGetConfigFile`'s doc). `Arc<T>`'s own `Hash` impl already forwards to `T`'s `Hash`
1394/// rather than hashing the pointer, so this is stable across repeat calls that hit
1395/// `config_cache` for every ancestor (same content, same hash) and changes the instant any
1396/// ancestor's parsed content actually differs. Lets [`fail_closed`] debounce its warning to
1397/// once per distinct config state instead of once per resolve call (this pass isn't itself
1398/// cached, unlike the per-file raw parse).
1399fn config_ancestors_fingerprint(ancestors: &[(ConfigTier, Arc<RawNuGetConfigFile>)]) -> u64 {
1400    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1401    for (tier, file) in ancestors {
1402        tier.hash(&mut hasher);
1403        file.hash(&mut hasher);
1404    }
1405    hasher.finish()
1406}
1407
1408/// State [`accumulate_config_tiers`] builds while walking the merged ancestor chain root-to-
1409/// leaf (C1), threaded into [`bind_credentials_and_finalize`] for the credential-binding pass
1410/// and the final [`NuGetConfig`] assembly. Kept as one struct rather than a long parameter
1411/// list since every field is produced together by the same walk and consumed together by the
1412/// same pass.
1413struct AccumulatedConfig {
1414    sources: Vec<PackageSourceEntry>,
1415    cleared: bool,
1416    nuget_org_removed: bool,
1417    disabled_raw: Vec<(String, String)>,
1418    repo_credentialed_raw: Vec<String>,
1419    user_credentialed_raw: Vec<String>,
1420    mapping: PackageSourceMapping,
1421    /// Credential-half accumulators (§3.8) — populated identically regardless of
1422    /// `user_profile_sources`.
1423    user_credentials: Vec<RawCredential>,
1424    user_profile_add: Vec<PackageSourceEntry>,
1425    user_profile_credential_suppressed: HashSet<String>,
1426}
1427
1428/// C1: applies each ancestor's contribution root -> leaf (reverse of `ancestors`' leaf-to-root
1429/// discovery order), accumulating package sources, `<clear/>`/`<remove>` state, disabled/
1430/// credentialed key sets, and `<packageSourceMapping>` into one [`AccumulatedConfig`].
1431///
1432/// A [`ConfigTier::UserProfile`] file's contribution splits into two halves (§3.8, FR-005/
1433/// FR-006): its credential half (`credentialed_keys`, the §3.4 suppression set, raw
1434/// `<packageSourceCredentials>` values, and its own `<clear/>`/`<add>`/`<remove>` batch
1435/// tracked separately as `user_profile_add`) always applies; its routing half (`sources`,
1436/// `sources_cleared`, `removed`/`nuget_org_removed`, `disabled`, `mapping`) is skipped
1437/// entirely when `user_profile_sources_enabled` is false (NFR-005). A repo-tier file's
1438/// contribution is unaffected by the flag and always applies in full. `cleared` is sticky for
1439/// the rest of the walk once set — see this module's doc.
1440fn accumulate_config_tiers(
1441    ancestors: &[(ConfigTier, Arc<RawNuGetConfigFile>)],
1442    policy: &RegistryAccessPolicy,
1443    user_profile_sources_enabled: bool,
1444) -> AccumulatedConfig {
1445    let mut sources: Vec<PackageSourceEntry> = Vec::new();
1446    let mut cleared = false;
1447    let mut nuget_org_removed = false;
1448    let mut disabled_raw: Vec<(String, String)> = Vec::new();
1449    let mut repo_credentialed_raw: Vec<String> = Vec::new();
1450    let mut user_credentialed_raw: Vec<String> = Vec::new();
1451    let mut mapping = PackageSourceMapping::default();
1452
1453    // Credential-half accumulators (§3.8) — populated identically regardless of the flag.
1454    let mut user_credentials: Vec<RawCredential> = Vec::new();
1455    let mut user_profile_add: Vec<PackageSourceEntry> = Vec::new();
1456    let mut user_profile_credential_suppressed: HashSet<String> = HashSet::new();
1457
1458    for (tier, file) in ancestors.iter().rev() {
1459        let tier = *tier;
1460
1461        if tier == ConfigTier::UserProfile {
1462            // Credential half — always runs, regardless of `user_profile_sources` (§3.8).
1463            user_credentialed_raw.extend(file.credentialed_keys.iter().cloned());
1464            user_credentials.extend(file.credentials.iter().cloned());
1465            for (key, value) in &file.disabled {
1466                if value.eq_ignore_ascii_case("true") {
1467                    user_profile_credential_suppressed.extend(key_candidates(key));
1468                }
1469            }
1470            if file.sources_cleared {
1471                user_profile_add.clear();
1472            }
1473            for add in &file.sources {
1474                upsert_source(&mut user_profile_add, add, policy, ConfigTier::UserProfile);
1475            }
1476            for key in &file.removed {
1477                user_profile_add.retain(|e| !key_candidates_overlap(&e.key, key));
1478            }
1479
1480            if !user_profile_sources_enabled {
1481                // Routing half skipped entirely for this file (FR-006).
1482                continue;
1483            }
1484        } else {
1485            repo_credentialed_raw.extend(file.credentialed_keys.iter().cloned());
1486        }
1487
1488        // Routing half: repo tier always; user-profile tier only when the flag is on. Note
1489        // `file.disabled` is deliberately NOT added to `disabled_raw` for a user-profile-tier
1490        // file even here — §3.4 keeps user-profile `<disabledPackageSources>` out of the
1491        // machine-wide set unconditionally; it only ever feeds the suppression set above.
1492        if file.sources_cleared {
1493            sources.clear();
1494            cleared = true;
1495        }
1496        for add in &file.sources {
1497            upsert_source(&mut sources, add, policy, tier);
1498        }
1499        // S4 fix (impl-critic): `<remove key="..."/>` removes a source accumulated so far
1500        // (this file's own `<add>`s or an inherited ancestor entry) — without this, an
1501        // explicitly removed `nuget.org`/private source stayed reachable, the same #248-class
1502        // silent-inclusion bug this feature exists to close in the opposite direction.
1503        for key in &file.removed {
1504            sources.retain(|s| !key_candidates_overlap(&s.key, key));
1505            if key.eq_ignore_ascii_case("nuget.org") {
1506                nuget_org_removed = true;
1507            }
1508        }
1509        if tier == ConfigTier::Repo {
1510            disabled_raw.extend(file.disabled.iter().cloned());
1511        }
1512        for (source_key, patterns) in &file.mapping {
1513            mapping.extend(source_key, patterns);
1514        }
1515    }
1516
1517    AccumulatedConfig {
1518        sources,
1519        cleared,
1520        nuget_org_removed,
1521        disabled_raw,
1522        repo_credentialed_raw,
1523        user_credentialed_raw,
1524        mapping,
1525        user_credentials,
1526        user_profile_add,
1527        user_profile_credential_suppressed,
1528    }
1529}
1530
1531/// Final pass (§3.2, FR-007): binds a user-profile credential to each resolved source entry
1532/// where all of conditions (0)-(3) hold (see [`resolve_with_context`]'s doc for the full
1533/// condition list), applying the FR-004 repo-tier-credentialed check and the FR-008 public-
1534/// index carve-out first, and fails an entry closed via [`fail_closed`] whenever a credential
1535/// match cannot be bound cleanly. Consumes `accumulated` and returns the finished
1536/// [`NuGetConfig`], since nothing else in [`resolve_with_context`] needs the intermediate
1537/// accumulator state after this point.
1538fn bind_credentials_and_finalize(
1539    mut accumulated: AccumulatedConfig,
1540    config_cache: &NuGetConfigCache,
1541    config_fingerprint: u64,
1542) -> NuGetConfig {
1543    let mut disabled_keys: HashSet<String> = HashSet::new();
1544    for (key, value) in &accumulated.disabled_raw {
1545        if value.eq_ignore_ascii_case("true") {
1546            disabled_keys.extend(key_candidates(key));
1547        }
1548    }
1549    let repo_credentialed_keys: HashSet<String> = accumulated
1550        .repo_credentialed_raw
1551        .iter()
1552        .flat_map(|k| key_candidates(k))
1553        .collect();
1554    let user_credentialed_keys: HashSet<String> = accumulated
1555        .user_credentialed_raw
1556        .iter()
1557        .flat_map(|k| key_candidates(k))
1558        .collect();
1559
1560    for entry in &mut accumulated.sources {
1561        let Ok(url) = entry.value.as_ref() else {
1562            continue;
1563        };
1564        let resolved_url = url.as_str().to_string();
1565        let candidates = key_candidates(&entry.key);
1566        let is_disabled = candidates.iter().any(|c| disabled_keys.contains(c));
1567        let is_repo_credentialed = candidates
1568            .iter()
1569            .any(|c| repo_credentialed_keys.contains(c));
1570        let is_user_credentialed = candidates
1571            .iter()
1572            .any(|c| user_credentialed_keys.contains(c));
1573        let is_public = crate::registry::is_public_registry_url(&resolved_url);
1574
1575        // FR-004: repo-tier `<packageSourceCredentials>` always wins, unconditionally —
1576        // matching spec 035 FR-009 verbatim, independent of any C2 outcome and of the FR-008
1577        // public-index carve-out (a repo declaring `nuget.org` under
1578        // `<packageSourceCredentials>` still fails closed, exactly as it did before this
1579        // feature).
1580        if is_repo_credentialed {
1581            fail_closed(
1582                entry,
1583                NuGetFeedUrlError::HasCredentials,
1584                FailClosedCause::RepoTierCredentialed,
1585                config_cache,
1586                config_fingerprint,
1587            );
1588            continue;
1589        }
1590        if is_disabled {
1591            fail_closed(
1592                entry,
1593                NuGetFeedUrlError::Disabled,
1594                FailClosedCause::MachineDisabled,
1595                config_cache,
1596                config_fingerprint,
1597            );
1598            continue;
1599        }
1600        // FR-008: the public-index carve-out — a user-profile-derived credentialed-key match
1601        // never forces `HasCredentials`, and never attaches, for the real public index.
1602        if is_public {
1603            continue;
1604        }
1605
1606        match bind_user_profile_credential(
1607            entry,
1608            &accumulated.user_credentials,
1609            &accumulated.user_profile_add,
1610            &accumulated.user_profile_credential_suppressed,
1611            &resolved_url,
1612        ) {
1613            Some(Ok(auth)) => entry.auth = Some(auth),
1614            Some(Err((reason, cause))) => {
1615                fail_closed(entry, reason, cause, config_cache, config_fingerprint);
1616            }
1617            None if is_user_credentialed => {
1618                fail_closed(
1619                    entry,
1620                    NuGetFeedUrlError::HasCredentials,
1621                    FailClosedCause::AmbiguousCredentialKeyMatch,
1622                    config_cache,
1623                    config_fingerprint,
1624                );
1625            }
1626            None => {}
1627        }
1628    }
1629
1630    NuGetConfig {
1631        sources: accumulated.sources,
1632        cleared: accumulated.cleared,
1633        nuget_org_removed: accumulated.nuget_org_removed,
1634        mapping: accumulated.mapping,
1635    }
1636}
1637
1638/// Why a source failed closed during the C2 credential-binding pass — logging-only detail,
1639/// deliberately separate from [`NuGetFeedUrlError`] (issue #576 S1 follow-up, impl-critic).
1640///
1641/// Several structurally different C2 sub-conditions all resolve to the same
1642/// [`NuGetFeedUrlError::HasCredentials`] reason (by design — it stays the single, hover/
1643/// diagnostic-safe, user-facing error), so logging `reason` alone makes issue #576's own repro
1644/// (a user-profile `<packageSourceCredentials>` entry with no matching same-file
1645/// `<packageSources><add>`, condition (2)) byte-identical in the log to an unrelated cause like
1646/// a plain repo-tier `<packageSourceCredentials>` declaration. This enum exists only to break
1647/// that tie in `fail_closed`'s log line.
1648#[derive(Debug, Clone, Copy, Hash)]
1649enum FailClosedCause {
1650    /// FR-004: the source itself is named under a repo-tier `<packageSourceCredentials>`.
1651    RepoTierCredentialed,
1652    /// FR-004: the source is named in `<disabledPackageSources>` with a `true` value —
1653    /// intentional, expected configuration, not a misconfiguration (see `fail_closed`'s level
1654    /// choice for this cause).
1655    MachineDisabled,
1656    /// C2 condition (0): a user-profile credential's key overlaps a key the user profile
1657    /// itself suppresses via `<disabledPackageSources>`.
1658    UserProfileSuppressed,
1659    /// C2 condition (2): the credential's own key does not resolve to exactly one
1660    /// `user_profile_add` entry (zero or ambiguous matches) — issue #576's own repro shape.
1661    NoMatchingUserProfileAdd,
1662    /// C2 condition (2): the one matched `user_profile_add` entry's own value failed URL
1663    /// validation, so there is no URL to compare under condition (3).
1664    UserProfileAddEntryInvalid,
1665    /// C2 condition (3): the matched `user_profile_add` entry's URL does not equal the
1666    /// resolved entry's URL (not origin equality — see `bind_user_profile_credential`'s doc).
1667    UserProfileUrlMismatch,
1668    /// Condition (1)'s `unique_overlap` found more than one user-profile credential whose key
1669    /// overlaps `entry.key` (M2 fix, impl-critic: this is only ever reached via the
1670    /// `is_user_credentialed` guard at this cause's one call site, which already establishes at
1671    /// least one raw-declared credential key overlaps `entry.key` — so a `None` here can only
1672    /// mean *ambiguous*, never *zero*, and this variant is named accordingly, not shared with a
1673    /// zero-match case).
1674    AmbiguousCredentialKeyMatch,
1675    /// The matched credential itself failed to expand (missing `ClearTextPassword`, an unset
1676    /// `%ENV_VAR%` reference, or a DPAPI-encrypted `<Password>` — the last of which
1677    /// `expand_credential` already logs itself; see `fail_closed`'s double-log guard).
1678    CredentialExpansionFailed,
1679}
1680
1681/// Overwrites `entry.value` with `Err(InvalidEntry { reason, .. })`, preserving whatever raw
1682/// text was already resolvable (the URL if valid, or the prior `InvalidEntry::raw` if not).
1683///
1684/// Issue #576: this is the C2 credential-binding pass's own fail-closed path — unlike
1685/// `resolve_source_entry`'s URL-validation failures (which already `tracing::warn!`), this path
1686/// previously dropped a source from resolution with zero log output at any level, making a
1687/// misconfigured `<packageSourceCredentials>` binding indistinguishable from "package simply has
1688/// no versions" in the logs.
1689///
1690/// Severity mirrors `resolve_source_entry`'s existing debug!/warn! split (impl-critic S3 follow-
1691/// up), not a blanket `warn!`: [`NuGetFeedUrlError::Disabled`] is an intentional, expected
1692/// config state (analogous to a `protocolVersion="2"` or local-feed source there), so it logs
1693/// at `debug!`; every other reason is a genuine, actionable misconfiguration and logs at
1694/// `warn!`. [`NuGetFeedUrlError::EncryptedPasswordUnsupported`] logs nothing here at all —
1695/// `expand_credential` already emits its own `debug!` for that case, and warning again here
1696/// would double-log the identical event.
1697///
1698/// Debounced via `config_cache`'s dedup set (impl-critic S2 follow-up): without this, the
1699/// warning would re-fire on every `resolve_with_context` call (e.g. every LSP `did_change`
1700/// re-parse) even when the underlying config chain hasn't changed at all, unlike every other
1701/// warning in this module (naturally debounced by `MtimeFileCache` only re-parsing, and thus
1702/// only re-logging, on a genuine mtime change).
1703fn fail_closed(
1704    entry: &mut PackageSourceEntry,
1705    reason: NuGetFeedUrlError,
1706    cause: FailClosedCause,
1707    config_cache: &NuGetConfigCache,
1708    config_fingerprint: u64,
1709) {
1710    let raw = match &entry.value {
1711        Ok(url) => url.as_str().to_string(),
1712        Err(invalid) => invalid.raw.clone(),
1713    };
1714
1715    if !matches!(reason, NuGetFeedUrlError::EncryptedPasswordUnsupported) {
1716        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1717        config_fingerprint.hash(&mut hasher);
1718        entry.key.hash(&mut hasher);
1719        cause.hash(&mut hasher);
1720        std::mem::discriminant(&reason).hash(&mut hasher);
1721        if config_cache.should_warn_once(hasher.finish()) {
1722            if matches!(reason, NuGetFeedUrlError::Disabled) {
1723                tracing::debug!(
1724                    key = %entry.key,
1725                    %reason,
1726                    ?cause,
1727                    "NuGet package source fails closed on credential binding"
1728                );
1729            } else {
1730                tracing::warn!(
1731                    key = %entry.key,
1732                    %reason,
1733                    ?cause,
1734                    "NuGet package source fails closed on credential binding"
1735                );
1736            }
1737        }
1738    }
1739
1740    entry.value = Err(InvalidEntry { raw, reason });
1741}
1742
1743/// §3.2/FR-007: attempts to bind a user-profile credential to `entry` (whose resolved URL is
1744/// `resolved_url`). Returns `None` when no user-profile credential key matches `entry.key` at
1745/// all (nothing to bind, not an error — the caller decides separately whether that's fine).
1746/// Returns `Some(Err((reason, cause)))` when a credential key matched but conditions (0)-(3)
1747/// failed, or the matched credential itself failed to expand (unset `%ENV_VAR%`,
1748/// DPAPI-encrypted) — `cause` is a logging-only detail (see [`FailClosedCause`]), never part of
1749/// the user-facing `reason`. Returns `Some(Ok(auth))` on a successful bind.
1750fn bind_user_profile_credential(
1751    entry: &PackageSourceEntry,
1752    user_credentials: &[RawCredential],
1753    user_profile_add: &[PackageSourceEntry],
1754    suppressed: &HashSet<String>,
1755    resolved_url: &str,
1756) -> Option<Result<NuGetAuth, (NuGetFeedUrlError, FailClosedCause)>> {
1757    // (0): suppression — union match, the fail-closed direction for an exclusion. Checked here
1758    // rather than short-circuiting on it alone, because (0) only matters once (1) below has
1759    // established that a credential actually exists to suppress — otherwise there is nothing to
1760    // bind and the correct return is `None`, not `Some(Err(..))`.
1761    let candidates = key_candidates(&entry.key);
1762    let suppressed_match = candidates.iter().any(|c| suppressed.contains(c));
1763
1764    // (1): exactly one user-profile credential's key-candidates overlap `entry.key`.
1765    let credential = unique_overlap(&entry.key, user_credentials, |c| c.key.as_str())?;
1766
1767    if suppressed_match {
1768        return Some(Err((
1769            NuGetFeedUrlError::HasCredentials,
1770            FailClosedCause::UserProfileSuppressed,
1771        )));
1772    }
1773
1774    // (2): exactly one `user_profile_add` entry's key-candidates overlap the credential's own
1775    // key.
1776    let Some(add_entry) = unique_overlap(&credential.key, user_profile_add, |e| e.key.as_str())
1777    else {
1778        return Some(Err((
1779            NuGetFeedUrlError::HasCredentials,
1780            FailClosedCause::NoMatchingUserProfileAdd,
1781        )));
1782    };
1783
1784    // (3): normalized full-URL equality — not origin equality (see §3.2's rationale).
1785    let Ok(add_url) = add_entry.value.as_ref() else {
1786        return Some(Err((
1787            NuGetFeedUrlError::HasCredentials,
1788            FailClosedCause::UserProfileAddEntryInvalid,
1789        )));
1790    };
1791    if add_url.as_str() != resolved_url {
1792        return Some(Err((
1793            NuGetFeedUrlError::HasCredentials,
1794            FailClosedCause::UserProfileUrlMismatch,
1795        )));
1796    }
1797
1798    Some(
1799        expand_credential(credential)
1800            .map_err(|reason| (reason, FailClosedCause::CredentialExpansionFailed)),
1801    )
1802}
1803
1804/// FR-002/FR-003: expands `%ENV_VAR%` references (post-cache, credential values only) and
1805/// formats the result into a [`NuGetAuth`]. A DPAPI-encrypted `<Password>` fails closed as
1806/// [`NuGetFeedUrlError::EncryptedPasswordUnsupported`]; a missing `ClearTextPassword`, or any
1807/// referenced environment variable being unset, fails closed as
1808/// [`NuGetFeedUrlError::HasCredentials`].
1809fn expand_credential(credential: &RawCredential) -> Result<NuGetAuth, NuGetFeedUrlError> {
1810    if credential.encrypted {
1811        tracing::debug!(
1812            key = %credential.key,
1813            "DPAPI-encrypted <Password> is not supported; dropping credential"
1814        );
1815        return Err(NuGetFeedUrlError::EncryptedPasswordUnsupported);
1816    }
1817    let Some(password) = &credential.password else {
1818        return Err(NuGetFeedUrlError::HasCredentials);
1819    };
1820    let username = credential
1821        .username
1822        .as_ref()
1823        .map(RedactedSecret::expose_secret)
1824        .unwrap_or("");
1825    let username = expand_env_vars(username)?;
1826    let password = expand_env_vars(password.expose_secret())?;
1827    Ok(NuGetAuth::new(&username, &password))
1828}
1829
1830/// Expands every `%NAME%` reference in `raw` against the process environment. Any referenced
1831/// variable being unset fails the *whole* expansion closed (FR-002) — never a partial
1832/// substitution. `%` sequences that don't form a well-formed `%NAME%` reference (empty name, a
1833/// non-alphanumeric/underscore character, or an unterminated `%`) are left as literal text.
1834///
1835/// Returns [`Zeroizing`], not a bare `String` — the caller (`expand_credential`) only ever
1836/// expands a secret (`RedactedSecret` username/password), never an ordinary value.
1837fn expand_env_vars(raw: &str) -> Result<Zeroizing<String>, NuGetFeedUrlError> {
1838    expand_env_vars_with(raw, |name| std::env::var(name).ok().map(Zeroizing::new))
1839}
1840
1841/// [`expand_env_vars`], but reading variables through `lookup` instead of [`std::env::var`]
1842/// directly — lets tests inject a fake environment instead of mutating the real process
1843/// environment (this workspace forbids `unsafe`, and Rust 2024 made `std::env::set_var` an
1844/// `unsafe fn`, so a test cannot do that mutation at all; mirrors
1845/// `deps_npm::config::expand_env_vars_with`'s identical rationale).
1846///
1847/// Scans `raw` (and each looked-up value) by `&str` slice, never collecting the secret into
1848/// an intermediate `Vec<char>` copy, and precomputes the exact output length from the
1849/// resolved segments before allocating — so the returned [`Zeroizing`] buffer is never grown
1850/// past its initial capacity. `zeroize`'s own docs note it cannot guarantee a `Vec`/`String`
1851/// reallocation didn't leave a stale copy on the heap; sizing exactly once, up front, is what
1852/// avoids that reallocation in the first place, rather than merely zeroizing after the fact.
1853fn expand_env_vars_with(
1854    raw: &str,
1855    lookup: impl Fn(&str) -> Option<Zeroizing<String>>,
1856) -> Result<Zeroizing<String>, NuGetFeedUrlError> {
1857    enum Segment<'a> {
1858        Literal(&'a str),
1859        Value(Zeroizing<String>),
1860    }
1861
1862    let mut segments = Vec::new();
1863    let mut rest = raw;
1864    while let Some(pct) = rest.find('%') {
1865        let literal = &rest[..pct];
1866        let after = &rest[pct + 1..];
1867        if let Some(end) = after.find('%') {
1868            let name = &after[..end];
1869            if !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
1870                if !literal.is_empty() {
1871                    segments.push(Segment::Literal(literal));
1872                }
1873                let value = lookup(name).ok_or(NuGetFeedUrlError::HasCredentials)?;
1874                segments.push(Segment::Value(value));
1875                rest = &after[end + 1..];
1876                continue;
1877            }
1878        }
1879        // Not a well-formed `%NAME%` reference: keep everything up to and including this
1880        // `%` as literal text, then keep scanning from just past it.
1881        segments.push(Segment::Literal(&rest[..=pct]));
1882        rest = &rest[pct + 1..];
1883    }
1884    if !rest.is_empty() {
1885        segments.push(Segment::Literal(rest));
1886    }
1887
1888    let total_len: usize = segments
1889        .iter()
1890        .map(|segment| match segment {
1891            Segment::Literal(s) => s.len(),
1892            Segment::Value(v) => v.len(),
1893        })
1894        .sum();
1895    let mut out = Zeroizing::new(String::with_capacity(total_len));
1896    for segment in &segments {
1897        match segment {
1898            Segment::Literal(s) => out.push_str(s),
1899            Segment::Value(v) => out.push_str(v.as_str()),
1900        }
1901    }
1902    Ok(out)
1903}
1904
1905#[cfg(test)]
1906mod tests {
1907    use super::*;
1908    use deps_core::net_policy::WorkspaceRegistryAccess;
1909    use std::assert_matches;
1910
1911    fn all_policy() -> RegistryAccessPolicy {
1912        RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
1913    }
1914
1915    fn pkg(name: &str) -> PackageName {
1916        PackageName::new(name)
1917    }
1918
1919    fn write_config(dir: &Path, content: &str) {
1920        std::fs::write(dir.join("NuGet.Config"), content).unwrap();
1921    }
1922
1923    // --- NuGetFeedUrl ---
1924
1925    #[test]
1926    fn test_feed_url_accepts_https() {
1927        let policy = all_policy();
1928        assert!(NuGetFeedUrl::new("https://feed.mycorp.example/v3/index.json", &policy).is_ok());
1929    }
1930
1931    #[test]
1932    fn test_feed_url_rejects_userinfo() {
1933        let policy = all_policy();
1934        assert_matches!(
1935            NuGetFeedUrl::new("https://user:pass@feed.example/v3/index.json", &policy),
1936            Err(NuGetFeedUrlError::UserInfoPresent)
1937        );
1938    }
1939
1940    #[test]
1941    fn test_feed_url_normalizes_trailing_slash() {
1942        let policy = all_policy();
1943        let a = NuGetFeedUrl::new("https://feed.example/v3/index.json/", &policy).unwrap();
1944        let b = NuGetFeedUrl::new("https://feed.example/v3/index.json", &policy).unwrap();
1945        assert_eq!(a, b);
1946    }
1947
1948    // --- decode_xml_name / key_candidates (C3) ---
1949
1950    #[test]
1951    fn test_decode_xml_name_space() {
1952        assert_eq!(decode_xml_name("Corp_x0020_Feed"), "Corp Feed");
1953    }
1954
1955    #[test]
1956    fn test_decode_xml_name_literal_underscore() {
1957        assert_eq!(decode_xml_name("Corp_x005F_Feed"), "Corp_Feed");
1958    }
1959
1960    #[test]
1961    fn test_decode_xml_name_no_escapes_is_identity() {
1962        assert_eq!(decode_xml_name("CorpFeed"), "CorpFeed");
1963    }
1964
1965    #[test]
1966    fn test_key_candidates_overlap_case_insensitive() {
1967        assert!(key_candidates_overlap("CorpFeed", "corpfeed"));
1968    }
1969
1970    #[test]
1971    fn test_key_candidates_overlap_decoded_form() {
1972        assert!(key_candidates_overlap("Corp_x0020_Feed", "Corp Feed"));
1973    }
1974
1975    // --- resolve_mapping_source_key (R2) ---
1976
1977    fn source(key: &str, url: &str, policy: &RegistryAccessPolicy) -> PackageSourceEntry {
1978        PackageSourceEntry {
1979            key: key.to_string(),
1980            value: NuGetFeedUrl::new(url, policy).map_err(|reason| InvalidEntry {
1981                raw: url.to_string(),
1982                reason,
1983            }),
1984            tier: ConfigTier::Repo,
1985            auth: None,
1986        }
1987    }
1988
1989    #[test]
1990    fn test_resolve_mapping_source_key_unique_match() {
1991        let policy = all_policy();
1992        let sources = vec![source(
1993            "CorpFeed",
1994            "https://corp.example/v3/index.json",
1995            &policy,
1996        )];
1997        assert!(resolve_mapping_source_key("CorpFeed", &sources).is_some());
1998        assert!(resolve_mapping_source_key("corpfeed", &sources).is_some());
1999    }
2000
2001    #[test]
2002    fn test_resolve_mapping_source_key_absent_source_is_none() {
2003        let sources: Vec<PackageSourceEntry> = Vec::new();
2004        assert!(resolve_mapping_source_key("Missing", &sources).is_none());
2005    }
2006
2007    /// R2: an ambiguous union match (two declared sources whose raw/decoded candidates both
2008    /// cover the mapping key) must resolve to nothing, not fan out to both.
2009    #[test]
2010    fn test_resolve_mapping_source_key_ambiguous_is_none() {
2011        let policy = all_policy();
2012        let sources = vec![
2013            source(
2014                "Corp_x0020_Feed",
2015                "https://a.example/v3/index.json",
2016                &policy,
2017            ),
2018            source("Corp Feed", "https://b.example/v3/index.json", &policy),
2019        ];
2020        assert!(resolve_mapping_source_key("Corp Feed", &sources).is_none());
2021    }
2022
2023    // --- NuGetConfig::resolve_source_for: plain (non-mapping) chain ---
2024
2025    #[test]
2026    fn test_no_config_resolves_to_plain_registry() {
2027        let config = NuGetConfig::default();
2028        assert_eq!(
2029            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2030            DependencySource::Registry
2031        );
2032        assert!(config.resolved_chains().is_empty());
2033    }
2034
2035    #[test]
2036    fn test_single_alternate_source_no_clear_appends_implicit_public_fallback() {
2037        let dir = tempfile::tempdir().unwrap();
2038        write_config(
2039            dir.path(),
2040            r#"<configuration><packageSources>
2041                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2042            </packageSources></configuration>"#,
2043        );
2044        let cache = NuGetConfigCache::new();
2045        let policy = all_policy();
2046        let config = resolve(dir.path(), &cache, &policy);
2047
2048        let source = config.resolve_source_for(&pkg("Any.Package"));
2049        assert_matches!(source, DependencySource::AlternateRegistry { .. });
2050        let chains = config.resolved_chains();
2051        assert_eq!(chains.len(), 1);
2052        assert_eq!(chains[0].hops.len(), 1);
2053        assert!(chains[0].implicit_public_fallback);
2054    }
2055
2056    #[test]
2057    fn test_clear_suppresses_implicit_public_fallback() {
2058        let dir = tempfile::tempdir().unwrap();
2059        write_config(
2060            dir.path(),
2061            r#"<configuration><packageSources>
2062                <clear />
2063                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2064            </packageSources></configuration>"#,
2065        );
2066        let cache = NuGetConfigCache::new();
2067        let policy = all_policy();
2068        let config = resolve(dir.path(), &cache, &policy);
2069
2070        let chains = config.resolved_chains();
2071        assert_eq!(chains.len(), 1);
2072        assert!(!chains[0].implicit_public_fallback);
2073    }
2074
2075    /// R4: `<clear/>` with nothing re-added must be an explicit fail-closed
2076    /// `CustomRegistry`, never a fall-through to plain `Registry`.
2077    #[test]
2078    fn test_clear_with_nothing_readded_is_explicit_fail_closed() {
2079        let dir = tempfile::tempdir().unwrap();
2080        write_config(
2081            dir.path(),
2082            "<configuration><packageSources><clear /></packageSources></configuration>",
2083        );
2084        let cache = NuGetConfigCache::new();
2085        let policy = all_policy();
2086        let config = resolve(dir.path(), &cache, &policy);
2087
2088        let source = config.resolve_source_for(&pkg("Any.Package"));
2089        assert_eq!(
2090            source,
2091            DependencySource::CustomRegistry {
2092                url: NO_SOURCES_CONFIGURED_SENTINEL.to_string(),
2093            }
2094        );
2095        assert!(config.resolved_chains().is_empty());
2096    }
2097
2098    /// C1: a root `<clear/>` + CorpFeed, with a leaf adding a second feed and no `<clear/>`
2099    /// of its own, must never resurrect the implicit `nuget.org` hop (the #248 bug class).
2100    #[test]
2101    fn test_c1_root_clear_survives_leaf_without_clear() {
2102        let root = tempfile::tempdir().unwrap();
2103        let leaf = root.path().join("src").join("App");
2104        std::fs::create_dir_all(&leaf).unwrap();
2105        write_config(
2106            root.path(),
2107            r#"<configuration><packageSources>
2108                <clear />
2109                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2110            </packageSources></configuration>"#,
2111        );
2112        write_config(
2113            &leaf,
2114            r#"<configuration><packageSources>
2115                <add key="SecondFeed" value="https://second.example/v3/index.json" />
2116            </packageSources></configuration>"#,
2117        );
2118        let cache = NuGetConfigCache::new();
2119        let policy = all_policy();
2120        let config = resolve(&leaf, &cache, &policy);
2121
2122        let chains = config.resolved_chains();
2123        assert_eq!(chains.len(), 1);
2124        assert!(!chains[0].implicit_public_fallback);
2125        assert_eq!(chains[0].hops.len(), 2);
2126        assert_eq!(
2127            chains[0].hops[0].url.as_str(),
2128            "https://corp.example/v3/index.json"
2129        );
2130        assert_eq!(
2131            chains[0].hops[1].url.as_str(),
2132            "https://second.example/v3/index.json"
2133        );
2134    }
2135
2136    /// A leaf `<clear/>` must still be able to wipe an ancestor's feed (the reverse
2137    /// direction) — sticky-`cleared` is not a one-way ratchet against the leaf itself.
2138    #[test]
2139    fn test_leaf_clear_wipes_ancestor_source() {
2140        let root = tempfile::tempdir().unwrap();
2141        let leaf = root.path().join("src").join("App");
2142        std::fs::create_dir_all(&leaf).unwrap();
2143        write_config(
2144            root.path(),
2145            r#"<configuration><packageSources>
2146                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2147            </packageSources></configuration>"#,
2148        );
2149        write_config(
2150            &leaf,
2151            "<configuration><packageSources><clear /></packageSources></configuration>",
2152        );
2153        let cache = NuGetConfigCache::new();
2154        let policy = all_policy();
2155        let config = resolve(&leaf, &cache, &policy);
2156
2157        assert!(config.resolved_chains().is_empty());
2158        assert_eq!(
2159            config.resolve_source_for(&pkg("Any.Package")),
2160            DependencySource::CustomRegistry {
2161                url: NO_SOURCES_CONFIGURED_SENTINEL.to_string(),
2162            }
2163        );
2164    }
2165
2166    // --- disabled / credentialed (C3, FR-004/FR-009) ---
2167
2168    #[test]
2169    fn test_disabled_source_case_insensitive_key_match() {
2170        let dir = tempfile::tempdir().unwrap();
2171        write_config(
2172            dir.path(),
2173            r#"<configuration>
2174                <packageSources>
2175                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2176                </packageSources>
2177                <disabledPackageSources>
2178                    <add key="corpfeed" value="True" />
2179                </disabledPackageSources>
2180            </configuration>"#,
2181        );
2182        let cache = NuGetConfigCache::new();
2183        let policy = all_policy();
2184        let config = resolve(dir.path(), &cache, &policy);
2185
2186        assert!(config.resolved_chains().is_empty());
2187        assert_eq!(
2188            config.resolve_source_for(&pkg("Any.Package")),
2189            DependencySource::Registry
2190        );
2191    }
2192
2193    #[test]
2194    fn test_credentialed_source_dropped_with_decoded_name_match() {
2195        let dir = tempfile::tempdir().unwrap();
2196        write_config(
2197            dir.path(),
2198            r#"<configuration>
2199                <packageSources>
2200                    <add key="Corp Feed" value="https://corp.example/v3/index.json" />
2201                </packageSources>
2202                <packageSourceCredentials>
2203                    <Corp_x0020_Feed>
2204                        <add key="Username" value="user" />
2205                        <add key="ClearTextPassword" value="pass" />
2206                    </Corp_x0020_Feed>
2207                </packageSourceCredentials>
2208            </configuration>"#,
2209        );
2210        let cache = NuGetConfigCache::new();
2211        let policy = all_policy();
2212        let config = resolve(dir.path(), &cache, &policy);
2213
2214        assert!(config.resolved_chains().is_empty());
2215    }
2216
2217    // --- packageSourceMapping (C2/R1/R3) ---
2218
2219    #[test]
2220    fn test_mapping_unmatched_package_fails_closed() {
2221        let dir = tempfile::tempdir().unwrap();
2222        write_config(
2223            dir.path(),
2224            r#"<configuration>
2225                <packageSources>
2226                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2227                </packageSources>
2228                <packageSourceMapping>
2229                    <packageSource key="CorpFeed">
2230                        <package pattern="MyCompany.*" />
2231                    </packageSource>
2232                </packageSourceMapping>
2233            </configuration>"#,
2234        );
2235        let cache = NuGetConfigCache::new();
2236        let policy = all_policy();
2237        let config = resolve(dir.path(), &cache, &policy);
2238
2239        assert_eq!(
2240            config.resolve_source_for(&pkg("Unrelated.Package")),
2241            DependencySource::CustomRegistry {
2242                url: "Unrelated.Package".to_string(),
2243            }
2244        );
2245    }
2246
2247    #[test]
2248    fn test_mapping_matched_private_pattern_never_falls_back_to_public() {
2249        let dir = tempfile::tempdir().unwrap();
2250        write_config(
2251            dir.path(),
2252            r#"<configuration>
2253                <packageSources>
2254                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2255                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2256                </packageSources>
2257                <packageSourceMapping>
2258                    <packageSource key="CorpFeed">
2259                        <package pattern="MyCompany.*" />
2260                    </packageSource>
2261                    <packageSource key="nuget.org">
2262                        <package pattern="*" />
2263                    </packageSource>
2264                </packageSourceMapping>
2265            </configuration>"#,
2266        );
2267        let cache = NuGetConfigCache::new();
2268        let policy = all_policy();
2269        let config = resolve(dir.path(), &cache, &policy);
2270
2271        let source = config.resolve_source_for(&pkg("MyCompany.Internal"));
2272        let DependencySource::AlternateRegistry { index, .. } = source else {
2273            panic!("expected AlternateRegistry, private package must never route to nuget.org");
2274        };
2275        let chains = config.resolved_chains();
2276        let chain = chains.iter().find(|c| c.key == index).unwrap();
2277        assert_eq!(chain.hops.len(), 1);
2278        assert_eq!(
2279            chain.hops[0].url.as_str(),
2280            "https://corp.example/v3/index.json"
2281        );
2282        assert!(!chain.implicit_public_fallback);
2283    }
2284
2285    /// R3: a package whose winning pattern maps only to the *real* nuget.org source (by
2286    /// normalized URL, not by key name) resolves to plain `Registry` — keeping OSV/deps.dev.
2287    #[test]
2288    fn test_mapping_public_only_pattern_resolves_to_plain_registry() {
2289        let dir = tempfile::tempdir().unwrap();
2290        write_config(
2291            dir.path(),
2292            r#"<configuration>
2293                <packageSources>
2294                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2295                </packageSources>
2296                <packageSourceMapping>
2297                    <packageSource key="nuget.org">
2298                        <package pattern="*" />
2299                    </packageSource>
2300                </packageSourceMapping>
2301            </configuration>"#,
2302        );
2303        let cache = NuGetConfigCache::new();
2304        let policy = all_policy();
2305        let config = resolve(dir.path(), &cache, &policy);
2306
2307        assert_eq!(
2308            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2309            DependencySource::Registry
2310        );
2311        assert!(config.resolved_chains().is_empty());
2312    }
2313
2314    /// R3: a hostile config naming a private feed `nuget.org` must not be mistaken for the
2315    /// real public registry — identification is by normalized URL, never by key.
2316    #[test]
2317    fn test_mapping_source_named_nuget_org_but_different_url_is_not_public() {
2318        let dir = tempfile::tempdir().unwrap();
2319        write_config(
2320            dir.path(),
2321            r#"<configuration>
2322                <packageSources>
2323                    <add key="nuget.org" value="https://evil.example/v3/index.json" />
2324                </packageSources>
2325                <packageSourceMapping>
2326                    <packageSource key="nuget.org">
2327                        <package pattern="*" />
2328                    </packageSource>
2329                </packageSourceMapping>
2330            </configuration>"#,
2331        );
2332        let cache = NuGetConfigCache::new();
2333        let policy = all_policy();
2334        let config = resolve(dir.path(), &cache, &policy);
2335
2336        assert_matches!(
2337            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2338            DependencySource::AlternateRegistry { .. }
2339        );
2340    }
2341
2342    /// R1 counterexample from the critic review: a root mapping `{CorpFeed: MyCompany.*,
2343    /// nuget.org: *}` merged with a leaf mapping `{nuget.org: *}` must still route
2344    /// `MyCompany.Internal` to CorpFeed — "nearest file wins" would leak it to nuget.org.
2345    #[test]
2346    fn test_r1_mapping_merges_across_ancestor_and_leaf_not_nearest_wins() {
2347        let root = tempfile::tempdir().unwrap();
2348        let leaf = root.path().join("src").join("App");
2349        std::fs::create_dir_all(&leaf).unwrap();
2350        write_config(
2351            root.path(),
2352            r#"<configuration>
2353                <packageSources>
2354                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2355                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2356                </packageSources>
2357                <packageSourceMapping>
2358                    <packageSource key="CorpFeed">
2359                        <package pattern="MyCompany.*" />
2360                    </packageSource>
2361                    <packageSource key="nuget.org">
2362                        <package pattern="*" />
2363                    </packageSource>
2364                </packageSourceMapping>
2365            </configuration>"#,
2366        );
2367        write_config(
2368            &leaf,
2369            r#"<configuration>
2370                <packageSourceMapping>
2371                    <packageSource key="nuget.org">
2372                        <package pattern="*" />
2373                    </packageSource>
2374                </packageSourceMapping>
2375            </configuration>"#,
2376        );
2377        let cache = NuGetConfigCache::new();
2378        let policy = all_policy();
2379        let config = resolve(&leaf, &cache, &policy);
2380
2381        let source = config.resolve_source_for(&pkg("MyCompany.Internal"));
2382        let DependencySource::AlternateRegistry { index, .. } = source else {
2383            panic!("R1 regression: MyCompany.Internal leaked to nuget.org via nearest-wins");
2384        };
2385        let chains = config.resolved_chains();
2386        let chain = chains.iter().find(|c| c.key == index).unwrap();
2387        assert_eq!(
2388            chain.hops[0].url.as_str(),
2389            "https://corp.example/v3/index.json"
2390        );
2391
2392        // The unrelated public package must still resolve via the merged `*` -> nuget.org
2393        // mapping, unaffected by the merge.
2394        assert_eq!(
2395            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2396            DependencySource::Registry
2397        );
2398    }
2399
2400    // --- protocolVersion / local feeds ---
2401
2402    #[test]
2403    fn test_protocol_version_2_rejected() {
2404        let dir = tempfile::tempdir().unwrap();
2405        write_config(
2406            dir.path(),
2407            r#"<configuration><packageSources>
2408                <add key="Legacy" value="https://legacy.example/api/v2" protocolVersion="2" />
2409            </packageSources></configuration>"#,
2410        );
2411        let cache = NuGetConfigCache::new();
2412        let policy = all_policy();
2413        let config = resolve(dir.path(), &cache, &policy);
2414
2415        assert!(config.resolved_chains().is_empty());
2416        assert_eq!(
2417            config.resolve_source_for(&pkg("Any.Package")),
2418            DependencySource::Registry
2419        );
2420    }
2421
2422    #[test]
2423    fn test_local_feed_path_rejected() {
2424        let dir = tempfile::tempdir().unwrap();
2425        write_config(
2426            dir.path(),
2427            r#"<configuration><packageSources>
2428                <add key="Local" value="../packages" />
2429            </packageSources></configuration>"#,
2430        );
2431        let cache = NuGetConfigCache::new();
2432        let policy = all_policy();
2433        let config = resolve(dir.path(), &cache, &policy);
2434
2435        assert!(config.resolved_chains().is_empty());
2436    }
2437
2438    // --- chain key invariant ---
2439
2440    #[test]
2441    fn test_resolve_source_for_and_resolved_chains_agree_on_key() {
2442        let dir = tempfile::tempdir().unwrap();
2443        write_config(
2444            dir.path(),
2445            r#"<configuration><packageSources>
2446                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2447            </packageSources></configuration>"#,
2448        );
2449        let cache = NuGetConfigCache::new();
2450        let policy = all_policy();
2451        let config = resolve(dir.path(), &cache, &policy);
2452
2453        let DependencySource::AlternateRegistry { index, .. } =
2454            config.resolve_source_for(&pkg("Any.Package"))
2455        else {
2456            panic!("expected AlternateRegistry");
2457        };
2458        assert_eq!(config.resolved_chains()[0].key, index);
2459    }
2460
2461    // --- NuGetConfigCache ---
2462
2463    #[test]
2464    fn test_config_cache_reparses_after_mtime_change() {
2465        let dir = tempfile::tempdir().unwrap();
2466        let path = dir.path().join("NuGet.Config");
2467        std::fs::write(
2468            &path,
2469            r#"<configuration><packageSources><add key="A" value="https://a.example/v3/index.json" /></packageSources></configuration>"#,
2470        )
2471        .unwrap();
2472        let cache = NuGetConfigCache::new();
2473        let first = cache.get_or_parse(&path).unwrap();
2474        assert_eq!(first.sources.len(), 1);
2475
2476        let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
2477        std::fs::write(
2478            &path,
2479            r#"<configuration><packageSources>
2480                <add key="A" value="https://a.example/v3/index.json" />
2481                <add key="B" value="https://b.example/v3/index.json" />
2482            </packageSources></configuration>"#,
2483        )
2484        .unwrap();
2485        std::fs::OpenOptions::new()
2486            .write(true)
2487            .open(&path)
2488            .unwrap()
2489            .set_modified(future)
2490            .unwrap();
2491
2492        let second = cache.get_or_parse(&path).unwrap();
2493        assert_eq!(second.sources.len(), 2);
2494    }
2495
2496    #[test]
2497    fn test_resolve_with_no_config_anywhere_is_default() {
2498        let dir = tempfile::tempdir().unwrap();
2499        let cache = NuGetConfigCache::new();
2500        let policy = all_policy();
2501        let config = resolve(dir.path(), &cache, &policy);
2502        assert!(config.resolved_chains().is_empty());
2503        assert_eq!(
2504            config.resolve_source_for(&pkg("Any.Package")),
2505            DependencySource::Registry
2506        );
2507    }
2508
2509    // --- H1: malformed XML degrades to the all-default RawNuGetConfigFile ---
2510
2511    /// A mistyped closing tag after `<packageSources>` must not silently drop
2512    /// `<packageSourceCredentials>`/`<disabledPackageSources>`/`<packageSourceMapping>` that
2513    /// would otherwise have followed — the whole file degrades to empty (fail closed in the
2514    /// safe direction: a broken file behaves as if it declared nothing, never as if it
2515    /// declared only the part before the break).
2516    #[test]
2517    fn test_h1_malformed_xml_degrades_to_all_default_not_partial() {
2518        let raw = parse_nuget_config_raw(
2519            r#"<configuration><packageSources>
2520                <clear />
2521                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2522            </packageSource></configuration>"#,
2523        );
2524        assert!(!raw.sources_cleared);
2525        assert!(raw.sources.is_empty());
2526    }
2527
2528    #[test]
2529    fn test_h1_malformed_config_file_resolves_as_if_absent() {
2530        let dir = tempfile::tempdir().unwrap();
2531        write_config(
2532            dir.path(),
2533            r#"<configuration><packageSources>
2534                <clear />
2535                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2536            </packageSource></configuration>"#,
2537        );
2538        let cache = NuGetConfigCache::new();
2539        let policy = all_policy();
2540        let config = resolve(dir.path(), &cache, &policy);
2541        assert!(config.resolved_chains().is_empty());
2542        assert_eq!(
2543            config.resolve_source_for(&pkg("Any.Package")),
2544            DependencySource::Registry
2545        );
2546    }
2547
2548    // --- S2: a self-closing section element must not latch parser state ---
2549
2550    #[test]
2551    fn test_s2_self_closing_credentials_section_does_not_swallow_later_elements() {
2552        let dir = tempfile::tempdir().unwrap();
2553        write_config(
2554            dir.path(),
2555            r#"<configuration>
2556                <packageSourceCredentials />
2557                <packageSources>
2558                    <clear />
2559                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2560                </packageSources>
2561            </configuration>"#,
2562        );
2563        let cache = NuGetConfigCache::new();
2564        let policy = all_policy();
2565        let config = resolve(dir.path(), &cache, &policy);
2566
2567        let chains = config.resolved_chains();
2568        assert_eq!(chains.len(), 1, "packageSources must not be swallowed");
2569        assert!(!chains[0].implicit_public_fallback);
2570    }
2571
2572    #[test]
2573    fn test_s2_self_closing_sources_section_does_not_latch() {
2574        let dir = tempfile::tempdir().unwrap();
2575        write_config(
2576            dir.path(),
2577            r#"<configuration>
2578                <packageSources />
2579                <disabledPackageSources>
2580                    <add key="CorpFeed" value="true" />
2581                </disabledPackageSources>
2582                <packageSources>
2583                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2584                </packageSources>
2585            </configuration>"#,
2586        );
2587        let cache = NuGetConfigCache::new();
2588        let policy = all_policy();
2589        let config = resolve(dir.path(), &cache, &policy);
2590
2591        // The disabled entry must actually be recognized as disabled (proving
2592        // `disabledPackageSources` was reached as its own section, not folded into a latched
2593        // `packageSources` state), so CorpFeed contributes nothing and the implicit public
2594        // default remains.
2595        assert!(config.resolved_chains().is_empty());
2596        assert_eq!(
2597            config.resolve_source_for(&pkg("Any.Package")),
2598            DependencySource::Registry
2599        );
2600    }
2601
2602    // --- S1: a packageSourceMapping key naming the undeclared implicit nuget.org ---
2603
2604    #[test]
2605    fn test_s1_mapping_undeclared_nuget_org_key_falls_back_to_real_public_source() {
2606        let dir = tempfile::tempdir().unwrap();
2607        write_config(
2608            dir.path(),
2609            r#"<configuration>
2610                <packageSources>
2611                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2612                </packageSources>
2613                <packageSourceMapping>
2614                    <packageSource key="CorpFeed">
2615                        <package pattern="MyCompany.*" />
2616                    </packageSource>
2617                    <packageSource key="nuget.org">
2618                        <package pattern="*" />
2619                    </packageSource>
2620                </packageSourceMapping>
2621            </configuration>"#,
2622        );
2623        let cache = NuGetConfigCache::new();
2624        let policy = all_policy();
2625        let config = resolve(dir.path(), &cache, &policy);
2626
2627        // The near-universal real-world shape: nuget.org itself lives in the machine/user
2628        // config this feature does not read, so it is never a declared `PackageSourceEntry`
2629        // here — without the S1 fix this would fail every public package closed.
2630        assert_eq!(
2631            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2632            DependencySource::Registry
2633        );
2634        // The private pattern must still route to CorpFeed, unaffected.
2635        assert_matches!(
2636            config.resolve_source_for(&pkg("MyCompany.Internal")),
2637            DependencySource::AlternateRegistry { .. }
2638        );
2639    }
2640
2641    #[test]
2642    fn test_s1_mapping_undeclared_key_other_than_nuget_org_still_fails_closed() {
2643        let dir = tempfile::tempdir().unwrap();
2644        write_config(
2645            dir.path(),
2646            r#"<configuration>
2647                <packageSourceMapping>
2648                    <packageSource key="SomeOtherUndeclaredFeed">
2649                        <package pattern="*" />
2650                    </packageSource>
2651                </packageSourceMapping>
2652            </configuration>"#,
2653        );
2654        let cache = NuGetConfigCache::new();
2655        let policy = all_policy();
2656        let config = resolve(dir.path(), &cache, &policy);
2657
2658        assert_eq!(
2659            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2660            DependencySource::CustomRegistry {
2661                url: "Newtonsoft.Json".to_string(),
2662            }
2663        );
2664    }
2665
2666    // --- S4: <remove key="..."/> ---
2667
2668    #[test]
2669    fn test_s4_remove_excludes_previously_declared_source() {
2670        let dir = tempfile::tempdir().unwrap();
2671        write_config(
2672            dir.path(),
2673            r#"<configuration><packageSources>
2674                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2675                <remove key="CorpFeed" />
2676            </packageSources></configuration>"#,
2677        );
2678        let cache = NuGetConfigCache::new();
2679        let policy = all_policy();
2680        let config = resolve(dir.path(), &cache, &policy);
2681
2682        assert!(config.resolved_chains().is_empty());
2683        assert_eq!(
2684            config.resolve_source_for(&pkg("Any.Package")),
2685            DependencySource::Registry
2686        );
2687    }
2688
2689    #[test]
2690    fn test_s4_remove_across_ancestor_files() {
2691        let root = tempfile::tempdir().unwrap();
2692        let leaf = root.path().join("src").join("App");
2693        std::fs::create_dir_all(&leaf).unwrap();
2694        write_config(
2695            root.path(),
2696            r#"<configuration><packageSources>
2697                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2698            </packageSources></configuration>"#,
2699        );
2700        write_config(
2701            &leaf,
2702            r#"<configuration><packageSources>
2703                <remove key="CorpFeed" />
2704            </packageSources></configuration>"#,
2705        );
2706        let cache = NuGetConfigCache::new();
2707        let policy = all_policy();
2708        let config = resolve(&leaf, &cache, &policy);
2709
2710        assert!(config.resolved_chains().is_empty());
2711        assert_eq!(
2712            config.resolve_source_for(&pkg("Any.Package")),
2713            DependencySource::Registry
2714        );
2715    }
2716
2717    /// `<remove key="nuget.org"/>` with no `<clear/>` must suppress the implicit public
2718    /// fallback too — the same #248-class bug in the opposite direction (an explicitly
2719    /// removed public source staying reachable).
2720    #[test]
2721    fn test_s4_remove_nuget_org_suppresses_implicit_fallback() {
2722        let dir = tempfile::tempdir().unwrap();
2723        write_config(
2724            dir.path(),
2725            r#"<configuration><packageSources>
2726                <remove key="nuget.org" />
2727                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2728            </packageSources></configuration>"#,
2729        );
2730        let cache = NuGetConfigCache::new();
2731        let policy = all_policy();
2732        let config = resolve(dir.path(), &cache, &policy);
2733
2734        let chains = config.resolved_chains();
2735        assert_eq!(chains.len(), 1);
2736        assert!(
2737            !chains[0].implicit_public_fallback,
2738            "explicitly-removed nuget.org must not be resurrected as the implicit tail"
2739        );
2740    }
2741
2742    /// `<remove key="nuget.org"/>` alone, with no other source declared, must fail closed —
2743    /// not silently degrade to plain `Registry` the way "nothing declared at all" does.
2744    #[test]
2745    fn test_s4_remove_nuget_org_alone_fails_closed() {
2746        let dir = tempfile::tempdir().unwrap();
2747        write_config(
2748            dir.path(),
2749            r#"<configuration><packageSources>
2750                <remove key="nuget.org" />
2751            </packageSources></configuration>"#,
2752        );
2753        let cache = NuGetConfigCache::new();
2754        let policy = all_policy();
2755        let config = resolve(dir.path(), &cache, &policy);
2756
2757        assert!(config.resolved_chains().is_empty());
2758        assert_matches!(
2759            config.resolve_source_for(&pkg("Any.Package")),
2760            DependencySource::CustomRegistry { .. }
2761        );
2762    }
2763
2764    // --- M2: plain-chain path treats a public-only hop like the mapping path does ---
2765
2766    #[test]
2767    fn test_m2_explicit_clear_plus_nuget_org_add_resolves_to_plain_registry() {
2768        let dir = tempfile::tempdir().unwrap();
2769        write_config(
2770            dir.path(),
2771            r#"<configuration><packageSources>
2772                <clear />
2773                <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
2774            </packageSources></configuration>"#,
2775        );
2776        let cache = NuGetConfigCache::new();
2777        let policy = all_policy();
2778        let config = resolve(dir.path(), &cache, &policy);
2779
2780        assert_eq!(
2781            config.resolve_source_for(&pkg("Newtonsoft.Json")),
2782            DependencySource::Registry
2783        );
2784        assert!(config.resolved_chains().is_empty());
2785    }
2786
2787    // --- resolve_keys_for: genuine pattern competition (tester gap #3) ---
2788
2789    #[test]
2790    fn test_resolve_keys_for_exact_beats_longer_prefix() {
2791        let mut mapping = PackageSourceMapping::default();
2792        mapping.extend("ExactSource", &["MyCompany.Foo".to_string()]);
2793        mapping.extend("PrefixSource", &["MyCompany.*".to_string()]);
2794
2795        let keys = mapping.resolve_keys_for("mycompany.foo").unwrap();
2796        assert_eq!(keys, vec!["ExactSource"]);
2797    }
2798
2799    #[test]
2800    fn test_resolve_keys_for_longer_prefix_beats_shorter_prefix() {
2801        let mut mapping = PackageSourceMapping::default();
2802        mapping.extend("ShortPrefix", &["My.*".to_string()]);
2803        mapping.extend("LongPrefix", &["My.Company.*".to_string()]);
2804
2805        let keys = mapping.resolve_keys_for("my.company.internal").unwrap();
2806        assert_eq!(keys, vec!["LongPrefix"]);
2807    }
2808
2809    #[test]
2810    fn test_resolve_keys_for_prefix_beats_wildcard() {
2811        let mut mapping = PackageSourceMapping::default();
2812        mapping.extend("Wildcard", &["*".to_string()]);
2813        mapping.extend("Prefix", &["My.*".to_string()]);
2814
2815        let keys = mapping.resolve_keys_for("my.internal").unwrap();
2816        assert_eq!(keys, vec!["Prefix"]);
2817    }
2818
2819    /// A genuine tie (identical pattern text declared for two different sources — the only
2820    /// way a tie can occur, since two distinct pattern texts can never score equal against
2821    /// the same candidate name) makes both sources eligible.
2822    #[test]
2823    fn test_resolve_keys_for_tie_on_identical_pattern_fans_out() {
2824        let mut mapping = PackageSourceMapping::default();
2825        mapping.extend("SourceA", &["*".to_string()]);
2826        mapping.extend("SourceB", &["*".to_string()]);
2827
2828        let mut keys = mapping.resolve_keys_for("any.package").unwrap();
2829        keys.sort_unstable();
2830        assert_eq!(keys, vec!["SourceA", "SourceB"]);
2831    }
2832
2833    // --- R4 mapping-side empty state (tester gap #4) ---
2834
2835    /// A package's winning `<packageSourceMapping>` pattern resolves only to a source that
2836    /// was then filtered out as disabled — must fail closed, never fall through to any other
2837    /// source or to plain `Registry`.
2838    #[test]
2839    fn test_r4_mapping_winning_pattern_resolves_only_to_disabled_source() {
2840        let dir = tempfile::tempdir().unwrap();
2841        write_config(
2842            dir.path(),
2843            r#"<configuration>
2844                <packageSources>
2845                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2846                </packageSources>
2847                <disabledPackageSources>
2848                    <add key="CorpFeed" value="true" />
2849                </disabledPackageSources>
2850                <packageSourceMapping>
2851                    <packageSource key="CorpFeed">
2852                        <package pattern="MyCompany.*" />
2853                    </packageSource>
2854                </packageSourceMapping>
2855            </configuration>"#,
2856        );
2857        let cache = NuGetConfigCache::new();
2858        let policy = all_policy();
2859        let config = resolve(dir.path(), &cache, &policy);
2860
2861        assert_eq!(
2862            config.resolve_source_for(&pkg("MyCompany.Internal")),
2863            DependencySource::CustomRegistry {
2864                url: "MyCompany.Internal".to_string(),
2865            }
2866        );
2867        assert!(config.resolved_chains().is_empty());
2868    }
2869
2870    // --- LOW: upsert_source dedupes through the same key_candidates union as elsewhere ---
2871
2872    #[test]
2873    fn test_upsert_source_dedupes_across_xml_encoded_key_variants() {
2874        let root = tempfile::tempdir().unwrap();
2875        let leaf = root.path().join("src").join("App");
2876        std::fs::create_dir_all(&leaf).unwrap();
2877        write_config(
2878            root.path(),
2879            r#"<configuration><packageSources>
2880                <add key="Corp Feed" value="https://old.example/v3/index.json" />
2881            </packageSources></configuration>"#,
2882        );
2883        write_config(
2884            &leaf,
2885            r#"<configuration><packageSources>
2886                <add key="Corp_x0020_Feed" value="https://new.example/v3/index.json" />
2887            </packageSources></configuration>"#,
2888        );
2889        let cache = NuGetConfigCache::new();
2890        let policy = all_policy();
2891        let config = resolve(&leaf, &cache, &policy);
2892
2893        let chains = config.resolved_chains();
2894        assert_eq!(chains.len(), 1);
2895        assert_eq!(
2896            chains[0].hops.len(),
2897            1,
2898            "XML-name-equivalent keys must upsert into one entry, not two"
2899        );
2900        assert_eq!(
2901            chains[0].hops[0].url.as_str(),
2902            "https://new.example/v3/index.json"
2903        );
2904    }
2905
2906    // --- issue #561: user-profile credentials, C2 binding, %ENV_VAR% expansion ---
2907
2908    fn write_user_profile(dir: &Path, content: &str) -> PathBuf {
2909        let path = dir.join("UserProfile.NuGet.Config");
2910        std::fs::write(&path, content).unwrap();
2911        path
2912    }
2913
2914    fn resolve_ctx(
2915        repo_dir: &Path,
2916        cache: &NuGetConfigCache,
2917        policy: &RegistryAccessPolicy,
2918        user_profile: Option<&Path>,
2919        flag_on: bool,
2920    ) -> NuGetConfig {
2921        resolve_with_context(
2922            repo_dir,
2923            cache,
2924            policy,
2925            user_profile,
2926            &AtomicBool::new(flag_on),
2927        )
2928    }
2929
2930    const CORP_CRED_USER_PROFILE: &str = r#"<configuration>
2931        <packageSources>
2932            <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2933        </packageSources>
2934        <packageSourceCredentials>
2935            <CorpFeed>
2936                <add key="Username" value="user" />
2937                <add key="ClearTextPassword" value="pat-value" />
2938            </CorpFeed>
2939        </packageSourceCredentials>
2940    </configuration>"#;
2941
2942    /// SC-001/SC-006: a repo `<add key="CorpFeed">` at the exact URL the user-profile config
2943    /// declares gets the credential attached.
2944    #[test]
2945    fn test_c2_exact_url_match_attaches_credential() {
2946        let root = tempfile::tempdir().unwrap();
2947        let repo = root.path().join("repo");
2948        std::fs::create_dir_all(&repo).unwrap();
2949        let user_profile = write_user_profile(root.path(), CORP_CRED_USER_PROFILE);
2950        write_config(
2951            &repo,
2952            r#"<configuration><packageSources>
2953                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
2954            </packageSources></configuration>"#,
2955        );
2956        let cache = NuGetConfigCache::new();
2957        let policy = all_policy();
2958        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
2959
2960        let chains = config.resolved_chains();
2961        assert_eq!(chains.len(), 1);
2962        assert_eq!(chains[0].hops.len(), 1);
2963        assert!(
2964            chains[0].hops[0].auth.is_some(),
2965            "matching-URL repo entry must receive the user-profile credential"
2966        );
2967        assert_eq!(chains[0].hops[0].slot.as_deref(), Some("corpfeed"));
2968    }
2969
2970    /// SC-006: same-origin-different-path repo entry must fail closed as `HasCredentials` —
2971    /// condition (3) is full-URL equality, not origin equality.
2972    #[test]
2973    fn test_c2_same_origin_different_path_fails_closed() {
2974        let root = tempfile::tempdir().unwrap();
2975        let repo = root.path().join("repo");
2976        std::fs::create_dir_all(&repo).unwrap();
2977        let user_profile = write_user_profile(
2978            root.path(),
2979            r#"<configuration>
2980                <packageSources>
2981                    <add key="CorpFeed" value="https://pkgs.dev.azure.com/real-org/_packaging/x/nuget/v3/index.json" />
2982                </packageSources>
2983                <packageSourceCredentials>
2984                    <CorpFeed>
2985                        <add key="Username" value="user" />
2986                        <add key="ClearTextPassword" value="pat" />
2987                    </CorpFeed>
2988                </packageSourceCredentials>
2989            </configuration>"#,
2990        );
2991        write_config(
2992            &repo,
2993            r#"<configuration><packageSources>
2994                <add key="CorpFeed" value="https://pkgs.dev.azure.com/attacker-org/_packaging/x/nuget/v3/index.json" />
2995            </packageSources></configuration>"#,
2996        );
2997        let cache = NuGetConfigCache::new();
2998        let policy = all_policy();
2999        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3000
3001        assert!(
3002            config.resolved_chains().is_empty(),
3003            "URL mismatch must fail the source closed, not attach nor route it"
3004        );
3005    }
3006
3007    /// SC-007/§3.4: a user-profile-disabled key never receives a credential on a matching
3008    /// repo-declared source, while the repo source is still queried (just unauthenticated is
3009    /// impossible here since it has real credentials configured — so it must fail closed, not
3010    /// merely "unauthenticated", per condition (0)).
3011    #[test]
3012    fn test_c2_condition_0_suppressed_key_fails_closed_not_machine_disabled() {
3013        let root = tempfile::tempdir().unwrap();
3014        let repo = root.path().join("repo");
3015        std::fs::create_dir_all(&repo).unwrap();
3016        let user_profile = write_user_profile(
3017            root.path(),
3018            r#"<configuration>
3019                <packageSources>
3020                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3021                </packageSources>
3022                <packageSourceCredentials>
3023                    <CorpFeed>
3024                        <add key="Username" value="user" />
3025                        <add key="ClearTextPassword" value="pat" />
3026                    </CorpFeed>
3027                </packageSourceCredentials>
3028                <disabledPackageSources>
3029                    <add key="CorpFeed" value="true" />
3030                </disabledPackageSources>
3031            </configuration>"#,
3032        );
3033        write_config(
3034            &repo,
3035            r#"<configuration><packageSources>
3036                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3037            </packageSources></configuration>"#,
3038        );
3039        let cache = NuGetConfigCache::new();
3040        let policy = all_policy();
3041        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3042
3043        // The repo's CorpFeed is not machine-wide disabled by a user-profile suppression —
3044        // only the credential binding is refused, which here means the entry fails closed
3045        // (HasCredentials) since it *did* match a credential key.
3046        assert!(config.resolved_chains().is_empty());
3047    }
3048
3049    /// SC-002: `%ENV_VAR%` expansion — set resolves, unset fails closed, and the unexpanded
3050    /// literal is never leaked into the result. Exercises `expand_env_vars_with` directly
3051    /// (this workspace forbids `unsafe`, so a test cannot mutate the real process
3052    /// environment — see that function's doc).
3053    #[test]
3054    fn test_env_var_expansion_set_and_unset() {
3055        let set = expand_env_vars_with("%CORP_FEED_PAT%", |name| {
3056            (name == "CORP_FEED_PAT").then(|| Zeroizing::new("secret-pat".to_string()))
3057        });
3058        assert_eq!(set.unwrap().as_str(), "secret-pat");
3059
3060        let unset = expand_env_vars_with("%CORP_FEED_PAT%", |_| None);
3061        assert_matches!(unset, Err(NuGetFeedUrlError::HasCredentials));
3062    }
3063
3064    /// Regression coverage for the `&str`-slice rewrite of `expand_env_vars_with` (it no
3065    /// longer collects `raw` into an intermediate `Vec<char>`): surrounding literal text,
3066    /// back-to-back substitutions with no literal between them, a malformed name (non
3067    /// alphanumeric/underscore character) left as literal text, and an unterminated `%` at
3068    /// end-of-string left as literal text — all must behave exactly as the prior
3069    /// char-by-char scan did.
3070    #[test]
3071    fn test_env_var_expansion_edge_cases() {
3072        let lookup = |name: &str| match name {
3073            "A" => Some(Zeroizing::new("1".to_string())),
3074            "B" => Some(Zeroizing::new("2".to_string())),
3075            _ => None,
3076        };
3077
3078        assert_eq!(
3079            expand_env_vars_with("pre-%A%-post", lookup)
3080                .unwrap()
3081                .as_str(),
3082            "pre-1-post"
3083        );
3084        assert_eq!(
3085            expand_env_vars_with("%A%%B%", lookup).unwrap().as_str(),
3086            "12"
3087        );
3088        assert_eq!(
3089            expand_env_vars_with("abc%1bad!name%def", lookup)
3090                .unwrap()
3091                .as_str(),
3092            "abc%1bad!name%def"
3093        );
3094        assert_eq!(
3095            expand_env_vars_with("abc%A", lookup).unwrap().as_str(),
3096            "abc%A"
3097        );
3098        assert_eq!(expand_env_vars_with("%%", lookup).unwrap().as_str(), "%%");
3099        assert_matches!(
3100            expand_env_vars_with("pre-%UNSET%-post", lookup),
3101            Err(NuGetFeedUrlError::HasCredentials)
3102        );
3103    }
3104
3105    /// SC-002 end-to-end: the same expansion wired through `resolve`'s credential-binding
3106    /// pass — a credential whose `RawCredential` has no `password` (the shape an unset env
3107    /// var's literal string alone cannot distinguish from a real missing `ClearTextPassword`
3108    /// at this layer) fails closed via `expand_credential`.
3109    #[test]
3110    fn test_expand_credential_missing_password_fails_closed() {
3111        let credential = RawCredential {
3112            key: "CorpFeed".to_string(),
3113            username: Some(RedactedSecret::new("user".to_string())),
3114            password: None,
3115            encrypted: false,
3116        };
3117        assert_matches!(
3118            expand_credential(&credential),
3119            Err(NuGetFeedUrlError::HasCredentials)
3120        );
3121    }
3122
3123    /// SC-002/NFR-001: `Debug`/`Display` on the credential-holding types never leak the
3124    /// literal secret.
3125    #[test]
3126    fn test_nuget_auth_and_redacted_secret_never_debug_print_the_literal() {
3127        // codeql[rust/hard-coded-cryptographic-value] -- test fixture literal, not a real credential
3128        let auth = NuGetAuth::new("user", "super-secret-pat");
3129        assert!(!format!("{auth:?}").contains("super-secret-pat"));
3130        assert!(!format!("{auth}").contains("super-secret-pat"));
3131
3132        let secret = RedactedSecret::new("super-secret-pat".to_string());
3133        assert!(!format!("{secret:?}").contains("super-secret-pat"));
3134        assert!(!format!("{secret}").contains("super-secret-pat"));
3135    }
3136
3137    /// FR-003: a DPAPI-encrypted `<Password>` fails closed with a distinct reason, never
3138    /// `HasCredentials`.
3139    #[test]
3140    fn test_dpapi_encrypted_password_rejected_distinctly() {
3141        let root = tempfile::tempdir().unwrap();
3142        let repo = root.path().join("repo");
3143        std::fs::create_dir_all(&repo).unwrap();
3144        let user_profile = write_user_profile(
3145            root.path(),
3146            r#"<configuration>
3147                <packageSources>
3148                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3149                </packageSources>
3150                <packageSourceCredentials>
3151                    <CorpFeed>
3152                        <add key="Username" value="user" />
3153                        <add key="Password" value="AQAAANCM...encrypted..." />
3154                    </CorpFeed>
3155                </packageSourceCredentials>
3156            </configuration>"#,
3157        );
3158        write_config(
3159            &repo,
3160            r#"<configuration><packageSources>
3161                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3162            </packageSources></configuration>"#,
3163        );
3164        let cache = NuGetConfigCache::new();
3165        let policy = all_policy();
3166        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3167
3168        assert!(config.resolved_chains().is_empty());
3169        // No `<clear/>` anywhere in the chain, so the dropped CorpFeed leaves the implicit
3170        // `nuget.org` tail reachable exactly as if nothing had been configured (NFR-004,
3171        // matching `test_disabled_source_case_insensitive_key_match`'s identical shape) — the
3172        // distinct-reason assertion is in `expand_credential`'s own unit test below instead.
3173        assert_eq!(
3174            config.resolve_source_for(&pkg("Any.Package")),
3175            DependencySource::Registry
3176        );
3177    }
3178
3179    /// FR-003: the distinct-reason assertion for a DPAPI-encrypted credential, at the
3180    /// `expand_credential` unit level (see the end-to-end test above for the resolve()-level
3181    /// fail-closed behavior).
3182    #[test]
3183    fn test_expand_credential_encrypted_password_is_distinct_reason() {
3184        let credential = RawCredential {
3185            key: "CorpFeed".to_string(),
3186            username: Some(RedactedSecret::new("user".to_string())),
3187            password: None,
3188            encrypted: true,
3189        };
3190        assert_matches!(
3191            expand_credential(&credential),
3192            Err(NuGetFeedUrlError::EncryptedPasswordUnsupported)
3193        );
3194    }
3195
3196    /// FR-004/SC-010: repo-tier `<packageSourceCredentials>` fails closed unconditionally,
3197    /// independent of any C2 binding outcome — even when the user-profile config *also*
3198    /// credentials the exact same URL.
3199    #[test]
3200    fn test_repo_tier_credential_always_fails_closed_even_with_matching_user_profile() {
3201        let root = tempfile::tempdir().unwrap();
3202        let repo = root.path().join("repo");
3203        std::fs::create_dir_all(&repo).unwrap();
3204        let user_profile = write_user_profile(root.path(), CORP_CRED_USER_PROFILE);
3205        write_config(
3206            &repo,
3207            r#"<configuration>
3208                <packageSources>
3209                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3210                </packageSources>
3211                <packageSourceCredentials>
3212                    <CorpFeed>
3213                        <add key="Username" value="repo-user" />
3214                        <add key="ClearTextPassword" value="repo-pass" />
3215                    </CorpFeed>
3216                </packageSourceCredentials>
3217            </configuration>"#,
3218        );
3219        let cache = NuGetConfigCache::new();
3220        let policy = all_policy();
3221        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3222
3223        assert!(
3224            config.resolved_chains().is_empty(),
3225            "repo-tier credentialed source must fail closed regardless of C2"
3226        );
3227    }
3228
3229    /// Issue #576: `fail_closed` must log, not silently drop the source — a user-profile
3230    /// `<packageSourceCredentials>` entry for a key with no matching `<packageSources><add>` in
3231    /// that same user-profile file fails condition (2), so the repo-declared source is dropped
3232    /// with no other observable signal anywhere (no hover `Latest`, no diagnostic).
3233    #[test]
3234    fn test_fail_closed_logs_warning_with_key_and_reason() {
3235        let root = tempfile::tempdir().unwrap();
3236        let repo = root.path().join("repo");
3237        std::fs::create_dir_all(&repo).unwrap();
3238        let user_profile = write_user_profile(
3239            root.path(),
3240            r#"<configuration>
3241                <packageSourceCredentials>
3242                    <CorpFeed>
3243                        <add key="Username" value="user" />
3244                        <add key="ClearTextPassword" value="pat" />
3245                    </CorpFeed>
3246                </packageSourceCredentials>
3247            </configuration>"#,
3248        );
3249        write_config(
3250            &repo,
3251            r#"<configuration><packageSources>
3252                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3253            </packageSources></configuration>"#,
3254        );
3255        let cache = NuGetConfigCache::new();
3256        let policy = all_policy();
3257
3258        let log = deps_core::test_util::capture_tracing_output(|| {
3259            let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3260            assert!(
3261                config.resolved_chains().is_empty(),
3262                "unresolvable credential binding must still fail the source closed"
3263            );
3264        });
3265
3266        assert!(
3267            log.contains("CorpFeed") && log.contains("packageSourceCredentials"),
3268            "expected fail-closed warning naming the source key and reason in log: {log:?}"
3269        );
3270        // S1 fix (impl-critic): this specific C2 sub-condition (2) must be distinguishable in
3271        // the log from an unrelated cause that maps to the same `HasCredentials` reason (e.g. a
3272        // plain repo-tier `<packageSourceCredentials>` declaration, asserted separately below).
3273        assert!(
3274            log.contains("NoMatchingUserProfileAdd"),
3275            "expected the specific C2 sub-condition cause in log: {log:?}"
3276        );
3277    }
3278
3279    /// S1 fix (impl-critic): a repo-tier `<packageSourceCredentials>` declaration and issue
3280    /// #576's user-profile-condition-(2) repro (tested above) both resolve to the same
3281    /// `NuGetFeedUrlError::HasCredentials` reason, but must log a different `cause` — proving
3282    /// the two are no longer byte-identical in the log.
3283    #[test]
3284    fn test_fail_closed_repo_tier_and_c2_causes_are_distinguishable() {
3285        let root = tempfile::tempdir().unwrap();
3286        let repo = root.path().join("repo");
3287        std::fs::create_dir_all(&repo).unwrap();
3288        write_config(
3289            &repo,
3290            r#"<configuration>
3291                <packageSources>
3292                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3293                </packageSources>
3294                <packageSourceCredentials>
3295                    <CorpFeed>
3296                        <add key="Username" value="repo-user" />
3297                        <add key="ClearTextPassword" value="repo-pass" />
3298                    </CorpFeed>
3299                </packageSourceCredentials>
3300            </configuration>"#,
3301        );
3302        let cache = NuGetConfigCache::new();
3303        let policy = all_policy();
3304
3305        let log = deps_core::test_util::capture_tracing_output(|| {
3306            let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3307        });
3308
3309        assert!(
3310            log.contains("RepoTierCredentialed"),
3311            "expected the repo-tier cause, distinct from NoMatchingUserProfileAdd, in log: {log:?}"
3312        );
3313        assert!(
3314            !log.contains("NoMatchingUserProfileAdd"),
3315            "repo-tier cause must not be conflated with the C2 sub-condition cause: {log:?}"
3316        );
3317    }
3318
3319    /// S2 fix (impl-critic, issue #576 follow-up): the fail-closed warning must debounce to
3320    /// once per distinct config state, not once per `resolve_with_context` call (e.g. every LSP
3321    /// `did_change` re-parse against unchanged `NuGet.Config` content).
3322    #[test]
3323    fn test_fail_closed_warning_debounced_across_repeat_resolves() {
3324        let root = tempfile::tempdir().unwrap();
3325        let repo = root.path().join("repo");
3326        std::fs::create_dir_all(&repo).unwrap();
3327        let config_path = repo.join("NuGet.Config");
3328        write_config(
3329            &repo,
3330            r#"<configuration>
3331                <packageSources>
3332                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3333                </packageSources>
3334                <packageSourceCredentials>
3335                    <CorpFeed>
3336                        <add key="Username" value="repo-user" />
3337                        <add key="ClearTextPassword" value="repo-pass" />
3338                    </CorpFeed>
3339                </packageSourceCredentials>
3340            </configuration>"#,
3341        );
3342        let cache = NuGetConfigCache::new();
3343        let policy = all_policy();
3344
3345        let log = deps_core::test_util::capture_tracing_output(|| {
3346            for _ in 0..4 {
3347                let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3348            }
3349
3350            // C1/S2 fix (impl-critic): a genuine content change (distinguishable mtime) must
3351            // re-trigger the warning exactly once more, not once per subsequent resolve —
3352            // proving the debounce tracks config *content* rather than suppressing the warning
3353            // forever once fired. Mirrors deps-go's
3354            // `test_goenv_oversized_goprivate_warning_debounced_across_resolves` precedent.
3355            //
3356            // M1 fix (impl-critic follow-up): the source `key` (`CorpFeed`) and `cause`
3357            // (`RepoTierCredentialed`) are held deliberately constant across the change — only
3358            // the `value=` URL differs. `fail_closed`'s dedup hash also includes `entry.key`
3359            // and `cause` independently of `config_fingerprint`, so changing the key here too
3360            // (as an earlier version of this test did) would pass even against a broken,
3361            // pointer-identity-based fingerprint that never changes on real content edits — the
3362            // key change alone would already produce a fresh hash. Holding everything else
3363            // constant makes `config_fingerprint` the *only* thing that can distinguish the two
3364            // rounds, so this test actually regression-guards the C1 fix.
3365            let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
3366            write_config(
3367                &repo,
3368                r#"<configuration>
3369                    <packageSources>
3370                        <add key="CorpFeed" value="https://corp.example/v3/index-v2.json" />
3371                    </packageSources>
3372                    <packageSourceCredentials>
3373                        <CorpFeed>
3374                            <add key="Username" value="repo-user" />
3375                            <add key="ClearTextPassword" value="repo-pass" />
3376                        </CorpFeed>
3377                    </packageSourceCredentials>
3378                </configuration>"#,
3379            );
3380            std::fs::OpenOptions::new()
3381                .write(true)
3382                .open(&config_path)
3383                .unwrap()
3384                .set_modified(future)
3385                .unwrap();
3386
3387            for _ in 0..2 {
3388                let _ = resolve_ctx(&repo, &cache, &policy, None, false);
3389            }
3390        });
3391
3392        assert_eq!(
3393            log.matches("fails closed on credential binding").count(),
3394            2,
3395            "expected one warning for the original content and one more after a genuine \
3396             content change: {log:?}"
3397        );
3398    }
3399
3400    /// M1 fix (impl-critic follow-up): a machine-disabled source (`<disabledPackageSources>`)
3401    /// must still fail closed (unchanged behavior), but per S3 must log at `debug!`, not
3402    /// `warn!`. Captured at `DEBUG` (not the vacuous `INFO`-only capture, under which a
3403    /// `debug!` line is invisible regardless of whether the code is correct) so this test can
3404    /// positively assert the message fired, at the right level, rather than merely asserting
3405    /// its absence at a level that would hide it either way.
3406    #[test]
3407    fn test_disabled_source_fails_closed_at_debug_level_not_warn() {
3408        let root = tempfile::tempdir().unwrap();
3409        let repo = root.path().join("repo");
3410        std::fs::create_dir_all(&repo).unwrap();
3411        write_config(
3412            &repo,
3413            r#"<configuration>
3414                <packageSources>
3415                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3416                </packageSources>
3417                <disabledPackageSources>
3418                    <add key="CorpFeed" value="true" />
3419                </disabledPackageSources>
3420            </configuration>"#,
3421        );
3422        let cache = NuGetConfigCache::new();
3423        let policy = all_policy();
3424
3425        let log = deps_core::test_util::capture_tracing_output_at(tracing::Level::DEBUG, || {
3426            let config = resolve_ctx(&repo, &cache, &policy, None, false);
3427            assert_eq!(
3428                config.resolve_source_for(&pkg("CorpFeed.Package")),
3429                DependencySource::Registry,
3430                "a disabled-but-not-cleared source still leaves the implicit nuget.org tail reachable"
3431            );
3432        });
3433
3434        let line = log
3435            .lines()
3436            .find(|l| l.contains("fails closed on credential binding"))
3437            .unwrap_or_else(|| panic!("expected a fail-closed log line at DEBUG level: {log:?}"));
3438        assert!(
3439            line.contains("MachineDisabled"),
3440            "expected the MachineDisabled cause on the fail-closed line: {line}"
3441        );
3442        assert!(
3443            line.contains("DEBUG"),
3444            "MachineDisabled must log at debug!: {line}"
3445        );
3446        assert!(
3447            !line.contains("WARN"),
3448            "MachineDisabled must not log at warn!: {line}"
3449        );
3450    }
3451
3452    /// M1 fix (impl-critic follow-up): a DPAPI-encrypted `<Password>` must not double-log —
3453    /// `expand_credential` already emits its own `debug!` for this case, so `fail_closed` must
3454    /// not also emit a line for the same event, at any level. Captured at `DEBUG` so both the
3455    /// expected `debug!` line's presence and `fail_closed`'s line's absence are meaningfully
3456    /// asserted (at the old `INFO`-only capture, both would be invisible regardless of whether
3457    /// `fail_closed` incorrectly emitted a second `debug!`).
3458    #[test]
3459    fn test_dpapi_encrypted_password_does_not_double_log() {
3460        let root = tempfile::tempdir().unwrap();
3461        let repo = root.path().join("repo");
3462        std::fs::create_dir_all(&repo).unwrap();
3463        let user_profile = write_user_profile(
3464            root.path(),
3465            r#"<configuration>
3466                <packageSources>
3467                    <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3468                </packageSources>
3469                <packageSourceCredentials>
3470                    <CorpFeed>
3471                        <add key="Username" value="user" />
3472                        <add key="Password" value="AQAAANCM...encrypted..." />
3473                    </CorpFeed>
3474                </packageSourceCredentials>
3475            </configuration>"#,
3476        );
3477        write_config(
3478            &repo,
3479            r#"<configuration><packageSources>
3480                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3481            </packageSources></configuration>"#,
3482        );
3483        let cache = NuGetConfigCache::new();
3484        let policy = all_policy();
3485
3486        let log = deps_core::test_util::capture_tracing_output_at(tracing::Level::DEBUG, || {
3487            let _ = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3488        });
3489
3490        assert_eq!(
3491            log.matches("DPAPI-encrypted <Password> is not supported")
3492                .count(),
3493            1,
3494            "expected exactly one debug! from expand_credential: {log:?}"
3495        );
3496        assert!(
3497            !log.contains("fails closed on credential binding"),
3498            "fail_closed must not double-log the DPAPI case at any level: {log:?}"
3499        );
3500    }
3501
3502    /// SC-012/FR-008: a user-profile credential named for `nuget.org` never attaches to, nor
3503    /// blocks, a repo entry resolving to the real public index.
3504    #[test]
3505    fn test_public_index_carve_out_never_blocks_or_authenticates() {
3506        let root = tempfile::tempdir().unwrap();
3507        let repo = root.path().join("repo");
3508        std::fs::create_dir_all(&repo).unwrap();
3509        let user_profile = write_user_profile(
3510            root.path(),
3511            r#"<configuration>
3512                <packageSources>
3513                    <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
3514                </packageSources>
3515                <packageSourceCredentials>
3516                    <nuget.org>
3517                        <add key="Username" value="user" />
3518                        <add key="ClearTextPassword" value="upstream-pat" />
3519                    </nuget.org>
3520                </packageSourceCredentials>
3521            </configuration>"#,
3522        );
3523        write_config(
3524            &repo,
3525            r#"<configuration><packageSources>
3526                <add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
3527            </packageSources></configuration>"#,
3528        );
3529        let cache = NuGetConfigCache::new();
3530        let policy = all_policy();
3531        let config = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3532
3533        // Resolves to plain `Registry` (public index), never `HasCredentials`.
3534        assert_eq!(
3535            config.resolve_source_for(&pkg("Newtonsoft.Json")),
3536            DependencySource::Registry
3537        );
3538    }
3539
3540    /// SC-005/NFR-005: with the flag off, a user-profile file's `<clear/>`/
3541    /// `<packageSourceMapping>`/`<disabledPackageSources>` produce byte-identical
3542    /// `valid_hops`/routing to a run with no user-profile file at all.
3543    #[test]
3544    fn test_flag_off_user_profile_routing_directives_have_zero_effect() {
3545        let root = tempfile::tempdir().unwrap();
3546        let repo = root.path().join("repo");
3547        std::fs::create_dir_all(&repo).unwrap();
3548        write_config(
3549            &repo,
3550            r#"<configuration><packageSources>
3551                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3552            </packageSources></configuration>"#,
3553        );
3554        let user_profile = write_user_profile(
3555            root.path(),
3556            r#"<configuration>
3557                <packageSources>
3558                    <clear />
3559                    <add key="EvilFeed" value="https://evil.example/v3/index.json" />
3560                </packageSources>
3561                <disabledPackageSources>
3562                    <add key="CorpFeed" value="true" />
3563                </disabledPackageSources>
3564                <packageSourceMapping>
3565                    <packageSource key="EvilFeed">
3566                        <package pattern="*" />
3567                    </packageSource>
3568                </packageSourceMapping>
3569            </configuration>"#,
3570        );
3571        let cache = NuGetConfigCache::new();
3572        let policy = all_policy();
3573
3574        let with_profile = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3575        let without_profile = resolve_ctx(&repo, &cache, &policy, None, false);
3576
3577        let hops_of = |c: &NuGetConfig| -> Vec<String> {
3578            c.valid_hops()
3579                .into_iter()
3580                .map(|h| h.url.as_str().to_string())
3581                .collect()
3582        };
3583        assert_eq!(hops_of(&with_profile), hops_of(&without_profile));
3584        assert_eq!(
3585            with_profile.resolve_source_for(&pkg("Any.Package")),
3586            without_profile.resolve_source_for(&pkg("Any.Package"))
3587        );
3588    }
3589
3590    /// SC-005/US-005: with the flag on, a user-profile-only `<add>` (no repo `NuGet.Config`
3591    /// declaring it) becomes an `AlternateRegistry` routing hop.
3592    #[test]
3593    fn test_flag_on_user_profile_only_source_becomes_routing_hop() {
3594        let root = tempfile::tempdir().unwrap();
3595        let repo = root.path().join("repo");
3596        std::fs::create_dir_all(&repo).unwrap();
3597        let user_profile = write_user_profile(
3598            root.path(),
3599            r#"<configuration><packageSources>
3600                <add key="CorpFeed" value="https://corp.example/v3/index.json" />
3601            </packageSources></configuration>"#,
3602        );
3603        let cache = NuGetConfigCache::new();
3604        let policy = all_policy();
3605
3606        let flag_off = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), false);
3607        assert!(flag_off.resolved_chains().is_empty());
3608
3609        let flag_on = resolve_ctx(&repo, &cache, &policy, Some(&user_profile), true);
3610        assert_matches!(
3611            flag_on.resolve_source_for(&pkg("Any.Package")),
3612            DependencySource::AlternateRegistry { .. }
3613        );
3614    }
3615
3616    /// FR-009: the non-transitive key-aliasing counterexample from the critic review —
3617    /// `"Corp_x005f_x0020_Feed"` and `"Corp Feed"` don't overlap each other directly, but both
3618    /// overlap `"Corp_x0020_Feed"`. `unique_overlap` must resolve this as ambiguous (>=2
3619    /// matches), not silently pick one.
3620    #[test]
3621    fn test_unique_overlap_non_transitive_aliasing_is_ambiguous() {
3622        let policy = all_policy();
3623        let items = [
3624            source(
3625                "Corp_x005f_x0020_Feed",
3626                "https://a.example/v3/index.json",
3627                &policy,
3628            ),
3629            source("Corp Feed", "https://b.example/v3/index.json", &policy),
3630        ];
3631        assert!(unique_overlap("Corp_x0020_Feed", &items, |s| s.key.as_str()).is_none());
3632    }
3633}