Files
zeekayandHanzo Dev 94b53f72ef feat(goproxy): resolve @latest and @v/list against upstream, not just the cache
Serving @v/list from the local cache alone was not a missing feature, it was a
wrong answer. The go command picks the highest version the list offers, so a
registry holding v1.0.0 of a module that is upstream at v1.9.0 silently pinned
every caller to the stale one -- a resolution bug that looks like a working
cache. @latest was worse: cacheFromUpstream refuses a non-concrete version, so
`go get mod@latest` on an uncached module 404'd outright.

Both now go through the one upstream seam:

- upstreamListVersions merges upstream's versions into @v/list. Ordering is
  left to the client, which already selects by semver correctly -- sorting here
  would be a second, weaker implementation of a decision made elsewhere.
- upstreamLatest answers "which version is latest" before anything tries to
  store one, so the caching path below still only ever sees concrete versions.
  One way for a module to get stored; no second path for a moving target.

Both honour GOPROXY_PRIVATE (a private import path must not reach a public
proxy -- listing leaks the same repo name a fetch would) and both fail soft: an
unreachable upstream degrades to the cached answer, never to an error.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-26 18:35:40 -07:00

159 lines
5.4 KiB
Go

// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// SPDX-License-Identifier: MIT
package goproxy
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/hanzoai/git/modules/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEscapeModule(t *testing.T) {
// The GOPROXY case-encoding. Getting this wrong is the classic bug where a
// module resolves for one client and 404s for another, so it is pinned.
cases := []struct{ in, want string }{
{"github.com/hanzoai/git", "github.com/hanzoai/git"},
{"github.com/BurntSushi/toml", "github.com/!burnt!sushi/toml"},
{"github.com/Masterminds/semver", "github.com/!masterminds/semver"},
{"ALLCAPS", "!a!l!l!c!a!p!s"},
{"", ""},
}
for _, c := range cases {
assert.Equal(t, c.want, escapeModule(c.in), "escapeModule(%q)", c.in)
}
}
func TestUpstreamEnabled(t *testing.T) {
defer func(v string) { setting.Packages.GoProxyUpstream = v }(setting.Packages.GoProxyUpstream)
// Empty means publish-only — the behaviour every deployment has until
// someone opts in. Whitespace must not accidentally enable it.
setting.Packages.GoProxyUpstream = ""
assert.False(t, upstreamEnabled())
setting.Packages.GoProxyUpstream = " "
assert.False(t, upstreamEnabled())
setting.Packages.GoProxyUpstream = "https://proxy.golang.org"
assert.True(t, upstreamEnabled())
}
func TestIsPrivateModule(t *testing.T) {
defer func(v string) { setting.Packages.GoProxyPrivate = v }(setting.Packages.GoProxyPrivate)
setting.Packages.GoProxyPrivate = "github.com/hanzoai/,github.com/luxfi/,git.hanzo.ai/"
// Private paths must never reach a public proxy -- the path alone leaks a
// repo name that may not be public yet.
assert.True(t, isPrivateModule("github.com/hanzoai/git"))
assert.True(t, isPrivateModule("github.com/luxfi/node"))
assert.True(t, isPrivateModule("git.hanzo.ai/hanzoai/cloud"))
// Public modules still cache.
assert.False(t, isPrivateModule("github.com/stretchr/testify"))
assert.False(t, isPrivateModule("golang.org/x/net"))
// A near-miss must NOT be treated as private (no accidental over-blocking).
assert.False(t, isPrivateModule("github.com/hanzoai-community/thing"))
// Empty config blocks nothing.
setting.Packages.GoProxyPrivate = ""
assert.False(t, isPrivateModule("github.com/hanzoai/git"))
}
func TestUpstreamListVersions(t *testing.T) {
defer func(u, p string) {
setting.Packages.GoProxyUpstream, setting.Packages.GoProxyPrivate = u, p
}(setting.Packages.GoProxyUpstream, setting.Packages.GoProxyPrivate)
setting.Packages.GoProxyPrivate = "github.com/hanzoai/"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/example.com/mod/@v/list" {
// Blank lines and stray whitespace are real in the wild.
w.Write([]byte("v1.0.0\nv1.1.0\n\n v1.2.0 \n"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
setting.Packages.GoProxyUpstream = srv.URL
got, err := upstreamListVersions("example.com/mod")
require.NoError(t, err)
assert.Equal(t, []string{"v1.0.0", "v1.1.0", "v1.2.0"}, got)
// A private module must never be listed upstream -- same leak as a fetch.
got, err = upstreamListVersions("github.com/hanzoai/secret")
require.NoError(t, err)
assert.Nil(t, got)
// Disabled upstream is not an error, just nothing to add.
setting.Packages.GoProxyUpstream = ""
got, err = upstreamListVersions("example.com/mod")
require.NoError(t, err)
assert.Nil(t, got)
}
func TestUpstreamLatest(t *testing.T) {
defer func(u, p string) {
setting.Packages.GoProxyUpstream, setting.Packages.GoProxyPrivate = u, p
}(setting.Packages.GoProxyUpstream, setting.Packages.GoProxyPrivate)
setting.Packages.GoProxyPrivate = "github.com/hanzoai/"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/example.com/mod/@latest":
w.Write([]byte(`{"Version":"v1.2.0","Time":"2026-01-01T00:00:00Z"}`))
case "/example.com/empty/@latest":
w.Write([]byte(`{}`))
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
setting.Packages.GoProxyUpstream = srv.URL
v, err := upstreamLatest("example.com/mod")
require.NoError(t, err)
assert.Equal(t, "v1.2.0", v)
// A response with no version must be an error, never an empty version that
// downstream would treat as a real one.
_, err = upstreamLatest("example.com/empty")
require.Error(t, err)
// Private modules never ask upstream what their latest is.
_, err = upstreamLatest("github.com/hanzoai/secret")
require.Error(t, err)
}
func TestUpstreamFetch(t *testing.T) {
defer func(v string) { setting.Packages.GoProxyUpstream = v }(setting.Packages.GoProxyUpstream)
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
if r.URL.Path == "/example.com/mod/@v/v1.0.0.zip" {
w.Write([]byte("ZIPBYTES"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// A trailing slash on the configured base must not produce a doubled slash.
setting.Packages.GoProxyUpstream = srv.URL + "/"
body, _, err := upstreamFetch("example.com/mod/@v/v1.0.0.zip")
require.NoError(t, err)
defer body.Close()
assert.Equal(t, "/example.com/mod/@v/v1.0.0.zip", gotPath)
// A non-200 is an error, never an empty body silently cached as a module.
setting.Packages.GoProxyUpstream = srv.URL
_, _, err = upstreamFetch("example.com/mod/@v/v9.9.9.zip")
require.Error(t, err)
}