Skip to main content

deps_go/
registry.rs

1//! proxy.golang.org registry client.
2//!
3//! Provides access to Go module proxy via:
4//! - `/{module}/@v/list` - list all versions
5//! - `/{module}/@v/{version}.info` - version metadata
6//! - `/{module}/@v/{version}.mod` - go.mod file
7//! - `/{module}/@latest` - latest version info
8//!
9//! All HTTP requests are cached aggressively using ETag/Last-Modified headers.
10//!
11//! # Examples
12//!
13//! ```no_run
14//! use deps_go::GoRegistry;
15//! use deps_core::HttpCache;
16//! use std::sync::Arc;
17//!
18//! #[tokio::main]
19//! async fn main() {
20//!     let cache = Arc::new(HttpCache::new());
21//!     let registry = GoRegistry::new(cache);
22//!
23//!     let versions = registry.get_versions("github.com/gin-gonic/gin").await.unwrap();
24//!     println!("Latest gin: {}", versions[0].version);
25//! }
26//! ```
27
28use crate::config::{ChainSeparator, GoProxyChain, GoProxyHop, GoProxyUrl};
29use crate::types::GoVersion;
30use crate::version::{escape_module_path, escape_version, is_pseudo_version};
31use dashmap::DashMap;
32use deps_core::parser::DependencySource;
33use deps_core::{DepsError, HttpCache, Result, is_dot_segment, lsp_helpers::warn_rejected_value};
34use serde::Deserialize;
35use std::any::Any;
36use std::sync::Arc;
37
38const PROXY_BASE: &str = "https://proxy.golang.org";
39
40/// Display name for the Go module proxy used in not-found and API-response
41/// error messages.
42pub const REGISTRY: &str = "Go proxy";
43
44/// Base URL for Go package documentation
45pub const PKG_GO_DEV_URL: &str = "https://pkg.go.dev";
46
47/// Upper bound on [`GoRegistry::alternates`]' entry count. Generous for any realistic
48/// project's `$GOENV` configuration, exists only to keep this map — keyed by
49/// process-config-controlled chain identities — from growing unbounded for the process
50/// lifetime. Mirrors `deps-pypi`/`deps-npm`'s identical cap. Once at capacity, a *new* chain
51/// is simply never registered (see [`GoRegistry::register_chain`]) — a dependency resolved to
52/// an unregistered chain degrades to [`DepsError::PackageNotFound`], never to a
53/// `proxy.golang.org` lookup by name (spec FR-009/FR-013).
54const MAX_ALTERNATE_REGISTRIES: usize = 256;
55
56/// Which transport/behavior a [`GoRegistry`] instance uses (spec 034 FR-004/FR-006/FR-011).
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58enum GoRegistryTier {
59    /// `proxy.golang.org` (or a test override) — `HttpCache::get_cached`, today's path,
60    /// unchanged, never subject to `registries.workspace_registries` (FR-011).
61    Public,
62    /// A `$GOENV`-declared `GOPROXY` hop — `HttpCache::get_cached_workspace`, so every
63    /// redirect hop is re-classified against the live
64    /// [`deps_core::net_policy::RegistryAccessPolicy`] (mirrors `deps-pypi`/`deps-npm`'s
65    /// identical `WorkspaceDeclared` routing).
66    WorkspaceDeclared,
67    /// A [`GoProxyHop::Direct`]/[`GoProxyHop::Off`] sentinel (FR-004/FR-006) — every inherent
68    /// fetch method short-circuits to [`DepsError::PackageNotFound`] before building any URL
69    /// or issuing any request. Both sentinels are observably identical: phase 1 has no
70    /// direct-VCS resolution mechanism, and `off` disallows downloads outright.
71    Terminal,
72}
73
74/// Maximum allowed module path length to prevent DoS
75const MAX_MODULE_PATH_LENGTH: usize = 500;
76
77/// Maximum allowed version string length
78const MAX_VERSION_LENGTH: usize = 128;
79
80/// Validates a module path for length and basic format.
81///
82/// Rejections intentionally render through the shared `DepsError::InvalidVersionReq` variant
83/// rather than a Go-specific one — see #399.
84///
85/// # Errors
86///
87/// Returns error if:
88/// - Path is empty
89/// - Path exceeds MAX_MODULE_PATH_LENGTH
90/// - Any `/`-separated segment is exactly `.`/`..`
91///
92/// `pub(crate)` (not private) so [`crate::formatter::GoFormatter::validate_package_name`] can
93/// reuse the same structural rule for its "Invalid package name" diagnostic lint (#402).
94pub(crate) fn validate_module_path(module_path: &str) -> Result<()> {
95    if module_path.is_empty() {
96        return Err(DepsError::InvalidVersionReq("module path is empty".into()));
97    }
98
99    if module_path.len() > MAX_MODULE_PATH_LENGTH {
100        return Err(DepsError::InvalidVersionReq(format!(
101            "module path exceeds maximum length of {MAX_MODULE_PATH_LENGTH} characters"
102        )));
103    }
104
105    // `escape_module_path` deliberately passes `.` and `/` through unescaped — the Go
106    // module proxy protocol requires literal `/` for multi-segment paths, and Go module
107    // paths legitimately contain dots within a segment (e.g. `golang.org/x/mod`). But a
108    // segment that is *exactly* `.`/`..` is never a valid module path component, and once
109    // spliced into `{PROXY_BASE}/{escaped}/@v/list` (etc.) it is silently collapsed by the
110    // URL parser's dot-segment normalization — the same defect class as #341/#349/#357/#361.
111    if module_path.split('/').any(is_dot_segment) {
112        warn_rejected_value("is_dot_segment", "Go module proxy request URL", module_path);
113        return Err(DepsError::InvalidVersionReq(format!(
114            "module path '{module_path}' contains a `.`/`..` path segment"
115        )));
116    }
117
118    Ok(())
119}
120
121/// Builds the Go module proxy request URL for a module's version list, against `base`
122/// (`PROXY_BASE` for the public root; a resolved `$GOENV`-declared `GOPROXY` hop's own URL
123/// otherwise — spec 034 FR-013). Callers must run [`validate_module_path`] first —
124/// `escape_module_path` passes `.`/`/` through unescaped by design, so a `.`/`..` path
125/// segment reaches this unfiltered.
126fn versions_list_url_at(base: &str, module_path: &str) -> String {
127    let escaped = escape_module_path(module_path);
128    format!("{base}/{escaped}/@v/list")
129}
130
131/// Validates a version string for length and basic format.
132///
133/// # Errors
134///
135/// Returns error if:
136/// - Version is empty
137/// - Version exceeds MAX_VERSION_LENGTH
138/// - Version contains path traversal sequences
139fn validate_version_string(version: &str) -> Result<()> {
140    if version.is_empty() {
141        return Err(DepsError::InvalidVersionReq(
142            "version string is empty".into(),
143        ));
144    }
145
146    if version.len() > MAX_VERSION_LENGTH {
147        return Err(DepsError::InvalidVersionReq(format!(
148            "version string exceeds maximum length of {MAX_VERSION_LENGTH} characters"
149        )));
150    }
151
152    // Check for path traversal attempts
153    if version.contains("..") || version.contains('/') || version.contains('\\') {
154        return Err(DepsError::InvalidVersionReq(
155            "version string contains invalid characters".into(),
156        ));
157    }
158
159    Ok(())
160}
161
162/// Returns the URL for a module's documentation page on pkg.go.dev.
163///
164/// Each `/`-separated path segment is percent-encoded individually via
165/// `urlencoding::encode` (the same helper every other ecosystem uses), so `/` survives
166/// as the legitimate path separator in Go module paths (e.g.
167/// `github.com/gin-gonic/gin`) while every other character — including `%`, which a
168/// hand-rolled denylist would otherwise miss — is escaped.
169///
170/// Display link only, never fetched by this process — unlike a registry-fetch URL
171/// builder, so it is deliberately not gated against a `.`/`..` module-path segment (see
172/// [`deps_core::is_dot_segment`]'s doc for the fetch-sink-vs-display-link scope split, #379).
173pub fn package_url(module_path: &str) -> String {
174    let encoded = module_path
175        .split('/')
176        .map(urlencoding::encode)
177        .collect::<Vec<_>>()
178        .join("/");
179    format!("{PKG_GO_DEV_URL}/{encoded}")
180}
181
182/// Builds a `/@v/{version}.{suffix}` proxy URL (e.g. `.info`, `.mod`) for a module.
183///
184/// Both `module_path` and `version` are escaped via `escape_module_path` and
185/// `escape_version` respectively before interpolation, so a version string
186/// carrying `?`, `#`, or whitespace cannot retarget the request to a
187/// different endpoint or inject a query string / fragment (#377).
188fn version_url_at(base: &str, module_path: &str, version: &str, suffix: &str) -> String {
189    let escaped_module = escape_module_path(module_path);
190    let escaped_version = escape_version(version);
191    format!("{base}/{escaped_module}/@v/{escaped_version}.{suffix}")
192}
193
194/// Converts a `404`/`410` response into `DepsError::PackageNotFound`, passing through any
195/// other error unchanged.
196///
197/// `410 Gone` is included alongside `404` (spec 034 C1): the Go toolchain's own module-proxy
198/// client (`cmd/go/internal/web/api.go`) treats both as "module/version not found", and
199/// Athens/JFrog Artifactory/Sonatype Nexus/GitLab's Go proxy implementations — the exact
200/// `GOPROXY` chain hops this feature targets — return `410` for an absent module. Missing this
201/// left a real `410` response falling into `get_versions_chained`'s transport-failure arm,
202/// halting chain resolution instead of falling through to the next hop (FR-005).
203fn not_found_or(err: DepsError, module_path: &str) -> DepsError {
204    if matches!(
205        err,
206        DepsError::HttpStatus {
207            status: 404 | 410,
208            ..
209        }
210    ) {
211        DepsError::PackageNotFound {
212            package: module_path.to_string(),
213            registry: REGISTRY,
214        }
215    } else {
216        err
217    }
218}
219
220/// Client for interacting with proxy.golang.org.
221///
222/// Uses the Go module proxy protocol for version lookups and metadata.
223/// All requests are cached via the provided HttpCache.
224#[derive(Clone)]
225pub struct GoRegistry {
226    cache: Arc<HttpCache>,
227    /// Version-fetch base for **this client's own hop** — `PROXY_BASE` for the public root; a
228    /// resolved [`GoProxyUrl`]'s own URL for a `$GOENV`-declared `GOPROXY` hop; unused
229    /// (never reached — every fetch short-circuits first) for a `Terminal`-tier client.
230    proxy_base: String,
231    /// Which transport/behavior this client uses (spec 034 FR-004/FR-006/FR-011).
232    tier: GoRegistryTier,
233    /// Resolved chain-router clients, keyed by [`GoProxyChain::key`] (a `GOPROXY` chain) or
234    /// [`crate::config::GOPRIVATE_CHAIN_KEY`] (the `GOPRIVATE`-bypass chain). Only the root
235    /// (`Public`-tier) instance this crate constructs via [`Self::new`] ever registers into
236    /// this or is ever looked up by [`Self::alternate_client`] — a chain-hop leaf's own map is
237    /// always empty by construction, mirroring `deps-pypi`'s identical `alternates` invariant.
238    alternates: Arc<DashMap<String, Arc<Self>>>,
239    /// Resolved, already-constructed hop clients this instance falls through to when it (hop
240    /// 0) misses (spec FR-005), each paired with the [`ChainSeparator`] governing the
241    /// transition *into* it (spec 034 S2 — `,` = fall through only on not-found, `|` = fall
242    /// through on any error). Empty for the `Public`-tier root and every leaf hop — populated
243    /// only on the *head* client [`Self::register_chain`] builds for a multi-hop chain.
244    fallback_chain: Vec<(ChainSeparator, Arc<Self>)>,
245}
246
247impl GoRegistry {
248    /// Creates a new Go registry client with the given HTTP cache.
249    pub fn new(cache: Arc<HttpCache>) -> Self {
250        Self {
251            cache,
252            proxy_base: PROXY_BASE.to_string(),
253            tier: GoRegistryTier::Public,
254            alternates: Arc::new(DashMap::new()),
255            fallback_chain: Vec::new(),
256        }
257    }
258
259    /// Creates a [`GoRegistry`] client for one resolved `$GOENV`-declared `GOPROXY` hop —
260    /// `WorkspaceDeclared`-tier so it fetches through `HttpCache::get_cached_workspace`
261    /// (FR-011's redirect-hop gating) instead of the ungated public transport.
262    ///
263    /// `fallback_chain` is empty for every call except the *head* client
264    /// [`Self::register_chain`] builds for a multi-hop chain — every other hop is a leaf with
265    /// nothing further to fall through to, matching `deps-pypi`'s identical design.
266    #[must_use]
267    fn with_base(
268        cache: Arc<HttpCache>,
269        url: &GoProxyUrl,
270        fallback_chain: Vec<(ChainSeparator, Arc<Self>)>,
271    ) -> Self {
272        Self {
273            cache,
274            proxy_base: url.as_str().to_string(),
275            tier: GoRegistryTier::WorkspaceDeclared,
276            alternates: Arc::new(DashMap::new()),
277            fallback_chain,
278        }
279    }
280
281    /// Creates a `Terminal`-tier client (spec FR-004/FR-006) for a
282    /// [`GoProxyHop::Direct`]/[`GoProxyHop::Off`] chain entry — every inherent fetch method
283    /// short-circuits to [`DepsError::PackageNotFound`] before ever building a URL, so
284    /// `proxy_base` is left empty (never read).
285    #[must_use]
286    fn terminal(cache: Arc<HttpCache>, fallback_chain: Vec<(ChainSeparator, Arc<Self>)>) -> Self {
287        Self {
288            cache,
289            proxy_base: String::new(),
290            tier: GoRegistryTier::Terminal,
291            alternates: Arc::new(DashMap::new()),
292            fallback_chain,
293        }
294    }
295
296    /// Builds the client for one [`GoProxyHop`] — a leaf with an empty `fallback_chain`,
297    /// shared by [`Self::register_chain`] for every hop after the first.
298    fn hop_client(cache: &Arc<HttpCache>, hop: &GoProxyHop) -> Arc<Self> {
299        Arc::new(match hop {
300            GoProxyHop::Url(url) => Self::with_base(Arc::clone(cache), url, Vec::new()),
301            GoProxyHop::Direct | GoProxyHop::Off => Self::terminal(Arc::clone(cache), Vec::new()),
302        })
303    }
304
305    /// Builds the full hop chain for one [`GoProxyChain`] and inserts the head into
306    /// `root.alternates` under `chain.key`. Idempotent per key (a repeat registration for the
307    /// same key is a no-op), capacity-capped at `MAX_ALTERNATE_REGISTRIES`. Mirrors
308    /// `deps_pypi::PypiRegistry::register_chain` exactly in shape.
309    pub fn register_chain(root: &Arc<Self>, chain: &GoProxyChain) {
310        let Some((first_hop, rest_hops)) = chain.hops.split_first() else {
311            // Defensive: `GoEnvConfig::resolved_chains` never produces an empty-hop chain.
312            return;
313        };
314
315        // Read before `entry()`: `DashMap::len` read-locks every shard, and `entry()` holds a
316        // write guard on one — checking capacity from inside the `Vacant` arm would
317        // self-deadlock on that shard.
318        let at_capacity = root.alternates.len() >= MAX_ALTERNATE_REGISTRIES;
319
320        if let dashmap::mapref::entry::Entry::Vacant(slot) =
321            root.alternates.entry(chain.key.clone())
322        {
323            if at_capacity {
324                tracing::warn!(
325                    key = %chain.key,
326                    cap = MAX_ALTERNATE_REGISTRIES,
327                    "Go alternate proxy cap reached; not registering a new chain"
328                );
329                return;
330            }
331
332            // `chain.separators[i]` is the separator between `hops[i]` and `hops[i + 1]`
333            // (spec 034 S2); `rest_hops[i]` is `hops[i + 1]`, so both are indexed by `i`
334            // here. A shorter/empty `separators` (every hand-built test chain, and the
335            // single-hop `GOPRIVATE` chain) defaults every transition to `NotFoundOnly`.
336            let fallback_chain: Vec<(ChainSeparator, Arc<Self>)> = rest_hops
337                .iter()
338                .enumerate()
339                .map(|(i, hop)| {
340                    let sep = chain
341                        .separators
342                        .get(i)
343                        .copied()
344                        .unwrap_or(ChainSeparator::NotFoundOnly);
345                    (sep, Self::hop_client(&root.cache, hop))
346                })
347                .collect();
348
349            let head = match first_hop {
350                GoProxyHop::Url(url) => {
351                    Self::with_base(Arc::clone(&root.cache), url, fallback_chain)
352                }
353                GoProxyHop::Direct | GoProxyHop::Off => {
354                    Self::terminal(Arc::clone(&root.cache), fallback_chain)
355                }
356            };
357            slot.insert(Arc::new(head));
358        }
359    }
360
361    /// The registered client for `index` (a [`GoProxyChain::key`] or
362    /// [`crate::config::GOPRIVATE_CHAIN_KEY`]), if any — read-only, performs no registration,
363    /// no validation.
364    ///
365    /// Only ever meaningful on the **root**: a chain-hop leaf's own `alternates` map is
366    /// always empty by construction (`Self::with_base`/`Self::terminal` never populate
367    /// it).
368    #[must_use]
369    pub fn alternate_client(&self, index: &str) -> Option<Arc<Self>> {
370        self.alternates.get(index).map(|entry| Arc::clone(&entry))
371    }
372
373    /// S3 (spec 034 review): tries `/@latest` first — mirrors the public-path
374    /// `Registry::get_latest_matching` fast path — falling back to `/@v/list` on an `/@latest`
375    /// miss. `/@v/list` alone is incomplete for an untagged/pseudo-version-only module (#364);
376    /// a chain hop that only ever tried `/@v/list` silently lost version data for that case.
377    async fn get_versions_with_latest_fallback(&self, module_path: &str) -> Result<Vec<GoVersion>> {
378        if let Ok(latest) = self.get_latest(module_path).await {
379            return Ok(vec![latest]);
380        }
381        self.get_versions(module_path).await
382    }
383
384    /// FR-005/NFR-006: tries `self` (hop 0) first, then each already-resolved
385    /// `Self::fallback_chain` entry in order (each via
386    /// [`Self::get_versions_with_latest_fallback`], S3). A [`GoProxyHop::Direct`]/
387    /// [`GoProxyHop::Off`] hop's `Terminal`-tier fetch always returns `PackageNotFound` with
388    /// no network request, which this loop treats the same as an ordinary not-found response
389    /// and falls through past — implementing FR-005's "explicit not-found continues to the
390    /// next hop" rule uniformly for a proxy 404/410 and for reaching a terminal sentinel
391    /// (US-003).
392    ///
393    /// A transport failure (connection error, timeout, 5xx) on a hop is terminal for the
394    /// whole chain **unless** the [`ChainSeparator`] governing the transition to the next hop
395    /// is [`ChainSeparator::AnyError`] (spec 034 S2, the `|`-separator case) — mirrors
396    /// `deps-pypi`'s identical FR-005(c) trade-off for the default `,`-separated case:
397    /// silently falling through on transport failure risks resolving a module through a
398    /// fallback the user did not intend for the reachability state they are actually in.
399    async fn get_versions_chained(&self, module_path: &str) -> Result<Vec<GoVersion>> {
400        let mut last_miss: Result<Vec<GoVersion>> = Err(DepsError::PackageNotFound {
401            package: module_path.to_string(),
402            registry: REGISTRY,
403        });
404
405        // `next_seps[k]` is the separator governing hop `k`'s fallback to hop `k + 1` — `None`
406        // past the last hop (nothing to fall through to, so any error there is unconditionally
407        // terminal regardless of separator).
408        let hops: Vec<&Self> = std::iter::once(self)
409            .chain(self.fallback_chain.iter().map(|(_, hop)| hop.as_ref()))
410            .collect();
411        let next_seps: Vec<Option<ChainSeparator>> = self
412            .fallback_chain
413            .iter()
414            .map(|(sep, _)| Some(*sep))
415            .chain(std::iter::once(None))
416            .collect();
417
418        for (hop, next_sep) in hops.iter().zip(next_seps.iter()) {
419            match hop.get_versions_with_latest_fallback(module_path).await {
420                Ok(versions) if !versions.is_empty() => return Ok(versions),
421                Ok(empty) => last_miss = Ok(empty),
422                Err(DepsError::PackageNotFound { .. }) => {
423                    last_miss = Err(DepsError::PackageNotFound {
424                        package: module_path.to_string(),
425                        registry: REGISTRY,
426                    });
427                }
428                Err(other) => match next_sep {
429                    Some(ChainSeparator::AnyError) => {
430                        tracing::warn!(
431                            module = module_path,
432                            error = %other,
433                            "Go alternate-proxy chain hop failed, but the `|` separator \
434                             tolerates any error; falling through to the next hop"
435                        );
436                        last_miss = Err(other);
437                    }
438                    _ => {
439                        tracing::warn!(
440                            module = module_path,
441                            error = %other,
442                            "Go alternate-proxy chain resolution halted on a transport error — \
443                             not falling back to proxy.golang.org or the next configured hop"
444                        );
445                        return Err(DepsError::ChainResolutionHalted);
446                    }
447                },
448            }
449        }
450
451        last_miss
452    }
453
454    /// Fetches all versions for a module from the `/@v/list` endpoint.
455    ///
456    /// Returns versions in registry order (not sorted). Includes pseudo-versions.
457    ///
458    /// # Errors
459    ///
460    /// Returns an error if:
461    /// - HTTP request fails
462    /// - Response body is invalid UTF-8
463    /// - Module does not exist (404)
464    /// - Module path is invalid or too long
465    ///
466    /// # Examples
467    ///
468    /// ```no_run
469    /// # use deps_go::GoRegistry;
470    /// # use deps_core::HttpCache;
471    /// # use std::sync::Arc;
472    /// # #[tokio::main]
473    /// # async fn main() {
474    /// let cache = Arc::new(HttpCache::new());
475    /// let registry = GoRegistry::new(cache);
476    ///
477    /// let versions = registry.get_versions("github.com/gin-gonic/gin").await.unwrap();
478    /// assert!(!versions.is_empty());
479    /// # }
480    /// ```
481    pub async fn get_versions(&self, module_path: &str) -> Result<Vec<GoVersion>> {
482        if self.tier == GoRegistryTier::Terminal {
483            return Err(DepsError::PackageNotFound {
484                package: module_path.to_string(),
485                registry: REGISTRY,
486            });
487        }
488        validate_module_path(module_path)?;
489
490        let url = versions_list_url_at(&self.proxy_base, module_path);
491
492        let data = match self.tier {
493            GoRegistryTier::Public => self.cache.get_cached(&url).await,
494            GoRegistryTier::WorkspaceDeclared => self.cache.get_cached_workspace(&url).await,
495            GoRegistryTier::Terminal => unreachable!("short-circuited above"),
496        }
497        .map_err(|e| not_found_or(e, module_path))?;
498
499        parse_version_list(&data)
500    }
501
502    /// Fetches version metadata from the `/@v/{version}.info` endpoint.
503    ///
504    /// Returns version with timestamp information.
505    ///
506    /// # Errors
507    ///
508    /// Returns an error if:
509    /// - HTTP request fails
510    /// - JSON parsing fails
511    /// - Module path or version string is invalid
512    ///
513    /// # Examples
514    ///
515    /// ```no_run
516    /// # use deps_go::GoRegistry;
517    /// # use deps_core::HttpCache;
518    /// # use std::sync::Arc;
519    /// # #[tokio::main]
520    /// # async fn main() {
521    /// let cache = Arc::new(HttpCache::new());
522    /// let registry = GoRegistry::new(cache);
523    ///
524    /// let info = registry.get_version_info("github.com/gin-gonic/gin", "v1.9.1").await.unwrap();
525    /// assert_eq!(info.version, "v1.9.1");
526    /// # }
527    /// ```
528    pub async fn get_version_info(&self, module_path: &str, version: &str) -> Result<GoVersion> {
529        if self.tier == GoRegistryTier::Terminal {
530            return Err(DepsError::PackageNotFound {
531                package: module_path.to_string(),
532                registry: REGISTRY,
533            });
534        }
535        validate_module_path(module_path)?;
536        validate_version_string(version)?;
537
538        let url = version_url_at(&self.proxy_base, module_path, version, "info");
539
540        let data = match self.tier {
541            GoRegistryTier::Public => self.cache.get_cached(&url).await,
542            GoRegistryTier::WorkspaceDeclared => self.cache.get_cached_workspace(&url).await,
543            GoRegistryTier::Terminal => unreachable!("short-circuited above"),
544        }
545        .map_err(|e| not_found_or(e, module_path))?;
546
547        parse_version_info(module_path, &data)
548    }
549
550    /// Fetches latest version using the `/@latest` endpoint.
551    ///
552    /// Returns the latest stable version (non-pseudo).
553    ///
554    /// # Errors
555    ///
556    /// Returns an error if:
557    /// - HTTP request fails
558    /// - JSON parsing fails
559    /// - Module path is invalid
560    ///
561    /// # Examples
562    ///
563    /// ```no_run
564    /// # use deps_go::GoRegistry;
565    /// # use deps_core::HttpCache;
566    /// # use std::sync::Arc;
567    /// # #[tokio::main]
568    /// # async fn main() {
569    /// let cache = Arc::new(HttpCache::new());
570    /// let registry = GoRegistry::new(cache);
571    ///
572    /// let latest = registry.get_latest("github.com/gin-gonic/gin").await.unwrap();
573    /// assert!(!latest.is_pseudo);
574    /// # }
575    /// ```
576    pub async fn get_latest(&self, module_path: &str) -> Result<GoVersion> {
577        if self.tier == GoRegistryTier::Terminal {
578            return Err(DepsError::PackageNotFound {
579                package: module_path.to_string(),
580                registry: REGISTRY,
581            });
582        }
583        validate_module_path(module_path)?;
584
585        let escaped = escape_module_path(module_path);
586        let url = format!("{}/{escaped}/@latest", self.proxy_base);
587
588        let data = match self.tier {
589            GoRegistryTier::Public => self.cache.get_cached(&url).await,
590            GoRegistryTier::WorkspaceDeclared => self.cache.get_cached_workspace(&url).await,
591            GoRegistryTier::Terminal => unreachable!("short-circuited above"),
592        }
593        .map_err(|e| not_found_or(e, module_path))?;
594
595        parse_version_info(module_path, &data)
596    }
597
598    /// Fetches the go.mod file for a specific version.
599    ///
600    /// Returns the raw content of the go.mod file.
601    ///
602    /// # Errors
603    ///
604    /// Returns an error if:
605    /// - HTTP request fails
606    /// - Response body is invalid UTF-8
607    /// - Module path or version string is invalid
608    ///
609    /// # Examples
610    ///
611    /// ```no_run
612    /// # use deps_go::GoRegistry;
613    /// # use deps_core::HttpCache;
614    /// # use std::sync::Arc;
615    /// # #[tokio::main]
616    /// # async fn main() {
617    /// let cache = Arc::new(HttpCache::new());
618    /// let registry = GoRegistry::new(cache);
619    ///
620    /// let go_mod = registry.get_go_mod("github.com/gin-gonic/gin", "v1.9.1").await.unwrap();
621    /// assert!(go_mod.contains("module github.com/gin-gonic/gin"));
622    /// # }
623    /// ```
624    pub async fn get_go_mod(&self, module_path: &str, version: &str) -> Result<String> {
625        if self.tier == GoRegistryTier::Terminal {
626            return Err(DepsError::PackageNotFound {
627                package: module_path.to_string(),
628                registry: REGISTRY,
629            });
630        }
631        validate_module_path(module_path)?;
632        validate_version_string(version)?;
633
634        let url = version_url_at(&self.proxy_base, module_path, version, "mod");
635
636        let data = match self.tier {
637            GoRegistryTier::Public => self.cache.get_cached(&url).await,
638            GoRegistryTier::WorkspaceDeclared => self.cache.get_cached_workspace(&url).await,
639            GoRegistryTier::Terminal => unreachable!("short-circuited above"),
640        }
641        .map_err(|e| not_found_or(e, module_path))?;
642
643        std::str::from_utf8(&data)
644            .map(std::string::ToString::to_string)
645            .map_err(|e| DepsError::CacheError(format!("Invalid UTF-8 in go.mod: {e}")))
646    }
647}
648
649/// Version info response from proxy.golang.org.
650#[derive(Deserialize)]
651struct VersionInfo {
652    #[serde(rename = "Version")]
653    version: String,
654    #[serde(rename = "Time")]
655    time: String,
656}
657
658/// Parses newline-separated version list from `/@v/list` endpoint.
659///
660/// Versions are sorted in descending order (newest first) to ensure
661/// `find_latest_stable` returns the correct latest version.
662fn parse_version_list(data: &[u8]) -> Result<Vec<GoVersion>> {
663    let content = std::str::from_utf8(data).map_err(|e| {
664        DepsError::CacheError(format!("Invalid UTF-8 in version list response: {e}"))
665    })?;
666
667    // Parse versions with precomputed sort keys (Schwartzian transform)
668    // This avoids repeated regex/semver parsing during sort comparisons
669    let mut versions_with_keys: Vec<(GoVersion, Option<semver::Version>)> = content
670        .lines()
671        .filter(|line| !line.trim().is_empty())
672        .map(|line| {
673            let is_pseudo = is_pseudo_version(line);
674            let sort_key = parse_sort_key(line, is_pseudo);
675            let version = GoVersion {
676                version: line.into(),
677                // `/@v/list` carries no dates — a documented Go-specific
678                // limitation of this Ch2 path, not a parse failure.
679                published_at: None,
680                is_pseudo,
681                retracted: false,
682            };
683            (version, sort_key)
684        })
685        .collect();
686
687    // Sort by precomputed keys (descending - newest first)
688    versions_with_keys.sort_by(|a, b| match (&b.1, &a.1) {
689        (Some(v1), Some(v2)) => v1.cmp(v2),
690        (Some(_), None) => std::cmp::Ordering::Less,
691        (None, Some(_)) => std::cmp::Ordering::Greater,
692        (None, None) => b.0.version.as_str().cmp(a.0.version.as_str()),
693    });
694
695    Ok(versions_with_keys.into_iter().map(|(v, _)| v).collect())
696}
697
698/// Parses a version string into a semver::Version for sorting.
699/// Uses precomputed is_pseudo flag to avoid regex during sort.
700fn parse_sort_key(version: &str, is_pseudo: bool) -> Option<semver::Version> {
701    use crate::version::base_version_from_pseudo;
702
703    let clean = version.trim_start_matches('v').replace("+incompatible", "");
704    let cmp_str = if is_pseudo {
705        base_version_from_pseudo(version).unwrap_or(clean)
706    } else {
707        clean
708    };
709
710    // Parse only the X.Y.Z part, ignoring prerelease suffix
711    let base = cmp_str.split('-').next().unwrap_or(&cmp_str);
712    semver::Version::parse(base.trim_start_matches('v')).ok()
713}
714
715/// Parses JSON version info from `/@v/{version}.info` or `/@latest` endpoint.
716fn parse_version_info(module_path: &str, data: &[u8]) -> Result<GoVersion> {
717    let info: VersionInfo =
718        deps_core::parse_json_checked(data).map_err(|e| DepsError::ApiResponse {
719            package: module_path.to_string(),
720            registry: REGISTRY,
721            source: e,
722        })?;
723
724    let is_pseudo = is_pseudo_version(&info.version);
725    Ok(GoVersion {
726        version: info.version.into(),
727        published_at: deps_core::PublishTime::parse_rfc3339(&info.time),
728        is_pseudo,
729        retracted: false,
730    })
731}
732
733impl deps_core::Registry for GoRegistry {
734    fn get_versions<'a>(
735        &'a self,
736        name: &'a deps_core::PackageName,
737    ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn deps_core::Version>>>>
738    {
739        Box::pin(async move {
740            let versions = self.get_versions(name.as_str()).await?;
741            Ok(versions
742                .into_iter()
743                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
744                .collect())
745        })
746    }
747
748    fn get_latest_matching<'a>(
749        &'a self,
750        name: &'a deps_core::PackageName,
751        _req: &'a deps_core::VersionReq,
752    ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn deps_core::Version>>>>
753    {
754        Box::pin(async move {
755            // Try /@latest first (fast path)
756            if let Ok(version) = self.get_latest(name.as_str()).await {
757                return Ok(Some(Box::new(version) as Box<dyn deps_core::Version>));
758            }
759            // Fallback to /@v/list (/@latest is optional per Go proxy spec)
760            let versions = self.get_versions(name.as_str()).await?;
761            let latest = versions.into_iter().find(|v| !v.is_pseudo && !v.retracted);
762            Ok(latest.map(|v| Box::new(v) as Box<dyn deps_core::Version>))
763        })
764    }
765
766    /// Dispatches by `source` (spec 034 FR-013): an `AlternateRegistry` whose index has a
767    /// registered client routes through `GoRegistry::get_versions_chained` (FR-005's chain
768    /// walk); one with **no** registered client is `PackageNotFound`, never a fall back to
769    /// `proxy.golang.org` (Go always sets `mirrors_crates_io: false`, so Cargo's
770    /// mirror-degradation arm is dead here and must not be written — falling back would send
771    /// a private module path to the public proxy, the exact class of leak FR-008/FR-009
772    /// close). Every other source keeps today's public-registry path unchanged.
773    fn get_versions_from<'a>(
774        &'a self,
775        name: &'a deps_core::PackageName,
776        source: &'a DependencySource,
777        freshness: deps_core::FreshnessSettings,
778    ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn deps_core::Version>>>>
779    {
780        Box::pin(async move {
781            match source {
782                DependencySource::AlternateRegistry { index, .. } => {
783                    match self.alternate_client(index) {
784                        Some(client) => {
785                            let versions = client.get_versions_chained(name.as_str()).await?;
786                            Ok(versions
787                                .into_iter()
788                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
789                                .collect())
790                        }
791                        None => Err(DepsError::PackageNotFound {
792                            package: name.to_string(),
793                            registry: "alternate registry (not registered)",
794                        }),
795                    }
796                }
797                _ => deps_core::Registry::get_versions_with(self, name, freshness).await,
798            }
799        })
800    }
801
802    /// `get_versions_from`'s `get_latest_matching`-shaped counterpart — same dispatch, same
803    /// "never fall back to `proxy.golang.org` for an unregistered `AlternateRegistry`"
804    /// invariant. Derived from `GoRegistry::get_versions_chained` +
805    /// [`deps_core::Registry::select_latest_matching`] (mirrors `deps-pypi`'s identical
806    /// derivation) rather than an independent per-hop matching walk — the winning hop (first
807    /// hop with a non-empty version list) is selected once, and matching happens only within
808    /// that single hop's list. No `/@latest` fast path for a chain hop (unlike the plain
809    /// public-registry path below): phase 1 keeps this simple and correct rather than
810    /// optimizing an extra request off the private-proxy path.
811    fn get_latest_matching_from<'a>(
812        &'a self,
813        name: &'a deps_core::PackageName,
814        source: &'a DependencySource,
815        req: &'a deps_core::VersionReq,
816        _minimum_stability: Option<&'a str>,
817    ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Option<Box<dyn deps_core::Version>>>>
818    {
819        Box::pin(async move {
820            match source {
821                DependencySource::AlternateRegistry { index, .. } => {
822                    match self.alternate_client(index) {
823                        Some(client) => {
824                            let versions: Vec<Box<dyn deps_core::Version>> = client
825                                .get_versions_chained(name.as_str())
826                                .await?
827                                .into_iter()
828                                .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
829                                .collect();
830                            let idx = deps_core::Registry::select_latest_matching(
831                                client.as_ref(),
832                                &versions,
833                                req,
834                            );
835                            Ok(idx.and_then(|i| versions.into_iter().nth(i)))
836                        }
837                        None => Err(DepsError::PackageNotFound {
838                            package: name.to_string(),
839                            registry: "alternate registry (not registered)",
840                        }),
841                    }
842                }
843                // Preserves the plain public-registry path's existing `/@latest`
844                // fast-path/`/@v/list`-fallback behavior unchanged (NFR-005).
845                _ => deps_core::Registry::get_latest_matching(self, name, req).await,
846            }
847        })
848    }
849
850    fn search<'a>(
851        &'a self,
852        _query: &'a str,
853        _limit: usize,
854    ) -> deps_core::ecosystem::BoxFuture<'a, deps_core::Result<Vec<Box<dyn deps_core::Metadata>>>>
855    {
856        // proxy.golang.org doesn't support search
857        Box::pin(async move { Ok(vec![]) })
858    }
859
860    fn select_latest_matching(
861        &self,
862        versions: &[Box<dyn deps_core::Version>],
863        _req: &deps_core::VersionReq,
864    ) -> Option<usize> {
865        // Mirrors the `/@v/list` fallback branch of the inherent `get_latest_matching`
866        // above (the `/@latest` fast path isn't reachable here: this method is a pure,
867        // no-I/O pick over an already-fetched list). `req` is ignored, matching that
868        // fallback — Go module requirements are exact pins/MVS, not ranges.
869        //
870        // Go deliberately opts out of the shared existence-check ladder
871        // (`deps_core::select_latest_for_existence`, see #364): Go has no real per-version
872        // retraction (`retracted` is hardcoded `false`, see `reports_yanked` below), so the
873        // ladder's rung 3 would return `Some(0)` unconditionally on an all-prerelease `/@v/
874        // list` and short-circuit the `/@latest` fallback in `lifecycle.rs`, which resolves
875        // pseudo-versions `/@v/list` never enumerates.
876        versions
877            .iter()
878            .position(|v| !v.is_prerelease() && !v.removal_status().blocks_resolution())
879    }
880
881    // `Version::removal_status` is hardcoded to `Available` (`registry.rs:354`, `:401`) —
882    // the proxy's `/@v/list` fast path never surfaces `retract` data, so a
883    // yanked-check probe here would always come back empty (#233).
884    fn reports_yanked(&self) -> bool {
885        false
886    }
887
888    fn as_any(&self) -> &dyn Any {
889        self
890    }
891}
892
893#[cfg(test)]
894mod tests {
895    use super::*;
896
897    use std::assert_matches;
898
899    #[test]
900    fn test_parse_version_list() {
901        let data = b"v1.0.0\nv1.0.1\nv1.1.0\nv2.0.0\n";
902
903        let versions = parse_version_list(data).unwrap();
904        assert_eq!(versions.len(), 4);
905        // Sorted descending (newest first)
906        assert_eq!(versions[0].version, "v2.0.0");
907        assert_eq!(versions[1].version, "v1.1.0");
908        assert_eq!(versions[2].version, "v1.0.1");
909        assert_eq!(versions[3].version, "v1.0.0");
910        assert!(!versions[0].is_pseudo);
911    }
912
913    #[test]
914    fn test_parse_version_list_with_pseudo() {
915        let data = b"v1.0.0\nv0.0.0-20191109021931-daa7c04131f5\nv1.1.0\n";
916
917        let versions = parse_version_list(data).unwrap();
918        assert_eq!(versions.len(), 3);
919        // Sorted descending: v1.1.0, v1.0.0, v0.0.0-... (pseudo based on v0.0.0)
920        assert_eq!(versions[0].version, "v1.1.0");
921        assert!(!versions[0].is_pseudo);
922        assert_eq!(versions[1].version, "v1.0.0");
923        assert!(!versions[1].is_pseudo);
924        assert!(versions[2].is_pseudo);
925    }
926
927    #[test]
928    fn test_parse_version_list_empty() {
929        let data = b"";
930        let versions = parse_version_list(data).unwrap();
931        assert_eq!(versions.len(), 0);
932    }
933
934    #[test]
935    fn test_parse_version_list_blank_lines() {
936        let data = b"\n\n\n";
937        let versions = parse_version_list(data).unwrap();
938        assert_eq!(versions.len(), 0);
939    }
940
941    #[test]
942    fn test_parse_version_info() {
943        let json = r#"{"Version":"v1.9.1","Time":"2023-07-18T14:30:00Z"}"#;
944        let version = parse_version_info("github.com/gin-gonic/gin", json.as_bytes()).unwrap();
945        assert_eq!(version.version, "v1.9.1");
946        assert_eq!(
947            version.published_at,
948            deps_core::PublishTime::parse_rfc3339("2023-07-18T14:30:00Z")
949        );
950        assert!(!version.is_pseudo);
951    }
952
953    #[test]
954    fn test_parse_version_info_with_malformed_time() {
955        let json = r#"{"Version":"v1.9.1","Time":"not-a-timestamp"}"#;
956        let version = parse_version_info("github.com/gin-gonic/gin", json.as_bytes()).unwrap();
957        assert!(
958            version.published_at.is_none(),
959            "malformed Time degrades to None, not an error"
960        );
961    }
962
963    #[test]
964    fn test_parse_version_info_pseudo() {
965        let json =
966            r#"{"Version":"v0.0.0-20191109021931-daa7c04131f5","Time":"2019-11-09T02:19:31Z"}"#;
967        let version = parse_version_info("github.com/gin-gonic/gin", json.as_bytes()).unwrap();
968        assert_eq!(version.version, "v0.0.0-20191109021931-daa7c04131f5");
969        assert!(version.is_pseudo);
970    }
971
972    #[test]
973    fn test_parse_version_info_invalid_json() {
974        let json = b"not json";
975        let result = parse_version_info("github.com/gin-gonic/gin", json);
976        assert!(result.is_err());
977    }
978
979    #[test]
980    fn test_parse_version_info_nesting_at_max_depth_accepted() {
981        let depth = deps_core::MAX_JSON_NESTING_DEPTH;
982        let json = format!(
983            r#"{{"Version": "v1.0.0", "Time": "2024-01-01T00:00:00Z", "extra": {}1{}}}"#,
984            "[".repeat(depth - 1),
985            "]".repeat(depth - 1)
986        );
987        assert!(parse_version_info("github.com/gin-gonic/gin", json.as_bytes()).is_ok());
988    }
989
990    #[test]
991    fn test_parse_version_info_nesting_over_max_depth_rejected() {
992        let depth = deps_core::MAX_JSON_NESTING_DEPTH + 1;
993        let json = format!(
994            r#"{{"Version": "v1.0.0", "Time": "2024-01-01T00:00:00Z", "extra": {}1{}}}"#,
995            "[".repeat(depth),
996            "]".repeat(depth)
997        );
998        assert!(parse_version_info("github.com/gin-gonic/gin", json.as_bytes()).is_err());
999    }
1000
1001    #[test]
1002    fn test_not_found_or_maps_404_to_package_not_found() {
1003        let err = DepsError::HttpStatus {
1004            url: "https://proxy.golang.org/github.com/x/y/@v/list".into(),
1005            status: 404,
1006        };
1007        let result = not_found_or(err, "github.com/x/y");
1008        assert_matches!(
1009            result,
1010            DepsError::PackageNotFound { package, registry }
1011                if package == "github.com/x/y" && registry == REGISTRY
1012        );
1013    }
1014
1015    #[test]
1016    fn test_not_found_or_passes_through_non_404() {
1017        let err = DepsError::HttpStatus {
1018            url: "https://proxy.golang.org/github.com/x/y/@v/list".into(),
1019            status: 500,
1020        };
1021        let result = not_found_or(err, "github.com/x/y");
1022        assert_matches!(result, DepsError::HttpStatus { status: 500, .. });
1023    }
1024
1025    /// C1: a `410 Gone` response (Athens/Artifactory/Nexus/GitLab's not-found status) maps to
1026    /// `PackageNotFound` exactly like `404`, so FR-005's chain fallback fires for it too.
1027    #[test]
1028    fn test_not_found_or_maps_410_to_package_not_found() {
1029        let err = DepsError::HttpStatus {
1030            url: "https://goproxy.mycorp.example/github.com/x/y/@v/list".into(),
1031            status: 410,
1032        };
1033        let result = not_found_or(err, "github.com/x/y");
1034        assert_matches!(
1035            result,
1036            DepsError::PackageNotFound { package, registry }
1037                if package == "github.com/x/y" && registry == REGISTRY
1038        );
1039    }
1040
1041    #[test]
1042    fn test_package_url() {
1043        assert_eq!(
1044            package_url("github.com/gin-gonic/gin"),
1045            "https://pkg.go.dev/github.com/gin-gonic/gin"
1046        );
1047        assert_eq!(
1048            package_url("golang.org/x/crypto"),
1049            "https://pkg.go.dev/golang.org/x/crypto"
1050        );
1051    }
1052
1053    #[test]
1054    fn test_package_url_encodes_malicious_chars() {
1055        let url = package_url("github.com/evil](https://evil.example)[pkg");
1056        assert!(!url.contains('('));
1057        assert!(!url.contains(')'));
1058        assert!(!url.contains('['));
1059        assert!(!url.contains(']'));
1060        assert!(
1061            url.contains("github.com/evil"),
1062            "legitimate path preserved: {url}"
1063        );
1064    }
1065
1066    #[test]
1067    fn test_package_url_encodes_newline_autolink_and_percent() {
1068        let url = package_url("github.com/evil\n<https://evil%zz.example>");
1069        assert!(!url.contains('\n'));
1070        assert!(!url.contains('<'));
1071        assert!(!url.contains('>'));
1072        assert!(url.contains("%25"));
1073    }
1074
1075    #[test]
1076    fn test_package_url_empty_module_path() {
1077        assert_eq!(package_url(""), "https://pkg.go.dev/");
1078    }
1079
1080    /// Exercises `version_url` — the exact helper `get_version_info`/`get_go_mod` call —
1081    /// with a legitimate version, proving no regression: the URL is the expected literal
1082    /// string, not just "doesn't panic". Binds the guard to the production sink: deleting
1083    /// escaping from `version_url` would fail this test.
1084    #[test]
1085    fn test_info_url_construction_legitimate_version() {
1086        let url = version_url_at(PROXY_BASE, "github.com/gin-gonic/gin", "v1.9.1", "info");
1087        assert_eq!(
1088            url,
1089            "https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.info"
1090        );
1091    }
1092
1093    /// Same as above for a pseudo-version, which carries a `-` and digits only — must
1094    /// also pass through unescaped.
1095    #[test]
1096    fn test_info_url_construction_pseudo_version() {
1097        let url = version_url_at(
1098            PROXY_BASE,
1099            "github.com/user/repo",
1100            "v0.0.0-20210101000000-abcdef123456",
1101            "info",
1102        );
1103        assert_eq!(
1104            url,
1105            "https://proxy.golang.org/github.com/user/repo/@v/v0.0.0-20210101000000-abcdef123456.info"
1106        );
1107    }
1108
1109    /// #377 regression guard: a version string carrying `?`/`#`/space (which
1110    /// `validate_version_string` does NOT reject — only `..`, `/`, `\` are rejected) must
1111    /// not be able to inject a query string or fragment into the `.info` URL. Calls
1112    /// `version_url` directly (the same helper `get_version_info` calls) rather than
1113    /// duplicating its `format!` logic, so removing the escaping from the production sink
1114    /// fails this test.
1115    #[test]
1116    fn test_info_url_construction_rejects_query_and_fragment_injection() {
1117        let cases = [
1118            ("v1?a=b", "v1%3Fa%3Db"),
1119            ("v1#frag", "v1%23frag"),
1120            ("v1 x", "v1%20x"),
1121            ("v1&x=y", "v1%26x%3Dy"),
1122        ];
1123
1124        for (raw_version, expected_escaped) in cases {
1125            let url = version_url_at(PROXY_BASE, "github.com/gin-gonic/gin", raw_version, "info");
1126            let expected_url = format!(
1127                "https://proxy.golang.org/github.com/gin-gonic/gin/@v/{expected_escaped}.info"
1128            );
1129            assert_eq!(url, expected_url, "raw version: {raw_version:?}");
1130
1131            // The base URL up to the module path is fixed and query/fragment-free;
1132            // everything after MUST stay within the path component.
1133            let after_base = url
1134                .strip_prefix("https://proxy.golang.org/")
1135                .expect("URL must start with the proxy base");
1136            assert!(
1137                !after_base.contains('?'),
1138                "constructed URL must not contain a bare '?' for input {raw_version:?}: {url}"
1139            );
1140            assert!(
1141                !after_base.contains('#'),
1142                "constructed URL must not contain a bare '#' for input {raw_version:?}: {url}"
1143            );
1144            assert!(
1145                !after_base.contains(' '),
1146                "constructed URL must not contain a raw space for input {raw_version:?}: {url}"
1147            );
1148        }
1149    }
1150
1151    /// Same construction proof for `get_go_mod`'s `.mod` URL via `version_url`.
1152    #[test]
1153    fn test_mod_url_construction_legitimate_version() {
1154        let url = version_url_at(PROXY_BASE, "github.com/gin-gonic/gin", "v1.9.1", "mod");
1155        assert_eq!(
1156            url,
1157            "https://proxy.golang.org/github.com/gin-gonic/gin/@v/v1.9.1.mod"
1158        );
1159    }
1160
1161    /// #377 regression guard for the `.mod` endpoint — same injection proof as
1162    /// `test_info_url_construction_rejects_query_and_fragment_injection`, mirroring
1163    /// `get_go_mod`'s URL construction instead of `get_version_info`'s.
1164    #[test]
1165    fn test_mod_url_construction_rejects_query_and_fragment_injection() {
1166        let cases = [
1167            ("v1?a=b", "v1%3Fa%3Db"),
1168            ("v1#frag", "v1%23frag"),
1169            ("v1 x", "v1%20x"),
1170        ];
1171
1172        for (raw_version, expected_escaped) in cases {
1173            let url = version_url_at(PROXY_BASE, "github.com/gin-gonic/gin", raw_version, "mod");
1174            let expected_url = format!(
1175                "https://proxy.golang.org/github.com/gin-gonic/gin/@v/{expected_escaped}.mod"
1176            );
1177            assert_eq!(url, expected_url, "raw version: {raw_version:?}");
1178
1179            let after_base = url
1180                .strip_prefix("https://proxy.golang.org/")
1181                .expect("URL must start with the proxy base");
1182            assert!(!after_base.contains('?'), "raw version: {raw_version:?}");
1183            assert!(!after_base.contains('#'), "raw version: {raw_version:?}");
1184        }
1185    }
1186
1187    /// #377 S1 regression guard: `version_url` must case-fold uppercase in the version
1188    /// segment the same way `escape_module_path` folds module paths — a raw uppercase
1189    /// segment 404s against the real proxy (live-verified during review).
1190    #[test]
1191    fn test_info_url_construction_case_folds_uppercase_version() {
1192        let url = version_url_at(PROXY_BASE, "github.com/user/repo", "v1.7.0-RC", "info");
1193        assert_eq!(
1194            url,
1195            "https://proxy.golang.org/github.com/user/repo/@v/v1.7.0-!r!c.info"
1196        );
1197    }
1198
1199    #[tokio::test]
1200    async fn test_registry_creation() {
1201        let cache = Arc::new(HttpCache::new());
1202        let _registry = GoRegistry::new(cache);
1203    }
1204
1205    /// #365 end-to-end coverage (critic S2): exercises the real production
1206    /// `get_versions` — not a reimplemented gate+sink pair — proving the gate is actually
1207    /// wired into the call path a real completion/hover/diagnostic request would take. No
1208    /// mock is needed: the gate must reject before any network request is issued.
1209    /// The dot-segment gate rejects with `DepsError::InvalidVersionReq`, not
1210    /// `PackageNotFound` — this crate's existing not-found mapping is unrelated to the
1211    /// dot-segment gate and is left unchanged.
1212    #[tokio::test]
1213    async fn test_get_versions_rejects_bare_dot_dot_segment() {
1214        let registry = GoRegistry::new(Arc::new(HttpCache::new()));
1215        let err = registry
1216            .get_versions("github.com/user/..")
1217            .await
1218            .unwrap_err();
1219        assert_matches!(err, DepsError::InvalidVersionReq(_));
1220    }
1221
1222    #[tokio::test]
1223    async fn test_registry_clone() {
1224        let cache = Arc::new(HttpCache::new());
1225        let registry = GoRegistry::new(cache);
1226        let _cloned = registry;
1227    }
1228
1229    #[tokio::test]
1230    #[ignore]
1231    async fn test_fetch_real_gin_versions() {
1232        let cache = Arc::new(HttpCache::new());
1233        let registry = GoRegistry::new(cache);
1234        let versions = registry
1235            .get_versions("github.com/gin-gonic/gin")
1236            .await
1237            .unwrap();
1238
1239        assert!(!versions.is_empty());
1240        assert!(
1241            versions
1242                .iter()
1243                .any(|v| v.version.as_str().starts_with("v1."))
1244        );
1245    }
1246
1247    #[tokio::test]
1248    #[ignore]
1249    async fn test_fetch_real_version_info() {
1250        let cache = Arc::new(HttpCache::new());
1251        let registry = GoRegistry::new(cache);
1252        let info = registry
1253            .get_version_info("github.com/gin-gonic/gin", "v1.9.1")
1254            .await
1255            .unwrap();
1256
1257        assert_eq!(info.version, "v1.9.1");
1258        assert!(info.published_at.is_some());
1259    }
1260
1261    #[tokio::test]
1262    #[ignore]
1263    async fn test_fetch_real_latest() {
1264        let cache = Arc::new(HttpCache::new());
1265        let registry = GoRegistry::new(cache);
1266        let latest = registry
1267            .get_latest("github.com/gin-gonic/gin")
1268            .await
1269            .unwrap();
1270
1271        assert!(latest.version.as_str().starts_with('v'));
1272        assert!(!latest.is_pseudo);
1273    }
1274
1275    #[tokio::test]
1276    #[ignore]
1277    async fn test_fetch_real_go_mod() {
1278        let cache = Arc::new(HttpCache::new());
1279        let registry = GoRegistry::new(cache);
1280        let go_mod = registry
1281            .get_go_mod("github.com/gin-gonic/gin", "v1.9.1")
1282            .await
1283            .unwrap();
1284
1285        assert!(go_mod.contains("module github.com/gin-gonic/gin"));
1286    }
1287
1288    #[tokio::test]
1289    #[ignore]
1290    async fn test_module_not_found() {
1291        let cache = Arc::new(HttpCache::new());
1292        let registry = GoRegistry::new(cache);
1293        let result = registry
1294            .get_versions("github.com/nonexistent/module12345")
1295            .await;
1296        assert!(result.is_err());
1297    }
1298
1299    #[test]
1300    fn test_parse_version_list_mixed_stable_and_pseudo() {
1301        let data = b"v1.0.0\nv1.1.0-0.20200101000000-abcdefabcdef\nv1.2.0\nv1.2.1-beta.1\n";
1302        let versions = parse_version_list(data).unwrap();
1303        assert_eq!(versions.len(), 4);
1304        // Sorted descending: v1.2.1-beta.1, v1.2.0, v1.1.0-0...(pseudo), v1.0.0
1305        assert_eq!(versions[0].version, "v1.2.1-beta.1");
1306        assert!(!versions[0].is_pseudo); // prerelease, not pseudo
1307        assert_eq!(versions[1].version, "v1.2.0");
1308        assert!(!versions[1].is_pseudo);
1309        assert!(versions[2].is_pseudo); // pseudo-version based on v1.1.0
1310        assert_eq!(versions[3].version, "v1.0.0");
1311        assert!(!versions[3].is_pseudo);
1312    }
1313
1314    #[test]
1315    fn test_parse_version_list_invalid_utf8() {
1316        let data = &[0xFF, 0xFE, 0xFD]; // Invalid UTF-8
1317        let result = parse_version_list(data);
1318        assert!(result.is_err());
1319    }
1320
1321    #[test]
1322    fn test_parse_version_info_missing_fields() {
1323        let json = r#"{"Version":"v1.0.0"}"#; // Missing Time field
1324        let result = parse_version_info("github.com/gin-gonic/gin", json.as_bytes());
1325        assert!(result.is_err());
1326    }
1327
1328    #[test]
1329    fn test_validate_module_path_empty() {
1330        let result = validate_module_path("");
1331        match result {
1332            Err(DepsError::InvalidVersionReq(msg)) => assert_eq!(msg, "module path is empty"),
1333            other => panic!("expected InvalidVersionReq, got {other:?}"),
1334        }
1335    }
1336
1337    #[test]
1338    fn test_validate_module_path_too_long() {
1339        let long_path = "a".repeat(MAX_MODULE_PATH_LENGTH + 1);
1340        let result = validate_module_path(&long_path);
1341        assert!(result.is_err());
1342        assert_matches!(result, Err(DepsError::InvalidVersionReq(_)));
1343    }
1344
1345    #[test]
1346    fn test_validate_module_path_valid() {
1347        let result = validate_module_path("github.com/user/repo");
1348        assert!(result.is_ok());
1349    }
1350
1351    #[test]
1352    fn test_validate_module_path_rejects_bare_dot_dot_segment() {
1353        let result = validate_module_path("github.com/user/..");
1354        assert!(result.is_err());
1355        assert_matches!(result, Err(DepsError::InvalidVersionReq(_)));
1356    }
1357
1358    #[test]
1359    fn test_validate_module_path_rejects_bare_dot_segment() {
1360        let result = validate_module_path("./evil");
1361        assert!(result.is_err());
1362        assert_matches!(result, Err(DepsError::InvalidVersionReq(_)));
1363    }
1364
1365    #[test]
1366    fn test_validate_module_path_accepts_dots_within_a_segment() {
1367        // A dot inside a segment (a real Go module path, e.g. a domain component) is not a
1368        // dot-segment and must stay valid.
1369        assert!(validate_module_path("golang.org/x/mod").is_ok());
1370    }
1371
1372    /// Demonstrates the vulnerability the `validate_module_path` dot-segment check exists
1373    /// to prevent: `versions_list_url` alone (with no caller-side guard) builds a URL that,
1374    /// once parsed, has escaped the module path segment entirely.
1375    #[test]
1376    fn test_versions_list_url_bare_dot_dot_normalizes_above_proxy_root() {
1377        let url = versions_list_url_at(PROXY_BASE, "..");
1378        let parsed = url::Url::parse(&url).unwrap();
1379        assert_eq!(parsed.path(), "/@v/list", "parsed path: {}", parsed.path());
1380    }
1381
1382    /// #365 regression sweep: exercises the real production pair (`validate_module_path`
1383    /// gate + `versions_list_url` sink) against the shared adversarial input set, guarding
1384    /// against a 6th recurrence of the dot-segment defect class in this crate.
1385    #[test]
1386    fn test_versions_list_url_dot_segment_sweep() {
1387        deps_core::test_util::assert_dot_segment_gated_or_contained(
1388            |seg| {
1389                validate_module_path(seg)
1390                    .ok()
1391                    .map(|()| versions_list_url_at(PROXY_BASE, seg))
1392            },
1393            "proxy.golang.org",
1394            "/",
1395        );
1396    }
1397
1398    #[test]
1399    fn test_validate_version_string_empty() {
1400        let result = validate_version_string("");
1401        match result {
1402            Err(DepsError::InvalidVersionReq(msg)) => assert_eq!(msg, "version string is empty"),
1403            other => panic!("expected InvalidVersionReq, got {other:?}"),
1404        }
1405    }
1406
1407    #[test]
1408    fn test_validate_version_string_too_long() {
1409        let long_version = "v".to_string() + &"1".repeat(MAX_VERSION_LENGTH);
1410        let result = validate_version_string(&long_version);
1411        assert!(result.is_err());
1412        assert_matches!(result, Err(DepsError::InvalidVersionReq(_)));
1413    }
1414
1415    #[test]
1416    fn test_validate_version_string_path_traversal() {
1417        let result = validate_version_string("v1.0.0/../etc/passwd");
1418        assert!(result.is_err());
1419        assert_matches!(result, Err(DepsError::InvalidVersionReq(_)));
1420    }
1421
1422    #[test]
1423    fn test_validate_version_string_slashes() {
1424        let result = validate_version_string("v1.0.0/malicious");
1425        assert!(result.is_err());
1426
1427        let result = validate_version_string("v1.0.0\\malicious");
1428        assert!(result.is_err());
1429    }
1430
1431    #[test]
1432    fn test_validate_version_string_valid() {
1433        let result = validate_version_string("v1.0.0");
1434        assert!(result.is_ok());
1435
1436        let result = validate_version_string("v0.0.0-20191109021931-daa7c04131f5");
1437        assert!(result.is_ok());
1438    }
1439
1440    /// S8 regression guard: `select_latest_matching`'s pure pick must agree with
1441    /// `get_latest_matching`'s `/@v/list` fallback branch (the `/@latest` fast path is
1442    /// unreachable from `select_latest_matching`, which is pure and has no I/O) for an
1443    /// ordinary, non-empty version list — the exact class of divergence Maven's S3/S7 hit.
1444    #[test]
1445    fn test_select_latest_matching_agrees_with_get_latest_matching_list_fallback() {
1446        use deps_core::{Registry, VersionReq};
1447
1448        let cache = Arc::new(HttpCache::new());
1449        let registry = GoRegistry::new(cache);
1450        let typed = vec![
1451            GoVersion {
1452                version: "v2.0.0-pseudo".into(),
1453                published_at: None,
1454                is_pseudo: true,
1455                retracted: false,
1456            },
1457            GoVersion {
1458                version: "v1.5.0".into(),
1459                published_at: None,
1460                is_pseudo: false,
1461                retracted: true,
1462            },
1463            GoVersion {
1464                version: "v1.0.0".into(),
1465                published_at: None,
1466                is_pseudo: false,
1467                retracted: false,
1468            },
1469        ];
1470
1471        // Mirrors get_latest_matching's `/@v/list` fallback branch exactly.
1472        let fallback_pick = typed
1473            .iter()
1474            .find(|v| !v.is_pseudo && !v.retracted)
1475            .map(|v| v.version.to_string());
1476
1477        let boxed: Vec<Box<dyn deps_core::Version>> = typed
1478            .into_iter()
1479            .map(|v| Box::new(v) as Box<dyn deps_core::Version>)
1480            .collect();
1481        let idx = registry
1482            .select_latest_matching(&boxed, &VersionReq::new("*"))
1483            .expect("non-empty list must select an index");
1484
1485        assert_eq!(Some(boxed[idx].version_string().to_string()), fallback_pick);
1486        assert_eq!(fallback_pick.as_deref(), Some("v1.0.0"));
1487    }
1488
1489    #[test]
1490    fn test_select_latest_matching_not_default_none() {
1491        use deps_core::{Registry, VersionReq};
1492
1493        let cache = Arc::new(HttpCache::new());
1494        let registry = GoRegistry::new(cache);
1495        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1496            Box::new(GoVersion {
1497                version: "v2.0.0".into(),
1498                published_at: None,
1499                is_pseudo: false,
1500                retracted: true,
1501            }),
1502            Box::new(GoVersion {
1503                version: "v1.0.0".into(),
1504                published_at: None,
1505                is_pseudo: false,
1506                retracted: false,
1507            }),
1508        ];
1509        let req = VersionReq::new("*");
1510        assert_eq!(registry.select_latest_matching(&versions, &req), Some(1));
1511    }
1512
1513    /// #364 regression guard: Go deliberately does NOT adopt the shared existence-check
1514    /// ladder's rung 3 (`deps_core::select_latest_for_existence`'s unconditional "newest
1515    /// overall" fallback). A non-empty but all-pseudo-version `/@v/list` (every entry
1516    /// `is_prerelease()`) must still yield `None` here so the fetch loop's `/@latest`
1517    /// fallback in `lifecycle.rs` keeps firing — adopting rung 3 would return `Some(0)`
1518    /// unconditionally and short-circuit that fallback, silently returning a pseudo-version
1519    /// instead of resolving through the more complete `/@latest` endpoint.
1520    #[test]
1521    fn test_select_latest_matching_all_prerelease_stays_none_go_opts_out_of_ladder() {
1522        use deps_core::{Registry, VersionReq};
1523
1524        let cache = Arc::new(HttpCache::new());
1525        let registry = GoRegistry::new(cache);
1526        let versions: Vec<Box<dyn deps_core::Version>> = vec![
1527            Box::new(GoVersion {
1528                version: "v0.0.0-20191109021931-daa7c04131f5".into(),
1529                published_at: None,
1530                is_pseudo: true,
1531                retracted: false,
1532            }),
1533            Box::new(GoVersion {
1534                version: "v1.0.0-beta.1".into(),
1535                published_at: None,
1536                is_pseudo: false,
1537                retracted: false,
1538            }),
1539        ];
1540        let req = VersionReq::new("*");
1541        assert_eq!(
1542            registry.select_latest_matching(&versions, &req),
1543            None,
1544            "Go must not fall through to rung 3; a non-empty all-prerelease list must \
1545             still yield None so the /@latest fallback fires"
1546        );
1547    }
1548
1549    // --- spec 034: GOPROXY/GOPRIVATE chain routing ---
1550
1551    use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
1552
1553    fn all_policy() -> RegistryAccessPolicy {
1554        RegistryAccessPolicy::new(WorkspaceRegistryAccess::All)
1555    }
1556
1557    fn url_hop(raw: &str, policy: &RegistryAccessPolicy) -> GoProxyHop {
1558        GoProxyHop::Url(GoProxyUrl::new(raw, policy).unwrap())
1559    }
1560
1561    #[test]
1562    fn test_register_chain_and_alternate_client_roundtrip() {
1563        let cache = Arc::new(HttpCache::new());
1564        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1565        let chain = GoProxyChain {
1566            key: "go-proxy:test".to_string(),
1567            hops: vec![url_hop("https://goproxy.mycorp.example", &all_policy())],
1568            ..Default::default()
1569        };
1570        GoRegistry::register_chain(&root, &chain);
1571        assert!(root.alternate_client("go-proxy:test").is_some());
1572        assert!(root.alternate_client("nonexistent").is_none());
1573    }
1574
1575    #[test]
1576    fn test_register_chain_idempotent() {
1577        let cache = Arc::new(HttpCache::new());
1578        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1579        let chain = GoProxyChain {
1580            key: "go-proxy:test".to_string(),
1581            hops: vec![url_hop("https://goproxy.mycorp.example", &all_policy())],
1582            ..Default::default()
1583        };
1584        GoRegistry::register_chain(&root, &chain);
1585        let first = root.alternate_client("go-proxy:test").unwrap();
1586        GoRegistry::register_chain(&root, &chain);
1587        let second = root.alternate_client("go-proxy:test").unwrap();
1588        assert!(Arc::ptr_eq(&first, &second));
1589    }
1590
1591    /// US-001: a registered single-hop chain routes `get_versions_from` there instead of
1592    /// `proxy.golang.org`.
1593    #[tokio::test]
1594    async fn test_get_versions_from_routes_to_registered_alternate() {
1595        use deps_core::{FreshnessSettings, Registry};
1596
1597        let mut alt_server = mockito::Server::new_async().await;
1598        alt_server
1599            .mock("GET", "/github.com/gin-gonic/gin/@v/list")
1600            .with_status(200)
1601            .with_body("v1.9.1\n")
1602            .create_async()
1603            .await;
1604
1605        let cache = Arc::new(HttpCache::new());
1606        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1607        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1608        let policy = all_policy();
1609        let chain = GoProxyChain {
1610            key: "go-proxy:test".to_string(),
1611            hops: vec![url_hop(&alt_server.url(), &policy)],
1612            ..Default::default()
1613        };
1614        GoRegistry::register_chain(&root, &chain);
1615
1616        let source = DependencySource::AlternateRegistry {
1617            index: "go-proxy:test".to_string(),
1618            mirrors_crates_io: false,
1619        };
1620        let versions = root
1621            .get_versions_from(
1622                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1623                &source,
1624                FreshnessSettings::default(),
1625            )
1626            .await
1627            .unwrap();
1628        assert_eq!(versions.len(), 1);
1629    }
1630
1631    /// FR-005: a module absent from hop 0 (explicit not-found) falls through to hop 1.
1632    #[tokio::test]
1633    async fn test_get_versions_from_falls_through_on_not_found() {
1634        use deps_core::{FreshnessSettings, Registry};
1635
1636        let mut hop0 = mockito::Server::new_async().await;
1637        hop0.mock("GET", mockito::Matcher::Any)
1638            .with_status(404)
1639            .create_async()
1640            .await;
1641        let mut hop1 = mockito::Server::new_async().await;
1642        hop1.mock("GET", "/github.com/gin-gonic/gin/@v/list")
1643            .with_status(200)
1644            .with_body("v1.9.1\n")
1645            .create_async()
1646            .await;
1647
1648        let cache = Arc::new(HttpCache::new());
1649        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1650        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1651        let policy = all_policy();
1652        let chain = GoProxyChain {
1653            key: "go-proxy:test".to_string(),
1654            hops: vec![url_hop(&hop0.url(), &policy), url_hop(&hop1.url(), &policy)],
1655            ..Default::default()
1656        };
1657        GoRegistry::register_chain(&root, &chain);
1658
1659        let source = DependencySource::AlternateRegistry {
1660            index: "go-proxy:test".to_string(),
1661            mirrors_crates_io: false,
1662        };
1663        let versions = root
1664            .get_versions_from(
1665                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1666                &source,
1667                FreshnessSettings::default(),
1668            )
1669            .await
1670            .unwrap();
1671        assert_eq!(versions.len(), 1);
1672    }
1673
1674    /// C1: a `410 Gone` not-found response (Athens/Artifactory/Nexus/GitLab's shape) falls
1675    /// through to the next hop exactly like a `404` — the primary GOPROXY chain-fallback
1676    /// scenario (US-001) against the real-world proxies this feature targets.
1677    #[tokio::test]
1678    async fn test_get_versions_from_falls_through_on_410() {
1679        use deps_core::{FreshnessSettings, Registry};
1680
1681        let mut hop0 = mockito::Server::new_async().await;
1682        hop0.mock("GET", mockito::Matcher::Any)
1683            .with_status(410)
1684            .create_async()
1685            .await;
1686        let mut hop1 = mockito::Server::new_async().await;
1687        hop1.mock("GET", "/github.com/gin-gonic/gin/@v/list")
1688            .with_status(200)
1689            .with_body("v1.9.1\n")
1690            .create_async()
1691            .await;
1692
1693        let cache = Arc::new(HttpCache::new());
1694        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1695        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1696        let policy = all_policy();
1697        let chain = GoProxyChain {
1698            key: "go-proxy:test".to_string(),
1699            hops: vec![url_hop(&hop0.url(), &policy), url_hop(&hop1.url(), &policy)],
1700            ..Default::default()
1701        };
1702        GoRegistry::register_chain(&root, &chain);
1703
1704        let source = DependencySource::AlternateRegistry {
1705            index: "go-proxy:test".to_string(),
1706            mirrors_crates_io: false,
1707        };
1708        let versions = root
1709            .get_versions_from(
1710                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1711                &source,
1712                FreshnessSettings::default(),
1713            )
1714            .await
1715            .unwrap();
1716        assert_eq!(versions.len(), 1);
1717    }
1718
1719    /// FR-005: a transport failure (5xx) on hop 0 is terminal for the whole chain — never
1720    /// silently falls through to hop 1.
1721    #[tokio::test]
1722    async fn test_get_versions_from_transport_failure_is_terminal() {
1723        use deps_core::{FreshnessSettings, Registry};
1724
1725        let mut hop0 = mockito::Server::new_async().await;
1726        hop0.mock("GET", mockito::Matcher::Any)
1727            .with_status(500)
1728            .create_async()
1729            .await;
1730        let mut hop1 = mockito::Server::new_async().await;
1731        let hop1_mock = hop1
1732            .mock("GET", mockito::Matcher::Any)
1733            .with_status(200)
1734            .with_body("v1.9.1\n")
1735            .expect(0)
1736            .create_async()
1737            .await;
1738
1739        let cache = Arc::new(HttpCache::new());
1740        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1741        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1742        let policy = all_policy();
1743        let chain = GoProxyChain {
1744            key: "go-proxy:test".to_string(),
1745            hops: vec![url_hop(&hop0.url(), &policy), url_hop(&hop1.url(), &policy)],
1746            ..Default::default()
1747        };
1748        GoRegistry::register_chain(&root, &chain);
1749
1750        let source = DependencySource::AlternateRegistry {
1751            index: "go-proxy:test".to_string(),
1752            mirrors_crates_io: false,
1753        };
1754        let result = root
1755            .get_versions_from(
1756                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1757                &source,
1758                FreshnessSettings::default(),
1759            )
1760            .await;
1761        assert_matches!(result.err(), Some(DepsError::ChainResolutionHalted));
1762        hop1_mock.assert_async().await;
1763    }
1764
1765    /// S2: with a `|`-separated chain, a transport failure (5xx) on hop 0 falls through to
1766    /// hop 1 instead of halting — the opposite of the `,`-separated default tested above.
1767    #[tokio::test]
1768    async fn test_pipe_separator_falls_through_on_transport_failure() {
1769        use deps_core::{FreshnessSettings, Registry};
1770
1771        let mut hop0 = mockito::Server::new_async().await;
1772        hop0.mock("GET", mockito::Matcher::Any)
1773            .with_status(500)
1774            .create_async()
1775            .await;
1776        let mut hop1 = mockito::Server::new_async().await;
1777        hop1.mock("GET", "/github.com/gin-gonic/gin/@v/list")
1778            .with_status(200)
1779            .with_body("v1.9.1\n")
1780            .create_async()
1781            .await;
1782
1783        let cache = Arc::new(HttpCache::new());
1784        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1785        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1786        let policy = all_policy();
1787        let chain = GoProxyChain {
1788            key: "go-proxy:test".to_string(),
1789            hops: vec![url_hop(&hop0.url(), &policy), url_hop(&hop1.url(), &policy)],
1790            separators: vec![ChainSeparator::AnyError],
1791        };
1792        GoRegistry::register_chain(&root, &chain);
1793
1794        let source = DependencySource::AlternateRegistry {
1795            index: "go-proxy:test".to_string(),
1796            mirrors_crates_io: false,
1797        };
1798        let versions = root
1799            .get_versions_from(
1800                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1801                &source,
1802                FreshnessSettings::default(),
1803            )
1804            .await
1805            .unwrap();
1806        assert_eq!(versions.len(), 1);
1807    }
1808
1809    /// S3: a chain hop whose `/@v/list` is empty (an untagged/pseudo-version-only module, the
1810    /// same class of module #364 added the `/@latest` fallback for on the public path) still
1811    /// yields version data via `/@latest`, instead of silently losing it.
1812    #[tokio::test]
1813    async fn test_chain_hop_falls_back_to_latest_when_list_is_empty() {
1814        use deps_core::{FreshnessSettings, Registry};
1815
1816        let mut hop = mockito::Server::new_async().await;
1817        hop.mock("GET", "/github.com/gin-gonic/gin/@latest")
1818            .with_status(200)
1819            .with_body(
1820                r#"{"Version":"v0.0.0-20191109021931-daa7c04131f5","Time":"2019-11-09T02:19:31Z"}"#,
1821            )
1822            .create_async()
1823            .await;
1824        hop.mock("GET", "/github.com/gin-gonic/gin/@v/list")
1825            .with_status(200)
1826            .with_body("")
1827            .create_async()
1828            .await;
1829
1830        let cache = Arc::new(HttpCache::new());
1831        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1832        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1833        let policy = all_policy();
1834        let chain = GoProxyChain {
1835            key: "go-proxy:test".to_string(),
1836            hops: vec![url_hop(&hop.url(), &policy)],
1837            ..Default::default()
1838        };
1839        GoRegistry::register_chain(&root, &chain);
1840
1841        let source = DependencySource::AlternateRegistry {
1842            index: "go-proxy:test".to_string(),
1843            mirrors_crates_io: false,
1844        };
1845        let versions = root
1846            .get_versions_from(
1847                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1848                &source,
1849                FreshnessSettings::default(),
1850            )
1851            .await
1852            .unwrap();
1853        assert_eq!(versions.len(), 1);
1854        assert!(
1855            versions[0].is_prerelease(),
1856            "expected the pseudo-version from /@latest"
1857        );
1858    }
1859
1860    /// US-003/FR-006: falling through past every proxy hop to a `direct` terminal hop shows
1861    /// no data, with zero requests for that hop.
1862    #[tokio::test]
1863    async fn test_direct_terminal_hop_shows_no_data_zero_requests() {
1864        use deps_core::{FreshnessSettings, Registry};
1865
1866        let mut hop0 = mockito::Server::new_async().await;
1867        hop0.mock("GET", mockito::Matcher::Any)
1868            .with_status(404)
1869            .create_async()
1870            .await;
1871
1872        let cache = Arc::new(HttpCache::new());
1873        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1874        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1875        let policy = all_policy();
1876        let chain = GoProxyChain {
1877            key: "go-proxy:test".to_string(),
1878            hops: vec![url_hop(&hop0.url(), &policy), GoProxyHop::Direct],
1879            ..Default::default()
1880        };
1881        GoRegistry::register_chain(&root, &chain);
1882
1883        let source = DependencySource::AlternateRegistry {
1884            index: "go-proxy:test".to_string(),
1885            mirrors_crates_io: false,
1886        };
1887        let result = root
1888            .get_versions_from(
1889                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1890                &source,
1891                FreshnessSettings::default(),
1892            )
1893            .await;
1894        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
1895    }
1896
1897    /// SC-002 (issue #559 follow-up): a `GOPRIVATE`-matched module's resolved source routes
1898    /// to the bypass chain *and* the registered `GOPROXY` chain never receives a request for
1899    /// it — combined into one resolution call, where previously each half was proven by a
1900    /// separate unit test.
1901    #[tokio::test]
1902    async fn test_goprivate_bypass_sends_zero_requests_to_goproxy() {
1903        use deps_core::{FreshnessSettings, Registry};
1904
1905        let mut public_proxy = mockito::Server::new_async().await;
1906        let public_mock = public_proxy
1907            .mock("GET", mockito::Matcher::Any)
1908            .with_status(200)
1909            .with_body("v1.9.1\n")
1910            .expect(0)
1911            .create_async()
1912            .await;
1913
1914        let content = format!(
1915            "GOPROXY={},direct\nGOPRIVATE=git.mycorp.example/*\n",
1916            public_proxy.url()
1917        );
1918        let policy = all_policy();
1919        let go_config = crate::config::GoEnvConfig::parse(&content, &policy);
1920        let source = go_config.resolve_source_for("git.mycorp.example/internal/auth");
1921        assert_eq!(
1922            source,
1923            DependencySource::AlternateRegistry {
1924                index: crate::config::GOPRIVATE_CHAIN_KEY.to_string(),
1925                mirrors_crates_io: false,
1926            }
1927        );
1928
1929        let cache = Arc::new(HttpCache::new());
1930        cache.set_registry_policy(WorkspaceRegistryAccess::All);
1931        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1932        for chain in go_config.resolved_chains() {
1933            GoRegistry::register_chain(&root, &chain);
1934        }
1935
1936        let result = root
1937            .get_versions_from(
1938                &deps_core::PackageName::new("git.mycorp.example/internal/auth"),
1939                &source,
1940                FreshnessSettings::default(),
1941            )
1942            .await;
1943        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
1944        public_mock.assert_async().await;
1945    }
1946
1947    /// US-004: `GOPROXY=off` shows no data and issues zero requests.
1948    #[tokio::test]
1949    async fn test_off_hop_zero_requests() {
1950        use deps_core::{FreshnessSettings, Registry};
1951
1952        let cache = Arc::new(HttpCache::new());
1953        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
1954        let chain = GoProxyChain {
1955            key: "go-proxy:off".to_string(),
1956            hops: vec![GoProxyHop::Off],
1957            ..Default::default()
1958        };
1959        GoRegistry::register_chain(&root, &chain);
1960
1961        let source = DependencySource::AlternateRegistry {
1962            index: "go-proxy:off".to_string(),
1963            mirrors_crates_io: false,
1964        };
1965        let result = root
1966            .get_versions_from(
1967                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1968                &source,
1969                FreshnessSettings::default(),
1970            )
1971            .await;
1972        assert_matches!(result.err(), Some(DepsError::PackageNotFound { .. }));
1973    }
1974
1975    /// An `AlternateRegistry` source whose index has no registered client is
1976    /// `PackageNotFound`, never resolved by falling through to the plain public-registry
1977    /// path.
1978    #[tokio::test]
1979    async fn test_unregistered_alternate_never_falls_back_to_public() {
1980        use deps_core::{FreshnessSettings, Registry};
1981
1982        let cache = Arc::new(HttpCache::new());
1983        let root = Arc::new(GoRegistry::new(cache));
1984        let source = DependencySource::AlternateRegistry {
1985            index: "never-registered".to_string(),
1986            mirrors_crates_io: false,
1987        };
1988        let result = root
1989            .get_versions_from(
1990                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
1991                &source,
1992                FreshnessSettings::default(),
1993            )
1994            .await;
1995        assert_matches!(
1996            result.err(),
1997            Some(DepsError::PackageNotFound {
1998                registry: "alternate registry (not registered)",
1999                ..
2000            })
2001        );
2002    }
2003
2004    /// FR-013: `get_latest_matching_from` routes an `AlternateRegistry` source to the
2005    /// registered chain and picks the matching version.
2006    #[tokio::test]
2007    async fn test_get_latest_matching_from_routes_to_alternate() {
2008        use deps_core::{Registry, VersionReq};
2009
2010        let mut alt_server = mockito::Server::new_async().await;
2011        alt_server
2012            .mock("GET", "/github.com/gin-gonic/gin/@v/list")
2013            .with_status(200)
2014            .with_body("v1.9.0\nv1.9.1\n")
2015            .create_async()
2016            .await;
2017
2018        let cache = Arc::new(HttpCache::new());
2019        cache.set_registry_policy(WorkspaceRegistryAccess::All);
2020        let root = Arc::new(GoRegistry::new(Arc::clone(&cache)));
2021        let policy = all_policy();
2022        let chain = GoProxyChain {
2023            key: "go-proxy:test".to_string(),
2024            hops: vec![url_hop(&alt_server.url(), &policy)],
2025            ..Default::default()
2026        };
2027        GoRegistry::register_chain(&root, &chain);
2028
2029        let source = DependencySource::AlternateRegistry {
2030            index: "go-proxy:test".to_string(),
2031            mirrors_crates_io: false,
2032        };
2033        let latest = root
2034            .get_latest_matching_from(
2035                &deps_core::PackageName::new("github.com/gin-gonic/gin"),
2036                &source,
2037                &VersionReq::new("*"),
2038                None,
2039            )
2040            .await
2041            .unwrap();
2042        assert!(latest.is_some());
2043    }
2044
2045    /// A plain `DependencySource::Registry` source keeps the existing public-registry path
2046    /// (`/@latest` fast path) unchanged (NFR-005).
2047    #[tokio::test]
2048    async fn test_get_versions_from_plain_registry_source_unchanged() {
2049        use deps_core::{FreshnessSettings, Registry};
2050
2051        let cache = Arc::new(HttpCache::new());
2052        let root = GoRegistry::new(cache);
2053        let result = root
2054            .get_versions_from(
2055                &deps_core::PackageName::new("github.com/nonexistent/module12345"),
2056                &DependencySource::Registry,
2057                FreshnessSettings::default(),
2058            )
2059            .await;
2060        // No network mock configured — a real request would error, proving this path still
2061        // goes through the ordinary public fetch rather than being silently no-op'd.
2062        assert!(result.is_err());
2063    }
2064
2065    #[test]
2066    fn test_alternate_registries_cap_enforced() {
2067        let cache = Arc::new(HttpCache::new());
2068        let root = Arc::new(GoRegistry::new(cache));
2069        let policy = all_policy();
2070        for i in 0..MAX_ALTERNATE_REGISTRIES {
2071            let chain = GoProxyChain {
2072                key: format!("go-proxy:cap-{i}"),
2073                hops: vec![url_hop("https://goproxy.mycorp.example", &policy)],
2074                ..Default::default()
2075            };
2076            GoRegistry::register_chain(&root, &chain);
2077        }
2078        assert_eq!(root.alternates.len(), MAX_ALTERNATE_REGISTRIES);
2079
2080        let overflow = GoProxyChain {
2081            key: "go-proxy:overflow".to_string(),
2082            hops: vec![url_hop("https://goproxy.mycorp.example", &policy)],
2083            ..Default::default()
2084        };
2085        GoRegistry::register_chain(&root, &overflow);
2086        assert_eq!(root.alternates.len(), MAX_ALTERNATE_REGISTRIES);
2087        assert!(root.alternate_client("go-proxy:overflow").is_none());
2088    }
2089}