Skip to main content

deps_go/
config.rs

1//! `$GOENV` discovery and `GOPROXY`/`GOPRIVATE` resolution.
2//!
3//! Go persists `go env -w`-set variables in a single `KEY=VALUE` file (`$GOENV`, defaulting to
4//! `os.UserConfigDir()/go/env`) rather than a project manifest — unlike Cargo/npm/PyPI, whose
5//! private-registry config lives inside or alongside the workspace being parsed. This module
6//! resolves that file once per process (memoized, mtime-gated — see [`GoEnvCache`]) into a
7//! [`GoEnvConfig`] consulted by every `go.mod` parse.
8//!
9//! # Security model (read before touching this module)
10//!
11//! `$GOENV` is a process-wide, user-owned file, not workspace-controlled content — but the
12//! resolved `GOPROXY` chain still names hosts a cloned repository's dependencies get resolved
13//! against, so the same discipline Cargo/npm/PyPI apply carries over:
14//!
15//! - **No credential-shaped value is ever parsed** (FR-014/NFR-001). [`GoProxyUrl::new`]
16//!   rejects any URL carrying `username()`/`password()` outright — there is no `${VAR}`
17//!   expansion step for `$GOENV` (unlike npm's `.npmrc`), so [`InvalidEntry::raw`] and every
18//!   `tracing::warn!` here name the as-written value with any embedded userinfo redacted first
19//!   (see [`deps_core::net_policy::redact_userinfo`]).
20//! - **FR-009's per-hop fail-closed rule is the load-bearing security invariant.** An invalid
21//!   `GOPROXY` hop is dropped when other valid hops remain; only when every hop is invalid does
22//!   the whole chain fail closed to [`deps_core::parser::DependencySource::CustomRegistry`].
23//!   Neither case ever falls back to `proxy.golang.org` — see
24//!   [`GoEnvConfig::resolve_source_for`].
25//! - **FR-008's `GOPRIVATE` bypass never reaches a configured proxy hop at all** — a module
26//!   whose path matches a `GOPRIVATE` glob resolves straight to the `direct` terminal hop,
27//!   regardless of what `GOPROXY` is configured to. See [`GoEnvConfig::resolve_source_for`].
28//!
29//! See `specs/034-go-goproxy-private-registry/spec.md` FR-001–FR-016 for the design this module
30//! implements.
31
32use std::path::{Path, PathBuf};
33use std::sync::{Arc, OnceLock};
34
35use deps_core::net_policy::{
36    PolicyGate, RegistryAccessPolicy, redact_userinfo, validate_index_url,
37};
38use deps_core::parser::DependencySource;
39
40/// Why a candidate `GOPROXY` hop URL failed [`GoProxyUrl::new`]'s validation.
41///
42/// An alias of the shared [`deps_core::net_policy::IndexUrlError`] — see that type's docs for
43/// the variants and their wording.
44pub use deps_core::net_policy::IndexUrlError as GoProxyUrlError;
45
46/// A validated, normalized, https-only Go module proxy URL with no embedded userinfo.
47///
48/// Mirrors `deps_pypi::config::PypiIndexUrl`/`deps_npm::config::NpmRegistryIndex`; kept
49/// `deps-go`-local rather than promoted to `deps-core` per this spec's Open Questions
50/// (consolidate only once a fourth near-identical implementation makes the duplication
51/// concrete).
52#[derive(Debug, Clone, PartialEq, Eq, Hash)]
53pub struct GoProxyUrl {
54    /// The validated URL, normalized by stripping a trailing `/` — matches the
55    /// `{base}/{module}/@v/...` join convention `crate::registry` already uses for
56    /// `PROXY_BASE`.
57    normalized: String,
58}
59
60impl GoProxyUrl {
61    /// Validates and normalizes `raw` against `policy`.
62    ///
63    /// # Errors
64    ///
65    /// Returns [`GoProxyUrlError`] if `raw` does not parse as a URL, is not `https` (outside
66    /// the `cfg(test)`/`test-util` loopback carve-out), carries a userinfo component, or
67    /// resolves to a host class the current `policy` blocks.
68    ///
69    /// # Examples
70    ///
71    /// ```
72    /// use deps_core::net_policy::RegistryAccessPolicy;
73    /// use deps_go::config::GoProxyUrl;
74    ///
75    /// let policy = RegistryAccessPolicy::default();
76    /// assert!(GoProxyUrl::new("https://goproxy.mycorp.example", &policy).is_ok());
77    /// assert!(GoProxyUrl::new("http://goproxy.mycorp.example", &policy).is_err());
78    /// assert!(GoProxyUrl::new("https://user:pass@goproxy.mycorp.example", &policy).is_err());
79    /// ```
80    pub fn new(raw: &str, policy: &RegistryAccessPolicy) -> Result<Self, GoProxyUrlError> {
81        let url = validate_index_url(raw, raw, "go", PolicyGate::Enforce(policy))?;
82        // F3 (spec 034 review): every request URL is built by appending
83        // `/{module}/@v/...`/`/{module}/@latest` after this normalized base
84        // (`crate::registry::versions_list_url_at` and friends) — a hop carrying a query
85        // string or fragment has no well-defined append point (`https://host/?tok=x` would
86        // silently become `https://host/?tok=x/github.com/.../@v/list`, an entirely
87        // different — and likely 404ing — request than intended), so it is rejected here
88        // rather than joined incorrectly. `InvalidUrl` is the closest existing
89        // `GoProxyUrlError` variant (no `deps-core` change for a Go-only validation rule).
90        if url.query().is_some() || url.fragment().is_some() {
91            return Err(GoProxyUrlError::InvalidUrl(redact_userinfo(raw)));
92        }
93        let normalized = url.as_str().trim_end_matches('/').to_string();
94        Ok(Self { normalized })
95    }
96
97    /// The normalized proxy URL. Never carries a trailing `/`.
98    #[must_use]
99    pub fn as_str(&self) -> &str {
100        &self.normalized
101    }
102}
103
104impl std::fmt::Display for GoProxyUrl {
105    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        f.write_str(self.as_str())
107    }
108}
109
110/// One `GOPROXY` chain entry (FR-002): either a validated proxy URL, or one of the two
111/// sentinel values `go help goproxy` defines.
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub enum GoProxyHop {
114    /// A validated, fetchable proxy host.
115    Url(GoProxyUrl),
116    /// `direct`: resolve straight from the VCS host — phase 1 has no direct-VCS resolution
117    /// mechanism (see this crate's module docs / spec FR-006), so this is a fail-closed
118    /// terminal hop that never issues a network request.
119    Direct,
120    /// `off`: disallow all downloads for the affected module (FR-004) — a fail-closed
121    /// terminal hop, identical in observable behavior to [`Self::Direct`].
122    Off,
123}
124
125/// A present-but-unusable `GOPROXY` hop — an invalid URL or a policy-blocked host (FR-009).
126///
127/// Carries the raw value as written, **with any embedded userinfo redacted**, so
128/// [`GoEnvConfig::resolve_source_for`] can build a
129/// [`DependencySource::CustomRegistry`] when every hop in a chain is invalid, or log a warning
130/// naming a dropped hop, without ever holding or surfacing the credential itself.
131#[derive(Debug, Clone)]
132pub struct InvalidEntry {
133    /// The raw `GOPROXY` hop value, as written in `$GOENV`, with any `user:pass@`/`user@`
134    /// userinfo component stripped.
135    pub raw: String,
136    /// Why it was rejected.
137    pub reason: GoProxyUrlError,
138}
139
140/// Parses and validates one `,`-or-`|`-separated `GOPROXY` chain entry (FR-002), logging a
141/// `tracing::warn!` naming the raw value (userinfo redacted) on failure.
142fn parse_hop(raw: &str, policy: &RegistryAccessPolicy) -> Result<GoProxyHop, InvalidEntry> {
143    match raw {
144        "direct" => Ok(GoProxyHop::Direct),
145        "off" => Ok(GoProxyHop::Off),
146        _ => GoProxyUrl::new(raw, policy)
147            .map(GoProxyHop::Url)
148            .map_err(|reason| {
149                let redacted = redact_userinfo(raw);
150                tracing::warn!(raw = %redacted, %reason, "GOPROXY hop failed validation");
151                InvalidEntry {
152                    raw: redacted,
153                    reason,
154                }
155            }),
156    }
157}
158
159/// Which fallback rule governs a `GOPROXY` chain hop's failure (spec 034 S2).
160///
161/// `go help goproxy` and Go's own `modfetch/proxy.go` give `,` and `|` genuinely different
162/// semantics — this crate's `,`-and-`|`-both-fall-through-on-not-found first cut collapsed
163/// that distinction; see [`GoRegistry::get_versions_chained`](crate::registry::GoRegistry)
164/// (registry.rs) for where this is consulted.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum ChainSeparator {
167    /// `,`: fall through to the next hop only on an explicit not-found response (`404`/`410`)
168    /// — a transport failure (timeout, connection error, 5xx) is terminal for the whole
169    /// chain.
170    NotFoundOnly,
171    /// `|`: fall through to the next hop on *any* error, including a transport failure.
172    AnyError,
173}
174
175impl ChainSeparator {
176    /// Stable string form for [`GoProxyChain::keyed`]'s hash — deliberately independent of
177    /// [`Debug`](std::fmt::Debug)/the variant name, so renaming a variant (a cosmetic,
178    /// no-compiler-signal change) never silently changes every `go-proxy` chain key.
179    const fn as_key_str(self) -> &'static str {
180        match self {
181            Self::NotFoundOnly => "not-found-only",
182            Self::AnyError => "any-error",
183        }
184    }
185}
186
187/// One fully-resolved, ready-to-register `GOPROXY` chain — produced by
188/// [`GoEnvConfig::goproxy_chain`], consumed by `GoRegistry::register_chain`.
189#[derive(Debug, Clone, Default)]
190pub struct GoProxyChain {
191    /// Opaque, composite identity — becomes both the router's `alternates` map key and the
192    /// `DependencySource::AlternateRegistry.index` value. A hashed token produced by
193    /// [`deps_core::hash_routing_key`] (`"go-proxy"`) over the ordered hop values, mirroring
194    /// `deps_pypi::config::ResolvedChain::key`'s "opaque routing key" widening of
195    /// `AlternateRegistry.index`'s contract.
196    pub key: String,
197    /// Ordered, already-validated hops (FR-002/FR-005 declaration order preserved). Never
198    /// empty — see [`GoEnvConfig::goproxy_chain`]'s zero-hop handling. Truncated after the
199    /// first [`GoProxyHop::Direct`]/[`GoProxyHop::Off`] entry: both are terminal by
200    /// definition (FR-004/FR-006), so any hop declared after one is unreachable and dropped
201    /// at parse time rather than carried into the registered chain.
202    pub hops: Vec<GoProxyHop>,
203    /// `separators[i]` is the fallback rule governing the transition from `hops[i]` to
204    /// `hops[i + 1]` (spec 034 S2) — `hops.len().saturating_sub(1)` entries when produced by
205    /// `parse_goproxy`. A shorter (or empty, `Default`) vector — every hand-constructed test
206    /// chain, and the single-hop `GOPRIVATE`-bypass chain — defaults every unspecified
207    /// transition to [`ChainSeparator::NotFoundOnly`], preserving this feature's original
208    /// (comma-only) behavior.
209    pub separators: Vec<ChainSeparator>,
210}
211
212impl GoProxyChain {
213    fn keyed(hops: Vec<GoProxyHop>, separators: Vec<ChainSeparator>) -> Self {
214        let hop_parts = hops.iter().map(|hop| match hop {
215            GoProxyHop::Url(url) => url.as_str(),
216            GoProxyHop::Direct => "direct",
217            GoProxyHop::Off => "off",
218        });
219        let sep_parts = separators.iter().map(|sep| sep.as_key_str());
220        let key = deps_core::hash_routing_key("go-proxy", hop_parts.chain(sep_parts));
221        Self {
222            key,
223            hops,
224            separators,
225        }
226    }
227}
228
229/// Fixed routing key for the `GOPRIVATE`-bypass chain (FR-008).
230///
231/// A single [`GoProxyHop::Direct`] hop, registered once whenever `$GOENV` declares any
232/// `GOPRIVATE` pattern, regardless of what (if anything) `GOPROXY` is configured to. Content
233/// never varies, so a fixed key (rather than a hash) is sufficient and stable across
234/// re-parses.
235pub const GOPRIVATE_CHAIN_KEY: &str = "go-private:direct";
236
237/// Parses a raw `GOPROXY` value (FR-002) into either a non-empty [`GoProxyChain`], or — when
238/// every declared hop turned out invalid — the first [`InvalidEntry`] encountered, so the
239/// caller can fail the whole chain closed to `CustomRegistry` (FR-009) rather than silently
240/// falling back to the default public chain.
241///
242/// Tracks which separator (`,`/`|`) preceded each entry (spec 034 S2) so the resulting
243/// [`GoProxyChain::separators`] preserves Go's own distinction between the two — a manual
244/// scan rather than `str::split([',', '|'])`, which would discard exactly that information.
245///
246/// When one or more invalid entries (FR-009) are dropped between two surviving hops, the
247/// separators spanning them are merged rather than the surviving-hop-adjacent one silently
248/// winning: the more permissive separator (`AnyError`/`|`) wins the merged transition (issue
249/// #564). E.g. `a|invalid,c` records `|` (the separator the user wrote before the dropped
250/// entry), not `,` (the separator that happened to follow it) — a user-written `|` must never
251/// be silently downgraded to `,` just because the entry it preceded turned out invalid.
252fn parse_goproxy(raw: &str, policy: &RegistryAccessPolicy) -> Result<GoProxyChain, InvalidEntry> {
253    let mut hops: Vec<GoProxyHop> = Vec::new();
254    let mut separators: Vec<ChainSeparator> = Vec::new();
255    let mut first_invalid: Option<InvalidEntry> = None;
256    // The separator(s) spanning every entry seen since the last surviving hop (or the start
257    // of the chain) — `None` until the first separator is seen. When this spans one or more
258    // dropped invalid entries, it accumulates via most-permissive-wins (`AnyError` beats
259    // `NotFoundOnly`) rather than being overwritten by the latest separator seen.
260    let mut pending_sep: Option<ChainSeparator> = None;
261
262    let mut remaining = raw;
263    loop {
264        let (entry, trailing_sep, rest) = match remaining.find([',', '|']) {
265            Some(idx) => {
266                let sep = if remaining.as_bytes()[idx] == b'|' {
267                    ChainSeparator::AnyError
268                } else {
269                    ChainSeparator::NotFoundOnly
270                };
271                (&remaining[..idx], Some(sep), &remaining[idx + 1..])
272            }
273            None => (remaining, None, ""),
274        };
275
276        let trimmed = entry.trim();
277        if !trimmed.is_empty() {
278            match parse_hop(trimmed, policy) {
279                Ok(hop) => {
280                    let terminal = matches!(hop, GoProxyHop::Direct | GoProxyHop::Off);
281                    if !hops.is_empty() {
282                        separators.push(pending_sep.unwrap_or(ChainSeparator::NotFoundOnly));
283                    }
284                    // Fresh start for the transition leading out of this surviving hop.
285                    pending_sep = None;
286                    hops.push(hop);
287                    if terminal {
288                        // FR-004/FR-006: everything after a terminal hop is unreachable.
289                        break;
290                    }
291                }
292                Err(invalid) => {
293                    if first_invalid.is_none() {
294                        first_invalid = Some(invalid);
295                    }
296                }
297            }
298        }
299
300        let Some(sep) = trailing_sep else {
301            break;
302        };
303        pending_sep = Some(match pending_sep {
304            Some(ChainSeparator::AnyError) => ChainSeparator::AnyError,
305            _ => sep,
306        });
307        remaining = rest;
308    }
309
310    if hops.is_empty() {
311        Err(first_invalid.unwrap_or_else(|| InvalidEntry {
312            raw: redact_userinfo(raw),
313            reason: GoProxyUrlError::InvalidUrl(redact_userinfo(raw)),
314        }))
315    } else {
316        Ok(GoProxyChain::keyed(hops, separators))
317    }
318}
319
320/// Upper bound on a single `GOPRIVATE` glob pattern's length (spec 034 perf fix). `$GOENV` is
321/// not length-limited elsewhere, and a legitimate module-path-prefix glob has no reason to
322/// approach this size — an oversized pattern is treated the same as a malformed one (never
323/// compiled, never matches) rather than rejected with an error, matching the existing
324/// "malformed pattern never panics, just doesn't match" contract.
325const MAX_GLOB_PATTERN_LENGTH: usize = 256;
326
327/// One compiled `GOPRIVATE` glob pattern token — see `compile_glob`.
328#[derive(Debug, Clone, PartialEq, Eq)]
329enum GlobToken {
330    /// `*`: matches any run of zero or more non-`/` characters.
331    Star,
332    /// `?`: matches exactly one non-`/` character.
333    Any,
334    /// A literal character (including a literal `/`, the segment separator).
335    Literal(char),
336    /// `[...]`/`[^...]`: matches exactly one character against a set of literals/ranges.
337    Class {
338        negate: bool,
339        entries: Vec<ClassEntry>,
340    },
341}
342
343/// One entry inside a `[...]` character class.
344#[derive(Debug, Clone, PartialEq, Eq)]
345enum ClassEntry {
346    Char(char),
347    Range(char, char),
348}
349
350impl GlobToken {
351    /// Whether this (non-[`Self::Star`]) token matches `c`. Panics if called on `Star` — the
352    /// caller (`tokens_match`) always handles `Star` separately before reaching this.
353    fn matches_char(&self, c: char) -> bool {
354        match self {
355            Self::Star => unreachable!("Star is matched by the caller, never directly"),
356            Self::Any => c != '/',
357            Self::Literal(l) => *l == c,
358            Self::Class { negate, entries } => {
359                let matched = entries.iter().any(|e| match e {
360                    ClassEntry::Char(ch) => *ch == c,
361                    ClassEntry::Range(lo, hi) => (*lo..=*hi).contains(&c),
362                });
363                matched != *negate
364            }
365        }
366    }
367}
368
369/// One `GOPRIVATE`/`GONOPROXY`-style glob pattern (FR-007).
370///
371/// Matched against a module path per Go's own `GlobsMatchPath` + `path.Match` semantics (`go
372/// help goprivate`): only the pattern's own number of `/`-separated elements is compared
373/// against the module path's leading elements, then matched with shell-glob syntax (`*`, `?`,
374/// `[...]`/`[^...]`) that never crosses a `/`.
375///
376/// Compiles its pattern once at construction into a token sequence, matched by the iterative
377/// `tokens_match` rather than naive recursive backtracking — a naive backtracking
378/// implementation is exponential (`O(2^n)`) on an adversarial pattern like many consecutive
379/// `*`s against a non-matching text (spec 034 perf review finding: 15 stars took ~19s, 20
380/// timed out); the token/iterative-pointer approach used here is `O(pattern length * matched
381/// text length)`, the same bound the classic "wildcard matching" two-pointer algorithm gives.
382#[derive(Debug, Clone, PartialEq, Eq)]
383pub struct GlobPattern {
384    /// The pattern as written — used only to count `/`-separated elements for the
385    /// segment-truncation step in [`Self::matches`]; matching itself always goes through
386    /// `Self::tokens`.
387    raw: String,
388    /// Precompiled once at construction (see `compile_glob`). `None` for a malformed
389    /// (unterminated `[` character class, issue #568; or a trailing unescaped `\`) or
390    /// oversized (see `MAX_GLOB_PATTERN_LENGTH`) pattern — never matches any text, mirroring
391    /// `path.Match`'s own `ErrBadPattern` degrading to "no match". A reversed `[lo-hi]`
392    /// character-class range (`lo > hi`, issue #570) does **not** invalidate the whole
393    /// pattern — verified empirically against go1.25.5's own `path.Match`, which returns
394    /// `err == nil` for a reversed range and treats it as an always-empty (never-matching)
395    /// range while the rest of the class and pattern are still evaluated normally; `tokens`
396    /// stays `Some(...)` in that case, only a `tracing::warn!` fires for observability.
397    tokens: Option<Vec<GlobToken>>,
398}
399
400impl GlobPattern {
401    /// Wraps and compiles `raw` — no validation surfaced to the caller, matching
402    /// `path.Match`'s own behavior (a malformed or oversized pattern simply never matches, per
403    /// `Self::tokens`'s doc). Rejection paths log a `tracing::warn!`: an oversized pattern
404    /// (spec 034 follow-up F6, issue #559) here, and a malformed pattern (unterminated `[`
405    /// character class, issue #568) inside `compile_glob` — both invalidate the whole pattern.
406    /// A reversed `[lo-hi]` range (issue #570) also logs a `tracing::warn!` inside
407    /// `compile_glob`, but — matching Go's own `path.Match` — does not invalidate the
408    /// pattern; see `Self::tokens`'s doc. Unlike a malformed `GOPROXY` hop, a `GOPRIVATE`
409    /// pattern that never matches fails **open** on confidentiality (the module it should
410    /// have hidden from the public proxy routes there instead), so these cases must be
411    /// visible rather than silent.
412    #[must_use]
413    pub fn new(raw: &str) -> Self {
414        let tokens = if raw.len() > MAX_GLOB_PATTERN_LENGTH {
415            tracing::warn!(
416                pattern_length = raw.len(),
417                max_length = MAX_GLOB_PATTERN_LENGTH,
418                "GOPRIVATE pattern exceeds max length; it will never match, so affected modules \
419                 route to the public proxy instead of being treated as private"
420            );
421            None
422        } else {
423            compile_glob(raw)
424        };
425        Self {
426            raw: raw.to_string(),
427            tokens,
428        }
429    }
430
431    /// Whether `module_path` matches this pattern (FR-008).
432    ///
433    /// # Examples
434    ///
435    /// ```
436    /// use deps_go::config::GlobPattern;
437    ///
438    /// let pattern = GlobPattern::new("git.mycorp.example/*");
439    /// assert!(pattern.matches("git.mycorp.example/internal/auth"));
440    /// assert!(!pattern.matches("github.com/other/repo"));
441    /// ```
442    #[must_use]
443    pub fn matches(&self, module_path: &str) -> bool {
444        let Some(tokens) = &self.tokens else {
445            return false;
446        };
447        let elements = self.raw.matches('/').count() + 1;
448        let mut slashes_seen = 0usize;
449        let mut cut_at = None;
450        for (i, b) in module_path.bytes().enumerate() {
451            if b == b'/' {
452                slashes_seen += 1;
453                if slashes_seen == elements {
454                    cut_at = Some(i);
455                    break;
456                }
457            }
458        }
459        let prefix = match cut_at {
460            Some(i) => &module_path[..i],
461            None if slashes_seen + 1 == elements => module_path,
462            None => return false,
463        };
464        let text: Vec<char> = prefix.chars().collect();
465        tokens_match(tokens, &text)
466    }
467}
468
469/// Compiles `pattern` (Go's `path.Match` glob syntax) into a token sequence for
470/// `tokens_match`. Returns `None` for an unterminated `[...]` character class (issue #568) or
471/// a trailing unescaped `\` — the whole pattern is then permanently non-matching (see
472/// `GlobPattern::tokens`'s doc), the same outcome the old recursive matcher produced for the
473/// unterminated-class case (it just failed the match at that point instead of failing to
474/// compile). Also logs a `tracing::warn!` in each case, for the same
475/// fail-open-on-confidentiality reason `GlobPattern::new`'s oversized-pattern guard already
476/// logs one — these rejection paths previously failed silently.
477///
478/// A reversed `[lo-hi]` range where `lo > hi` (issue #570 — e.g. `[c-a]`) does **not** reject
479/// the pattern: empirically, go1.25.5's own `path.Match` returns `err == nil` for a reversed
480/// range too, parsing it as an always-empty range (no character ever satisfies `lo <= c <=
481/// hi`) while the rest of the class — other entries, negation — is evaluated normally (e.g.
482/// `[^c-a]` still matches every character, since its one, always-empty, entry never matches).
483/// Rust's own `(lo..=hi).contains` is equally empty for `lo > hi`, so `matches_char` already
484/// gets this right without special-casing; `compile_glob` only adds a `tracing::warn!` here
485/// for observability. A degenerate `lo == hi` single-char range (e.g. `[a-a]`) is valid and
486/// unaffected.
487///
488/// A `\` escapes the next character, matching it literally (`path.Match` semantics) — both
489/// outside a class (this loop) and inside one (`read_class_char`, so `\]`, `\-`, `\\` etc.
490/// work as literals rather than a class terminator, range separator, or bare backslash). This
491/// also fixes a #568 side effect where `repo\[x` (a literal `[` per Go) was misparsed as an
492/// unterminated class.
493fn compile_glob(pattern: &str) -> Option<Vec<GlobToken>> {
494    let chars: Vec<char> = pattern.chars().collect();
495    let mut tokens = Vec::with_capacity(chars.len());
496    let mut i = 0;
497    while i < chars.len() {
498        match chars[i] {
499            '*' => {
500                tokens.push(GlobToken::Star);
501                i += 1;
502            }
503            '?' => {
504                tokens.push(GlobToken::Any);
505                i += 1;
506            }
507            '\\' => {
508                let Some(&escaped) = chars.get(i + 1) else {
509                    tracing::warn!(
510                        pattern = redact_userinfo(pattern),
511                        "GOPRIVATE pattern ends with a trailing unescaped '\\'; it will never \
512                         match, so affected modules route to the public proxy instead of being \
513                         treated as private"
514                    );
515                    return None; // dangling escape -> whole pattern invalid
516                };
517                tokens.push(GlobToken::Literal(escaped));
518                i += 2;
519            }
520            '[' => {
521                let negate = chars.get(i + 1) == Some(&'^');
522                let class_start = if negate { i + 2 } else { i + 1 };
523                let mut j = class_start;
524                let mut entries = Vec::new();
525                while j < chars.len() && (chars[j] != ']' || j == class_start) {
526                    let (lo, lo_len) = read_class_char(&chars, j);
527                    let after_lo = j + lo_len;
528                    if chars.get(after_lo) == Some(&'-')
529                        && chars.get(after_lo + 1).is_some_and(|&c| c != ']')
530                    {
531                        let (hi, hi_len) = read_class_char(&chars, after_lo + 1);
532                        if lo > hi {
533                            // Go's own `path.Match` (verified empirically against go1.25.5)
534                            // does not reject a reversed range either: it's parsed as an
535                            // always-empty range that simply never matches any character,
536                            // while the rest of the class (other entries, negation) still
537                            // behaves normally — so this only logs for observability rather
538                            // than invalidating the whole pattern.
539                            tracing::warn!(
540                                pattern = redact_userinfo(pattern),
541                                "GOPRIVATE pattern has a reversed '[' character-class range \
542                                 (lo > hi); that range can never match any character, but the \
543                                 rest of the pattern is still evaluated normally, matching Go's \
544                                 own path.Match behavior"
545                            );
546                        }
547                        entries.push(ClassEntry::Range(lo, hi));
548                        j = after_lo + 1 + hi_len;
549                    } else {
550                        entries.push(ClassEntry::Char(lo));
551                        j = after_lo;
552                    }
553                }
554                if chars.get(j) != Some(&']') {
555                    tracing::warn!(
556                        pattern = redact_userinfo(pattern),
557                        "GOPRIVATE pattern has an unterminated '[' character class; it will \
558                         never match, so affected modules route to the public proxy instead of \
559                         being treated as private"
560                    );
561                    return None; // unterminated class -> whole pattern invalid
562                }
563                tokens.push(GlobToken::Class { negate, entries });
564                i = j + 1;
565            }
566            c => {
567                tokens.push(GlobToken::Literal(c));
568                i += 1;
569            }
570        }
571    }
572    Some(tokens)
573}
574
575/// Reads one character-class member starting at `chars[j]`, honoring a `\`-escape (`path.Match`
576/// semantics apply inside `[...]` too — e.g. `\]`, `\-`, `\\` are literal, not a class
577/// terminator, range separator, or bare backslash respectively). Returns the member's char and
578/// how many source chars it consumed (1, or 2 for an escape pair). A trailing `\` with nothing
579/// left to escape is returned as a literal `\` of length 1 — the enclosing loop then reaches
580/// end-of-input without a closing `]`, which the caller's existing unterminated-class check
581/// already handles.
582fn read_class_char(chars: &[char], j: usize) -> (char, usize) {
583    if chars[j] == '\\'
584        && let Some(&escaped) = chars.get(j + 1)
585    {
586        return (escaped, 2);
587    }
588    (chars[j], 1)
589}
590
591/// Iterative glob matcher (spec 034 perf fix) — the classic two-pointer "wildcard matching"
592/// algorithm, generalized from `*`-only to this crate's full token set (`?`/`[...]`).
593/// `O(tokens.len() * text.len())` worst case, never exponential: each mismatch either advances
594/// `si` (bounded by `text.len()`) or terminates immediately (no star to retry), so the total
595/// number of loop iterations is bounded by `text.len()` restarts times `tokens.len()` work per
596/// restart, not `2^n`.
597///
598/// A `GlobToken::Star` never "jumps over" a `/` — matches `GlobToken::Star`'s
599/// doc and the previous recursive implementation's identical rule (a private module path's
600/// segment boundaries must stay meaningful to the glob).
601fn tokens_match(tokens: &[GlobToken], text: &[char]) -> bool {
602    let mut ti = 0usize;
603    let mut si = 0usize;
604    // (token index right after the star, text index the star currently "starts consuming
605    // from") — `None` until the first `*` is encountered.
606    let mut star: Option<(usize, usize)> = None;
607
608    while si < text.len() {
609        if matches!(tokens.get(ti), Some(GlobToken::Star)) {
610            star = Some((ti + 1, si));
611            ti += 1;
612            continue;
613        }
614        if tokens.get(ti).is_some_and(|tok| tok.matches_char(text[si])) {
615            ti += 1;
616            si += 1;
617            continue;
618        }
619        match star {
620            Some((next_ti, star_si)) if text[star_si] != '/' => {
621                let new_si = star_si + 1;
622                star = Some((next_ti, new_si));
623                ti = next_ti;
624                si = new_si;
625            }
626            _ => return false,
627        }
628    }
629    while matches!(tokens.get(ti), Some(GlobToken::Star)) {
630        ti += 1;
631    }
632    ti == tokens.len()
633}
634
635/// Resolved `$GOENV` configuration (FR-001–FR-008), consulted per-dependency via
636/// [`Self::resolve_source_for`].
637#[derive(Debug, Default)]
638pub struct GoEnvConfig {
639    /// `None` when `$GOENV` declares no `GOPROXY` override (FR-003/US-005: every dependency
640    /// keeps resolving to plain [`DependencySource::Registry`], byte-identical to today).
641    goproxy: Option<Result<GoProxyChain, InvalidEntry>>,
642    /// `GOPRIVATE` glob patterns (FR-007). Empty when absent/declares nothing.
643    goprivate: Vec<GlobPattern>,
644}
645
646impl GoEnvConfig {
647    /// Parses `$GOENV` file content (FR-001: `KEY=VALUE` lines, `#`-comments and blank lines
648    /// ignored) into a resolved config, validating any `GOPROXY` hop against `policy`
649    /// (FR-011).
650    #[must_use]
651    pub fn parse(content: &str, policy: &RegistryAccessPolicy) -> Self {
652        Self::from_raw(&parse_goenv_raw(content), policy)
653    }
654
655    fn from_raw(raw: &RawGoEnv, policy: &RegistryAccessPolicy) -> Self {
656        Self {
657            goproxy: raw
658                .goproxy
659                .as_deref()
660                .filter(|s| !s.is_empty())
661                .map(|raw| parse_goproxy(raw, policy)),
662            // Compiled once per distinct `RawGoEnv` (memoized on `raw` itself, see its
663            // `compiled_goprivate` doc) rather than on every `from_raw` call — GOPRIVATE glob
664            // compilation never depends on `policy`, unlike GOPROXY hop validation above, so
665            // there is nothing here that needs to re-run just because `from_raw` does.
666            goprivate: raw
667                .compiled_goprivate
668                .get_or_init(|| {
669                    raw.goprivate
670                        .as_deref()
671                        .unwrap_or_default()
672                        .split(',')
673                        .map(str::trim)
674                        .filter(|s| !s.is_empty())
675                        .map(GlobPattern::new)
676                        .collect()
677                })
678                .clone(),
679        }
680    }
681
682    /// FR-002/FR-007/FR-008/FR-009: resolves one module's [`DependencySource`].
683    ///
684    /// - A `GOPRIVATE`-matched module bypasses `GOPROXY` entirely, routing to the fixed
685    ///   [`GOPRIVATE_CHAIN_KEY`] chain (FR-008) — checked first, regardless of `GOPROXY`.
686    /// - No `GOPROXY` override declared -> plain [`DependencySource::Registry`] (US-005).
687    /// - A `GOPROXY` override where every hop is invalid -> [`DependencySource::CustomRegistry`]
688    ///   (FR-009, fail-closed, never a `proxy.golang.org` fallback).
689    /// - Otherwise -> [`DependencySource::AlternateRegistry`] pointing at the chain
690    ///   [`Self::goproxy_chain`] registers.
691    #[must_use]
692    pub fn resolve_source_for(&self, module_path: &str) -> DependencySource {
693        if self
694            .goprivate
695            .iter()
696            .any(|pattern| pattern.matches(module_path))
697        {
698            return DependencySource::AlternateRegistry {
699                index: GOPRIVATE_CHAIN_KEY.to_string(),
700                mirrors_crates_io: false,
701            };
702        }
703
704        match &self.goproxy {
705            None => DependencySource::Registry,
706            Some(Ok(chain)) => DependencySource::AlternateRegistry {
707                index: chain.key.clone(),
708                mirrors_crates_io: false,
709            },
710            Some(Err(invalid)) => DependencySource::CustomRegistry {
711                url: invalid.raw.clone(),
712            },
713        }
714    }
715
716    /// The resolved `GOPROXY` chain to register, if any (`None` when absent or every hop was
717    /// invalid — nothing to register in either case).
718    #[must_use]
719    pub fn goproxy_chain(&self) -> Option<&GoProxyChain> {
720        self.goproxy.as_ref().and_then(|r| r.as_ref().ok())
721    }
722
723    /// Whether at least one declared `GOPRIVATE` pattern actually compiled into a usable
724    /// matcher — gates whether the caller must also register the fixed
725    /// [`GOPRIVATE_CHAIN_KEY`] chain.
726    ///
727    /// Checks `tokens.is_some()` rather than mere presence in `self.goprivate` (issue #566): a
728    /// pattern rejected either by F6's oversized-pattern guard or as malformed (unterminated
729    /// `[` character class, issue #568) is still stored with `tokens: None` (see
730    /// `GlobPattern::tokens`'s doc) rather than removed, and can never match anything, so
731    /// counting it here would register a [`GOPRIVATE_CHAIN_KEY`] chain nothing can ever route
732    /// to.
733    #[must_use]
734    pub fn has_goprivate(&self) -> bool {
735        self.goprivate
736            .iter()
737            .any(|pattern| pattern.tokens.is_some())
738    }
739
740    /// Every chain this config implies, ready for `GoRegistry::register_chain` — the resolved
741    /// `GOPROXY` chain (if any), plus the fixed [`GOPRIVATE_CHAIN_KEY`] bypass chain when
742    /// [`Self::has_goprivate`] holds (registered regardless of whether `GOPROXY` itself is also
743    /// declared — FR-008 applies independently of `GOPROXY`). Empty when `$GOENV` declares no
744    /// override at all, or when every declared `GOPRIVATE` pattern was rejected (US-005/#566:
745    /// nothing usable to register).
746    #[must_use]
747    pub fn resolved_chains(&self) -> Vec<GoProxyChain> {
748        let mut chains = Vec::new();
749        if let Some(chain) = self.goproxy_chain() {
750            chains.push(chain.clone());
751        }
752        if self.has_goprivate() {
753            chains.push(GoProxyChain {
754                key: GOPRIVATE_CHAIN_KEY.to_string(),
755                hops: vec![GoProxyHop::Direct],
756                separators: Vec::new(),
757            });
758        }
759        chains
760    }
761}
762
763/// One `$GOENV` file's raw (unvalidated) `GOPROXY`/`GOPRIVATE` values — see
764/// [`parse_goenv_raw`]'s doc for exactly which keys this can ever contain.
765#[derive(Debug, Default)]
766struct RawGoEnv {
767    /// Raw, unvalidated `GOPROXY` value, which may carry embedded userinfo until
768    /// [`GoProxyUrl::new`] rejects it per-hop. Retained for the process lifetime by
769    /// [`GoEnvCache`]'s memoization, mirroring `deps_npm::config::NpmConfigCache`'s identical
770    /// shape exactly — precedent-consistent, not a regression to fix (spec 034 security
771    /// review, F4). Never logged or transmitted as-is (FR-014); only [`redact_userinfo`]'d
772    /// output ever leaves this module.
773    goproxy: Option<String>,
774    goprivate: Option<String>,
775    /// Memoizes [`GlobPattern::new`]'s compilation of `goprivate` (and thus any `tracing::warn!`
776    /// it logs — for an oversized pattern (F6) or a malformed one with an unterminated `[`
777    /// character class, issue #568) exactly once per distinct `RawGoEnv` instance.
778    ///
779    /// [`GoEnvCache`] hands out the *same* `Arc<RawGoEnv>` for repeat calls against an
780    /// unchanged file (mtime-gated), so `from_raw`'s `get_or_init` on this field runs the
781    /// compile-and-possibly-warn work only on the first call per distinct content — fixing the
782    /// F6 warning firing once per LSP re-parse (`did_change` -> ... -> `from_raw`) instead of
783    /// once per resolved config (issue #565), and equally debouncing the malformed-pattern
784    /// warning. A freshly-`parse_goenv_raw`'d `RawGoEnv` (e.g. from [`GoEnvConfig::parse`],
785    /// which has no cache) always starts with an empty `OnceLock`, so that entry point's
786    /// behavior — warn on every call — is unchanged.
787    compiled_goprivate: OnceLock<Vec<GlobPattern>>,
788}
789
790/// Parses `$GOENV` file content into its raw `GOPROXY`/`GOPRIVATE` values (FR-001).
791///
792/// Grammar: one `KEY=VALUE` per line (`go env -w`'s own written format); `#`-prefixed comment
793/// lines and blank lines are ignored; any other key is ignored (this crate has no use for
794/// `GONOSUMCHECK`/`GOFLAGS`/etc — see spec Out of Scope). A key declared more than once keeps
795/// its last occurrence, matching plain assignment-overwrite semantics.
796fn parse_goenv_raw(content: &str) -> RawGoEnv {
797    let mut raw = RawGoEnv::default();
798    for line in content.lines() {
799        let line = line.trim();
800        if line.is_empty() || line.starts_with('#') {
801            continue;
802        }
803        let Some((key, value)) = line.split_once('=') else {
804            continue;
805        };
806        match key.trim() {
807            "GOPROXY" => raw.goproxy = Some(value.trim().to_string()),
808            "GOPRIVATE" => raw.goprivate = Some(value.trim().to_string()),
809            _ => {}
810        }
811    }
812    raw
813}
814
815/// Per-`$GOENV`-file-path memoization, mirroring `deps_npm::config::NpmConfigCache` exactly in
816/// shape.
817///
818/// Caches **raw, unvalidated** entries — [`GoProxyUrl::new`] validation and policy gating
819/// re-run **per parse** against these cached entries, never cached themselves, so a
820/// `didChangeConfiguration` policy change takes effect immediately with no cache invalidation
821/// of its own. A thin newtype over [`deps_core::MtimeFileCache`].
822#[derive(Debug)]
823pub struct GoEnvCache(deps_core::MtimeFileCache<RawGoEnv>);
824
825impl Default for GoEnvCache {
826    fn default() -> Self {
827        Self::new()
828    }
829}
830
831impl GoEnvCache {
832    /// Creates an empty cache.
833    #[must_use]
834    pub fn new() -> Self {
835        Self(deps_core::MtimeFileCache::new(
836            deps_core::DEFAULT_MAX_CACHED_FILES,
837            "go env",
838        ))
839    }
840
841    fn get_or_parse(&self, path: &Path) -> Option<Arc<RawGoEnv>> {
842        self.0.get_or_parse(path, parse_goenv_raw)
843    }
844}
845
846/// Owned by `GoEcosystem`, shared across every document it parses.
847#[derive(Debug, Clone, Default)]
848pub struct GoParseContext {
849    /// Gates every `GOPROXY`-declared [`GoProxyUrl`] this parse constructs.
850    pub policy: Arc<RegistryAccessPolicy>,
851    /// Memoizes `$GOENV`'s raw, unvalidated contents across every parse that reads it.
852    pub config_cache: Arc<GoEnvCache>,
853    /// The resolved `$GOENV` path to consult, if any — resolved once by the caller rather
854    /// than looked up internally, so nothing in this crate reads the live host environment
855    /// implicitly (spec 034 follow-up C3/C4, issue #559). Production callers
856    /// (`crate::lib::register_ecosystems`) pass [`goenv_path`]'s result; tests pass a fixture
857    /// path. `None` — the [`Default`] value — means "no `$GOENV` file", the same hermetic,
858    /// zero-host-read behavior [`crate::parser::parse_go_mod`]'s doc already promises.
859    pub goenv_path: Option<PathBuf>,
860}
861
862/// Resolves `$GOENV`'s path (FR-001).
863///
864/// The `GOENV` environment variable if set and non-empty, else the platform default
865/// `os.UserConfigDir()/go/env` (`~/.config/go/env` on Linux/macOS, `%AppData%\go\env` on
866/// Windows).
867#[must_use]
868pub fn goenv_path() -> Option<PathBuf> {
869    goenv_path_with_env(std::env::var("GOENV").ok())
870}
871
872/// [`goenv_path`], but taking the `GOENV` environment variable's value explicitly instead of
873/// reading the real process environment — lets tests inject a fixture value without mutating
874/// process-global state (this crate forbids `unsafe` code, so an actual `std::env::set_var`
875/// call, which is `unsafe` since Rust 2024, is not an option here).
876fn goenv_path_with_env(env_value: Option<String>) -> Option<PathBuf> {
877    if let Some(value) = env_value.filter(|v| !v.is_empty()) {
878        return Some(PathBuf::from(value));
879    }
880    dirs::config_dir().map(|dir| dir.join("go").join("env"))
881}
882
883/// Resolves `$GOENV` into a [`GoEnvConfig`] (spec FR-001–FR-008).
884///
885/// `None`/an unreadable path (no `$GOENV` file at all) resolves to [`GoEnvConfig::default`] —
886/// every dependency keeps resolving to plain [`DependencySource::Registry`] (US-005, NFR-004:
887/// no additional filesystem/network activity beyond the one `stat` `MtimeFileCache` already
888/// performs).
889///
890/// # Examples
891///
892/// ```
893/// use deps_core::net_policy::RegistryAccessPolicy;
894/// use deps_go::config::{GoEnvCache, resolve};
895///
896/// let cache = GoEnvCache::new();
897/// let policy = RegistryAccessPolicy::default();
898/// let config = resolve(&cache, &policy);
899/// // No override anywhere in a typical test/CI environment with no $GOENV file.
900/// assert!(config.goproxy_chain().is_none() || config.goproxy_chain().is_some());
901/// ```
902#[must_use]
903pub fn resolve(cache: &GoEnvCache, policy: &RegistryAccessPolicy) -> GoEnvConfig {
904    resolve_at(cache, policy, goenv_path())
905}
906
907/// Resolves `$GOENV` into a [`GoEnvConfig`], reading the already-resolved
908/// [`GoParseContext::goenv_path`] instead of calling [`goenv_path`] itself.
909///
910/// This is the seam `crate::parser::parse_go_mod_with_context` calls in production, and the
911/// way tests exercise the full parse -> resolve -> `register_chain` -> `get_versions_from`
912/// path without depending on the real host `$GOENV`.
913#[must_use]
914pub fn resolve_with_context(ctx: &GoParseContext) -> GoEnvConfig {
915    resolve_at(&ctx.config_cache, &ctx.policy, ctx.goenv_path.clone())
916}
917
918/// [`resolve`], but taking the `$GOENV` path explicitly instead of [`goenv_path`] — lets tests
919/// inject a fixture path.
920fn resolve_at(
921    cache: &GoEnvCache,
922    policy: &RegistryAccessPolicy,
923    path: Option<PathBuf>,
924) -> GoEnvConfig {
925    let Some(path) = path else {
926        return GoEnvConfig::default();
927    };
928    let Some(raw) = cache.get_or_parse(&path) else {
929        return GoEnvConfig::default();
930    };
931    GoEnvConfig::from_raw(&raw, policy)
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937    use deps_core::net_policy::WorkspaceRegistryAccess;
938    use std::assert_matches;
939
940    fn all_policy() -> RegistryAccessPolicy {
941        RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
942    }
943
944    fn off_policy() -> RegistryAccessPolicy {
945        RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off)
946    }
947
948    // --- GoProxyUrl ---
949
950    #[test]
951    fn test_proxy_url_accepts_https() {
952        assert!(GoProxyUrl::new("https://goproxy.mycorp.example", &all_policy()).is_ok());
953    }
954
955    #[test]
956    fn test_proxy_url_rejects_http() {
957        assert_matches!(
958            GoProxyUrl::new("http://goproxy.mycorp.example", &all_policy()),
959            Err(GoProxyUrlError::NotHttps(_))
960        );
961    }
962
963    #[test]
964    fn test_proxy_url_rejects_userinfo() {
965        assert_matches!(
966            GoProxyUrl::new("https://user:pass@goproxy.mycorp.example", &all_policy()),
967            Err(GoProxyUrlError::UserInfoPresent)
968        );
969    }
970
971    #[test]
972    fn test_proxy_url_rejects_invalid() {
973        assert_matches!(
974            GoProxyUrl::new("not-a-valid-url", &all_policy()),
975            Err(GoProxyUrlError::InvalidUrl(_))
976        );
977    }
978
979    /// F3: a query string breaks the `{base}/{module}/@v/...` path-join convention every
980    /// request URL builder relies on — rejected rather than silently mis-joined.
981    #[test]
982    fn test_proxy_url_rejects_query_string() {
983        assert_matches!(
984            GoProxyUrl::new("https://goproxy.mycorp.example/?tok=abc", &all_policy()),
985            Err(GoProxyUrlError::InvalidUrl(_))
986        );
987    }
988
989    /// F3: a fragment is rejected for the same path-join reason as a query string.
990    #[test]
991    fn test_proxy_url_rejects_fragment() {
992        assert_matches!(
993            GoProxyUrl::new("https://goproxy.mycorp.example/#frag", &all_policy()),
994            Err(GoProxyUrlError::InvalidUrl(_))
995        );
996    }
997
998    #[test]
999    fn test_proxy_url_policy_matrix() {
1000        assert!(GoProxyUrl::new("https://goproxy.mycorp.example", &off_policy()).is_err());
1001    }
1002
1003    // --- glob matching (FR-007/FR-008) ---
1004
1005    #[test]
1006    fn test_glob_pattern_prefix_wildcard_matches() {
1007        let pattern = GlobPattern::new("git.mycorp.example/*");
1008        assert!(pattern.matches("git.mycorp.example/internal/auth"));
1009        assert!(pattern.matches("git.mycorp.example/anything"));
1010    }
1011
1012    #[test]
1013    fn test_glob_pattern_no_match() {
1014        let pattern = GlobPattern::new("git.mycorp.example/*");
1015        assert!(!pattern.matches("github.com/other/repo"));
1016    }
1017
1018    #[test]
1019    fn test_glob_pattern_exact_element_match() {
1020        let pattern = GlobPattern::new("github.com/myorg");
1021        assert!(pattern.matches("github.com/myorg"));
1022        assert!(pattern.matches("github.com/myorg/repo"));
1023        assert!(!pattern.matches("github.com/otherorg"));
1024    }
1025
1026    #[test]
1027    fn test_glob_pattern_not_enough_segments_no_match() {
1028        // Pattern needs 3 elements; target has only 2.
1029        let pattern = GlobPattern::new("git.mycorp.example/internal/*");
1030        assert!(!pattern.matches("git.mycorp.example/internal"));
1031    }
1032
1033    #[test]
1034    fn test_glob_pattern_question_mark() {
1035        let pattern = GlobPattern::new("git.mycorp.example/repo?");
1036        assert!(pattern.matches("git.mycorp.example/repo1"));
1037        assert!(!pattern.matches("git.mycorp.example/repo12"));
1038    }
1039
1040    #[test]
1041    fn test_glob_pattern_character_class() {
1042        let pattern = GlobPattern::new("git.mycorp.example/repo[12]");
1043        assert!(pattern.matches("git.mycorp.example/repo1"));
1044        assert!(pattern.matches("git.mycorp.example/repo2"));
1045        assert!(!pattern.matches("git.mycorp.example/repo3"));
1046    }
1047
1048    #[test]
1049    fn test_glob_pattern_negated_character_class() {
1050        let pattern = GlobPattern::new("git.mycorp.example/repo[^12]");
1051        assert!(!pattern.matches("git.mycorp.example/repo1"));
1052        assert!(pattern.matches("git.mycorp.example/repo3"));
1053    }
1054
1055    #[test]
1056    fn test_glob_pattern_range_character_class() {
1057        let pattern = GlobPattern::new("git.mycorp.example/repo[a-c]");
1058        assert!(pattern.matches("git.mycorp.example/repob"));
1059        assert!(!pattern.matches("git.mycorp.example/repod"));
1060    }
1061
1062    #[test]
1063    fn test_glob_pattern_degenerate_range_class_still_matches() {
1064        let pattern = GlobPattern::new("git.mycorp.example/repo[a-a]");
1065        assert!(pattern.matches("git.mycorp.example/repoa"));
1066        assert!(!pattern.matches("git.mycorp.example/repob"));
1067    }
1068
1069    /// Issue #570: a reversed range like `[c-a]` (`lo > hi`) is Go-valid, not malformed —
1070    /// verified empirically against go1.25.5's `path.Match`, which returns `err == nil` and
1071    /// treats it as an always-empty range. `GlobPattern` mirrors that: the reversed entry
1072    /// itself never matches any character, but `tokens` stays `Some(...)` (the pattern is
1073    /// still usable — `has_goprivate()` must not fail open just because it warned).
1074    #[test]
1075    fn test_glob_pattern_reversed_range_entry_never_matches_but_pattern_still_compiles() {
1076        let pattern = GlobPattern::new("git.mycorp.example/repo[c-a]");
1077        assert!(!pattern.matches("git.mycorp.example/repoa"));
1078        assert!(!pattern.matches("git.mycorp.example/repob"));
1079        assert!(!pattern.matches("git.mycorp.example/repoc"));
1080    }
1081
1082    #[test]
1083    fn test_glob_pattern_reversed_range_class_logs_warning() {
1084        let log = deps_core::test_util::capture_tracing_output(|| {
1085            let _ = GlobPattern::new("git.corp.example/[c-a]");
1086        });
1087        assert!(
1088            log.contains("reversed '[' character-class range"),
1089            "expected reversed-range warning in log: {log:?}"
1090        );
1091    }
1092
1093    /// A reversed range does not poison the rest of its class: other, valid entries in the
1094    /// same `[...]` still match normally (matches Go's `path.Match`).
1095    #[test]
1096    fn test_glob_pattern_reversed_range_among_valid_entries_others_still_match() {
1097        let pattern = GlobPattern::new("git.mycorp.example/repo[xc-a1]");
1098        assert!(pattern.matches("git.mycorp.example/repox"));
1099        assert!(pattern.matches("git.mycorp.example/repo1"));
1100        assert!(!pattern.matches("git.mycorp.example/repoy"));
1101    }
1102
1103    /// A negated class whose only entry is an always-empty reversed range matches every
1104    /// character, since no entry ever matches and negation flips that (matches Go's
1105    /// `path.Match`).
1106    #[test]
1107    fn test_glob_pattern_negated_reversed_range_matches_everything() {
1108        let pattern = GlobPattern::new("git.mycorp.example/repo[^c-a]");
1109        assert!(pattern.matches("git.mycorp.example/repox"));
1110        assert!(pattern.matches("git.mycorp.example/repo9"));
1111    }
1112
1113    #[test]
1114    fn test_glob_pattern_escaped_bracket_is_literal() {
1115        let pattern = GlobPattern::new(r"git.mycorp.example/repo\[x");
1116        assert!(pattern.matches("git.mycorp.example/repo[x"));
1117        assert!(!pattern.matches("git.mycorp.example/repox"));
1118    }
1119
1120    #[test]
1121    fn test_glob_pattern_escaped_bracket_does_not_log_unterminated_warning() {
1122        let log = deps_core::test_util::capture_tracing_output(|| {
1123            let pattern = GlobPattern::new(r"git.corp.example/repo\[x");
1124            assert!(pattern.matches("git.corp.example/repo[x"));
1125        });
1126        assert!(
1127            !log.contains("unterminated"),
1128            "escaped '[' must not be treated as a class start: {log:?}"
1129        );
1130    }
1131
1132    #[test]
1133    fn test_glob_pattern_trailing_backslash_never_matches() {
1134        let pattern = GlobPattern::new(r"git.mycorp.example/repo\");
1135        assert!(!pattern.matches("git.mycorp.example/repo"));
1136        assert!(!pattern.matches(r"git.mycorp.example/repo\"));
1137    }
1138
1139    /// S1: `\]` inside a class is a literal `]` range bound (here, the lo bound of a
1140    /// `]`-to-`a` range), not the class terminator — `]` is 0x5D, `a` is 0x61, so `_` (0x5F)
1141    /// falls inside the range and `A` (0x41) does not.
1142    #[test]
1143    fn test_glob_pattern_escaped_bracket_inside_class_as_range_bound() {
1144        let pattern = GlobPattern::new(r"git.mycorp.example/repo[\]-a]");
1145        assert!(pattern.matches("git.mycorp.example/repo_"));
1146        assert!(!pattern.matches("git.mycorp.example/repoA"));
1147    }
1148
1149    /// S1: `\]` as a standalone (non-range) class member doesn't close the class early —
1150    /// `[a\]b]` has three literal members (`a`, `]`, `b`), not `[a\]` followed by a stray `b]`.
1151    #[test]
1152    fn test_glob_pattern_escaped_bracket_as_class_member_does_not_close_class_early() {
1153        let pattern = GlobPattern::new(r"git.mycorp.example/repo[a\]b]");
1154        assert!(pattern.matches("git.mycorp.example/repoa"));
1155        assert!(pattern.matches("git.mycorp.example/repo]"));
1156        assert!(pattern.matches("git.mycorp.example/repob"));
1157        assert!(!pattern.matches("git.mycorp.example/repoc"));
1158    }
1159
1160    /// S1: `\-` inside a class is a literal `-` range bound, not the range separator —
1161    /// `[\--a]` is the range `-` (0x2D) to `a` (0x61), covering digits and more.
1162    #[test]
1163    fn test_glob_pattern_escaped_dash_inside_class_as_range_bound() {
1164        let pattern = GlobPattern::new(r"git.mycorp.example/repo[\--a]");
1165        assert!(pattern.matches("git.mycorp.example/repo-"));
1166        assert!(pattern.matches("git.mycorp.example/repo0"));
1167        assert!(pattern.matches("git.mycorp.example/repoa"));
1168        assert!(!pattern.matches("git.mycorp.example/repob"));
1169    }
1170
1171    /// S1 regression guard: without in-class escape handling, `\--a`'s escaped `-` lo bound
1172    /// misparses as `Range('\\', '-')` — the naive scan sees `chars[j] == '\\'` followed by a
1173    /// literal `-` and takes the next char (`-`) as `hi`, instead of recognizing `\-` as one
1174    /// escaped literal `-`. Since `\\` is `0x5C` and `-` is `0x2D`, `0x5C > 0x2D` makes that a
1175    /// reversed range, spuriously warning even though the intended range (`-` to `a`, an
1176    /// escaped literal lo bound) is valid and ascending.
1177    #[test]
1178    fn test_glob_pattern_escaped_dash_inside_class_does_not_spuriously_warn_reversed() {
1179        let log = deps_core::test_util::capture_tracing_output(|| {
1180            let pattern = GlobPattern::new(r"git.corp.example/repo[\--a]");
1181            assert!(pattern.matches("git.corp.example/repo-"));
1182        });
1183        assert!(
1184            !log.contains("reversed"),
1185            "escaped '-' as lo bound must not be misread as a backslash literal forming a \
1186             reversed range: {log:?}"
1187        );
1188    }
1189
1190    #[test]
1191    fn test_glob_pattern_malformed_class_never_panics_no_match() {
1192        let pattern = GlobPattern::new("git.mycorp.example/repo[unterminated");
1193        assert!(!pattern.matches("git.mycorp.example/repo1"));
1194    }
1195
1196    /// Issue #568: an unterminated `[` character class's silent fail-open must actually log a
1197    /// `tracing::warn!`, not just be non-matching — mirrors
1198    /// `test_glob_pattern_oversized_pattern_logs_warning`'s F6 pattern for the sibling
1199    /// rejection path.
1200    #[test]
1201    fn test_glob_pattern_malformed_class_logs_warning() {
1202        let log = deps_core::test_util::capture_tracing_output(|| {
1203            let _ = GlobPattern::new("git.corp.example/[abc");
1204        });
1205        assert!(
1206            log.contains("unterminated '[' character class"),
1207            "expected malformed-pattern warning in log: {log:?}"
1208        );
1209    }
1210
1211    #[test]
1212    fn test_glob_pattern_wildcard_never_crosses_slash() {
1213        let pattern = GlobPattern::new("git.mycorp.example/*");
1214        // `*` is scoped to one path element after truncation; the truncated prefix for a
1215        // 2-element pattern never contains more than 2 segments, so this is inherently
1216        // satisfied — asserted here as a regression guard.
1217        assert!(pattern.matches("git.mycorp.example/internal/auth/deep/nested"));
1218    }
1219
1220    /// Perf regression guard (spec 034 review finding): the naive recursive-backtracking
1221    /// matcher this replaced was `O(2^n)` on an adversarial many-consecutive-`*` pattern
1222    /// against a non-matching text — empirically 15 stars took ~19s and 20 stars timed out.
1223    /// The iterative token matcher must resolve this in well under a second.
1224    #[test]
1225    fn test_glob_pattern_many_stars_no_exponential_blowup() {
1226        let pattern = GlobPattern::new(&format!("{}x", "*".repeat(30)));
1227        let text = "a".repeat(60);
1228
1229        let start = std::time::Instant::now();
1230        let matched = pattern.matches(&text);
1231        let elapsed = start.elapsed();
1232
1233        assert!(
1234            !matched,
1235            "pattern requires a literal 'x' the text never has"
1236        );
1237        assert!(
1238            elapsed < std::time::Duration::from_secs(1),
1239            "glob matching took too long: {elapsed:?} (possible backtracking regression)"
1240        );
1241    }
1242
1243    /// Defense-in-depth: an oversized pattern (beyond `MAX_GLOB_PATTERN_LENGTH`) is treated
1244    /// as permanently non-matching rather than compiled at all.
1245    #[test]
1246    fn test_glob_pattern_oversized_pattern_never_matches() {
1247        let long_pattern = "*".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1248        let pattern = GlobPattern::new(&long_pattern);
1249        assert!(!pattern.matches(&"a".repeat(100)));
1250    }
1251
1252    /// F6 (spec 034 follow-up, issue #559 C2): an oversized `GOPRIVATE` pattern's silent
1253    /// fail-open must actually log a `tracing::warn!`, not just be non-matching.
1254    #[test]
1255    fn test_glob_pattern_oversized_pattern_logs_warning() {
1256        let long_pattern = "*".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1257        let log = deps_core::test_util::capture_tracing_output(|| {
1258            let _ = GlobPattern::new(&long_pattern);
1259        });
1260        assert!(
1261            log.contains("GOPRIVATE pattern exceeds max length"),
1262            "expected oversized-pattern warning in log: {log:?}"
1263        );
1264    }
1265
1266    /// Issue #565: `from_raw`'s GOPRIVATE compilation (and the F6 oversized-pattern warning it
1267    /// triggers) is memoized on the cached `RawGoEnv`, so repeated resolves against unchanged
1268    /// `$GOENV` content (e.g. LSP `did_change` re-parsing an unrelated part of `go.mod`) log
1269    /// the warning once, not once per resolve.
1270    #[test]
1271    fn test_goenv_oversized_goprivate_warning_debounced_across_resolves() {
1272        let dir = tempfile::tempdir().unwrap();
1273        let path = dir.path().join("env");
1274        let long_pattern = "*".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1275        std::fs::write(&path, format!("GOPRIVATE={long_pattern}\n")).unwrap();
1276
1277        let cache = GoEnvCache::new();
1278        let log = deps_core::test_util::capture_tracing_output(|| {
1279            for _ in 0..4 {
1280                let _ = resolve_at(&cache, &all_policy(), Some(path.clone()));
1281            }
1282
1283            // A genuine content change (distinguishable mtime) must re-trigger the warning
1284            // exactly once more, not once per subsequent resolve — proving the debounce
1285            // tracks content freshness rather than suppressing the warning forever.
1286            let another_long_pattern = "?".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1287            let future = std::time::SystemTime::now() + std::time::Duration::from_secs(2);
1288            std::fs::write(&path, format!("GOPRIVATE={another_long_pattern}\n")).unwrap();
1289            std::fs::OpenOptions::new()
1290                .write(true)
1291                .open(&path)
1292                .unwrap()
1293                .set_modified(future)
1294                .unwrap();
1295
1296            for _ in 0..2 {
1297                let _ = resolve_at(&cache, &all_policy(), Some(path.clone()));
1298            }
1299        });
1300
1301        assert_eq!(
1302            log.matches("GOPRIVATE pattern exceeds max length").count(),
1303            2,
1304            "expected one warning for the original content and one more after a genuine \
1305             content change: {log:?}"
1306        );
1307    }
1308
1309    // --- GoEnvConfig::parse / resolve_source_for (FR-001-FR-009) ---
1310
1311    #[test]
1312    fn test_empty_content_resolves_to_plain_registry() {
1313        let config = GoEnvConfig::parse("", &all_policy());
1314        assert_eq!(
1315            config.resolve_source_for("github.com/gin-gonic/gin"),
1316            DependencySource::Registry
1317        );
1318        assert!(config.goproxy_chain().is_none());
1319        assert!(!config.has_goprivate());
1320    }
1321
1322    #[test]
1323    fn test_comments_and_blank_lines_ignored() {
1324        let content = "# a comment\n\nGOPROXY=https://goproxy.mycorp.example\n";
1325        let config = GoEnvConfig::parse(content, &all_policy());
1326        assert!(config.goproxy_chain().is_some());
1327    }
1328
1329    /// US-001: a single-hop `GOPROXY,direct` chain resolves to `AlternateRegistry`.
1330    #[test]
1331    fn test_goproxy_single_hop_plus_direct() {
1332        let config = GoEnvConfig::parse(
1333            "GOPROXY=https://goproxy.mycorp.example,direct",
1334            &all_policy(),
1335        );
1336        let source = config.resolve_source_for("github.com/gin-gonic/gin");
1337        let DependencySource::AlternateRegistry {
1338            index,
1339            mirrors_crates_io,
1340        } = source
1341        else {
1342            panic!("expected AlternateRegistry");
1343        };
1344        assert!(!mirrors_crates_io);
1345        let chain = config.goproxy_chain().unwrap();
1346        assert_eq!(chain.key, index);
1347        assert_eq!(chain.hops.len(), 2);
1348        assert_matches!(chain.hops[0], GoProxyHop::Url(_));
1349        assert_matches!(chain.hops[1], GoProxyHop::Direct);
1350    }
1351
1352    /// US-004: `GOPROXY=off` as sole entry.
1353    #[test]
1354    fn test_goproxy_off_sole_entry() {
1355        let config = GoEnvConfig::parse("GOPROXY=off", &all_policy());
1356        let chain = config.goproxy_chain().unwrap();
1357        assert_eq!(chain.hops, vec![GoProxyHop::Off]);
1358    }
1359
1360    /// FR-009: sole invalid entry fails the whole chain closed to `CustomRegistry`.
1361    #[test]
1362    fn test_goproxy_sole_invalid_entry_fails_closed() {
1363        let config = GoEnvConfig::parse("GOPROXY=not-a-valid-url", &all_policy());
1364        assert_eq!(
1365            config.resolve_source_for("github.com/gin-gonic/gin"),
1366            DependencySource::CustomRegistry {
1367                url: "not-a-valid-url".to_string(),
1368            }
1369        );
1370        assert!(config.goproxy_chain().is_none());
1371    }
1372
1373    /// FR-009: an invalid hop is dropped when a valid one remains, no `CustomRegistry`
1374    /// escalation.
1375    #[test]
1376    fn test_goproxy_invalid_hop_dropped_when_valid_hop_remains() {
1377        let config = GoEnvConfig::parse(
1378            "GOPROXY=not-a-valid-url,https://goproxy.mycorp.example",
1379            &all_policy(),
1380        );
1381        let chain = config.goproxy_chain().unwrap();
1382        assert_eq!(chain.hops.len(), 1);
1383        assert_matches!(chain.hops[0], GoProxyHop::Url(_));
1384    }
1385
1386    /// FR-011: a policy-blocked hop is treated the same as an invalid one.
1387    #[test]
1388    fn test_goproxy_policy_blocked_hop_fails_closed() {
1389        let config = GoEnvConfig::parse("GOPROXY=https://goproxy.mycorp.example", &off_policy());
1390        assert_matches!(
1391            config.resolve_source_for("github.com/gin-gonic/gin"),
1392            DependencySource::CustomRegistry { .. }
1393        );
1394    }
1395
1396    /// FR-002: everything declared after a terminal `direct`/`off` hop is unreachable and
1397    /// dropped at parse time.
1398    #[test]
1399    fn test_goproxy_hops_after_terminal_are_dropped() {
1400        let config = GoEnvConfig::parse(
1401            "GOPROXY=https://a.example,direct,https://b.example",
1402            &all_policy(),
1403        );
1404        let chain = config.goproxy_chain().unwrap();
1405        assert_eq!(chain.hops.len(), 2);
1406        assert_matches!(chain.hops[1], GoProxyHop::Direct);
1407    }
1408
1409    /// FR-002: pipe-separated entries parse the same as comma-separated ones.
1410    #[test]
1411    fn test_goproxy_pipe_separated() {
1412        let config =
1413            GoEnvConfig::parse("GOPROXY=https://a.example|https://b.example", &all_policy());
1414        let chain = config.goproxy_chain().unwrap();
1415        assert_eq!(chain.hops.len(), 2);
1416    }
1417
1418    /// S2: `,` and `|` are recorded with their real, distinct semantics rather than both
1419    /// collapsing to the same fallback rule.
1420    #[test]
1421    fn test_goproxy_separators_recorded_distinctly() {
1422        let config = GoEnvConfig::parse(
1423            "GOPROXY=https://a.example,https://b.example|https://c.example",
1424            &all_policy(),
1425        );
1426        let chain = config.goproxy_chain().unwrap();
1427        assert_eq!(chain.hops.len(), 3);
1428        assert_eq!(
1429            chain.separators,
1430            vec![ChainSeparator::NotFoundOnly, ChainSeparator::AnyError]
1431        );
1432    }
1433
1434    /// S2: an all-comma chain (the common case) still records every transition as
1435    /// `NotFoundOnly`, matching this feature's original behavior.
1436    #[test]
1437    fn test_goproxy_all_comma_separators() {
1438        let config = GoEnvConfig::parse(
1439            "GOPROXY=https://a.example,https://b.example,direct",
1440            &all_policy(),
1441        );
1442        let chain = config.goproxy_chain().unwrap();
1443        assert_eq!(
1444            chain.separators,
1445            vec![ChainSeparator::NotFoundOnly, ChainSeparator::NotFoundOnly]
1446        );
1447    }
1448
1449    /// FR-008/US-002: a `GOPRIVATE`-matched module bypasses `GOPROXY` entirely.
1450    #[test]
1451    fn test_goprivate_bypasses_goproxy() {
1452        let content =
1453            "GOPROXY=https://goproxy.mycorp.example,direct\nGOPRIVATE=git.mycorp.example/*\n";
1454        let config = GoEnvConfig::parse(content, &all_policy());
1455
1456        let private_source = config.resolve_source_for("git.mycorp.example/internal/auth");
1457        assert_eq!(
1458            private_source,
1459            DependencySource::AlternateRegistry {
1460                index: GOPRIVATE_CHAIN_KEY.to_string(),
1461                mirrors_crates_io: false,
1462            }
1463        );
1464
1465        let public_source = config.resolve_source_for("github.com/gin-gonic/gin");
1466        assert_matches!(public_source, DependencySource::AlternateRegistry { .. });
1467        assert_ne!(private_source, public_source);
1468        assert!(config.has_goprivate());
1469    }
1470
1471    /// FR-008: `GOPRIVATE` alone (no `GOPROXY` declared) still routes a matched module to the
1472    /// bypass chain.
1473    #[test]
1474    fn test_goprivate_without_goproxy() {
1475        let config = GoEnvConfig::parse("GOPRIVATE=git.mycorp.example/*", &all_policy());
1476        assert!(config.goproxy_chain().is_none());
1477        assert_eq!(
1478            config.resolve_source_for("git.mycorp.example/internal/auth"),
1479            DependencySource::AlternateRegistry {
1480                index: GOPRIVATE_CHAIN_KEY.to_string(),
1481                mirrors_crates_io: false,
1482            }
1483        );
1484        assert_eq!(
1485            config.resolve_source_for("github.com/other/repo"),
1486            DependencySource::Registry
1487        );
1488    }
1489
1490    /// Edge case (issue #559): `GOPROXY=`/`GOPRIVATE=` with nothing after the `=` parse
1491    /// successfully but resolve exactly as if the key were absent — no phantom empty-string
1492    /// hop/pattern, no panic.
1493    #[test]
1494    fn test_goenv_empty_goproxy_and_goprivate_values_are_absent() {
1495        let config = GoEnvConfig::parse("GOPROXY=\nGOPRIVATE=\n", &all_policy());
1496        assert!(config.goproxy_chain().is_none());
1497        assert!(!config.has_goprivate());
1498        assert_eq!(
1499            config.resolve_source_for("github.com/gin-gonic/gin"),
1500            DependencySource::Registry
1501        );
1502    }
1503
1504    /// Edge case (issue #559): a `$GOENV` line with no `=` at all (not a comment, not blank)
1505    /// is silently ignored rather than panicking or corrupting the previous/next key.
1506    #[test]
1507    fn test_goenv_malformed_line_without_equals_is_ignored() {
1508        let content = "GARBAGE LINE WITH NO EQUALS\nGOPROXY=https://goproxy.mycorp.example\n";
1509        let config = GoEnvConfig::parse(content, &all_policy());
1510        assert!(config.goproxy_chain().is_some());
1511    }
1512
1513    /// Edge case (issue #559): the same `GOPRIVATE` glob pattern declared twice still matches
1514    /// (no dedup requirement, no panic) — duplicates are just redundant, not invalid.
1515    #[test]
1516    fn test_goenv_duplicate_goprivate_patterns_still_match() {
1517        let config = GoEnvConfig::parse(
1518            "GOPRIVATE=git.mycorp.example/*,git.mycorp.example/*",
1519            &all_policy(),
1520        );
1521        assert!(config.has_goprivate());
1522        assert_eq!(
1523            config.resolve_source_for("git.mycorp.example/internal/auth"),
1524            DependencySource::AlternateRegistry {
1525                index: GOPRIVATE_CHAIN_KEY.to_string(),
1526                mirrors_crates_io: false,
1527            }
1528        );
1529    }
1530
1531    /// Edge case (issue #559): a chain mixing `|` before `,` (rather than the `,`-then-`|`
1532    /// order `test_goproxy_separators_recorded_distinctly` already covers) still records each
1533    /// transition with its own real semantics, not the first separator seen in the value.
1534    #[test]
1535    fn test_goproxy_mixed_pipe_then_comma_separators() {
1536        let config = GoEnvConfig::parse(
1537            "GOPROXY=https://a.example|https://b.example,direct",
1538            &all_policy(),
1539        );
1540        let chain = config.goproxy_chain().unwrap();
1541        assert_eq!(chain.hops.len(), 3);
1542        assert_matches!(chain.hops[2], GoProxyHop::Direct);
1543        assert_eq!(
1544            chain.separators,
1545            vec![ChainSeparator::AnyError, ChainSeparator::NotFoundOnly]
1546        );
1547    }
1548
1549    /// Issue #564 (fixed): a separator preceding a *dropped* invalid hop is merged onto the
1550    /// transition between the two surviving hops, with the more permissive separator
1551    /// (`AnyError`/`|`) winning rather than being silently discarded. Here the user's `|`
1552    /// (fall through on any error) before the invalid entry wins over the `,` (fall through
1553    /// on not-found only) that happened to follow the dropped entry — see `parse_goproxy`'s
1554    /// doc.
1555    #[test]
1556    fn test_goproxy_separator_before_dropped_hop_is_merged_most_permissive_wins() {
1557        let config = GoEnvConfig::parse(
1558            "GOPROXY=https://a.example|not-a-valid-url,https://c.example",
1559            &all_policy(),
1560        );
1561        let chain = config.goproxy_chain().unwrap();
1562        assert_eq!(chain.hops.len(), 2);
1563        assert_eq!(chain.separators, vec![ChainSeparator::AnyError]);
1564    }
1565
1566    /// Issue #566: a GOPRIVATE pattern rejected by F6 (oversized) never becomes a usable
1567    /// matcher, so `has_goprivate()` must not report `true` for it, and no unreachable
1568    /// `GOPRIVATE_CHAIN_KEY` chain should be registered.
1569    #[test]
1570    fn test_has_goprivate_false_when_all_patterns_rejected() {
1571        let long_pattern = "*".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1572        let config = GoEnvConfig::parse(&format!("GOPRIVATE={long_pattern}"), &all_policy());
1573        assert!(!config.has_goprivate());
1574        assert!(config.resolved_chains().is_empty());
1575    }
1576
1577    /// Issue #566: as long as at least one declared GOPRIVATE pattern compiles into a usable
1578    /// matcher, `has_goprivate()` still reports `true` and the bypass chain is still
1579    /// registered, even alongside a sibling pattern that was rejected.
1580    #[test]
1581    fn test_has_goprivate_true_when_at_least_one_pattern_usable() {
1582        let long_pattern = "*".repeat(MAX_GLOB_PATTERN_LENGTH + 1);
1583        let content = format!("GOPRIVATE={long_pattern},git.mycorp.example/*");
1584        let config = GoEnvConfig::parse(&content, &all_policy());
1585        assert!(config.has_goprivate());
1586        assert_eq!(config.resolved_chains().len(), 1);
1587    }
1588
1589    /// Issue #568: a malformed (unterminated `[`) `GOPRIVATE` pattern is the sibling rejection
1590    /// path to #566's oversized-pattern one, and must be excluded from `has_goprivate()`
1591    /// (and thus `resolved_chains()`) the same way.
1592    #[test]
1593    fn test_has_goprivate_false_for_malformed_pattern() {
1594        let config = GoEnvConfig::parse("GOPRIVATE=git.corp.example/[abc", &all_policy());
1595        assert!(!config.has_goprivate());
1596        assert!(config.resolved_chains().is_empty());
1597        assert_eq!(
1598            config.resolve_source_for("git.corp.example/abc"),
1599            DependencySource::Registry
1600        );
1601    }
1602
1603    /// Issue #570 (corrected): unlike the oversized/unterminated rejection paths above, a
1604    /// reversed `[lo-hi]` character-class range only warns — `compile_glob` keeps
1605    /// `tokens: Some(...)` (see `GlobPattern::tokens`'s doc), so `has_goprivate()` must still
1606    /// report `true` and the bypass chain must still be registered for a pattern containing
1607    /// one.
1608    #[test]
1609    fn test_has_goprivate_true_for_pattern_with_reversed_range() {
1610        let config = GoEnvConfig::parse("GOPRIVATE=git.corp.example/repo[c-a]", &all_policy());
1611        assert!(config.has_goprivate());
1612        assert_eq!(config.resolved_chains().len(), 1);
1613    }
1614
1615    // --- redaction (FR-014/NFR-001) ---
1616
1617    /// M1-shaped guard: `InvalidEntry::raw` and the `tracing::warn!` line built from it must
1618    /// never carry a userinfo-bearing hop's credential through.
1619    #[test]
1620    fn test_invalid_hop_redacts_userinfo_from_raw_and_log() {
1621        let log = deps_core::test_util::capture_tracing_output(|| {
1622            let config = GoEnvConfig::parse(
1623                "GOPROXY=https://user:hunter2@goproxy.mycorp.example",
1624                &all_policy(),
1625            );
1626            let source = config.resolve_source_for("github.com/gin-gonic/gin");
1627            let DependencySource::CustomRegistry { url } = source else {
1628                panic!("expected CustomRegistry");
1629            };
1630            assert!(!url.contains("hunter2"), "leaked credential: {url}");
1631            assert!(!url.contains("user:"), "leaked username: {url}");
1632        });
1633        assert!(
1634            !log.contains("hunter2"),
1635            "tracing output leaked credential: {log:?}"
1636        );
1637    }
1638
1639    /// NFR-001/SC-005 structural guarantee: a URL carrying userinfo is rejected at
1640    /// construction (never stripped-and-proceeded), so no [`GoProxyUrl`] ever holds a
1641    /// credential.
1642    #[test]
1643    fn test_userinfo_rejected_never_retained() {
1644        let err =
1645            GoProxyUrl::new("https://user:hunter2@goproxy.example", &all_policy()).unwrap_err();
1646        assert_eq!(err, GoProxyUrlError::UserInfoPresent);
1647    }
1648
1649    // --- $GOENV path resolution (FR-001) ---
1650
1651    #[test]
1652    fn test_resolve_at_no_path_is_default() {
1653        let cache = GoEnvCache::new();
1654        let config = resolve_at(&cache, &all_policy(), None);
1655        assert!(config.goproxy_chain().is_none());
1656    }
1657
1658    #[test]
1659    fn test_resolve_at_nonexistent_path_is_default() {
1660        let cache = GoEnvCache::new();
1661        let config = resolve_at(
1662            &cache,
1663            &all_policy(),
1664            Some(PathBuf::from("/nonexistent/go/env")),
1665        );
1666        assert!(config.goproxy_chain().is_none());
1667    }
1668
1669    #[test]
1670    fn test_resolve_at_reads_real_file() {
1671        let dir = tempfile::tempdir().unwrap();
1672        let path = dir.path().join("env");
1673        std::fs::write(&path, "GOPROXY=https://goproxy.mycorp.example,direct\n").unwrap();
1674
1675        let cache = GoEnvCache::new();
1676        let config = resolve_at(&cache, &all_policy(), Some(path));
1677        assert!(config.goproxy_chain().is_some());
1678    }
1679
1680    #[test]
1681    fn test_goenv_path_honors_env_var() {
1682        let path = goenv_path_with_env(Some("/custom/goenv/path".to_string()));
1683        assert_eq!(path, Some(PathBuf::from("/custom/goenv/path")));
1684    }
1685
1686    #[test]
1687    fn test_goenv_path_empty_env_var_falls_back_to_platform_default() {
1688        let with_empty = goenv_path_with_env(Some(String::new()));
1689        let with_none = goenv_path_with_env(None);
1690        assert_eq!(with_empty, with_none);
1691    }
1692}