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}