29e4777eec
Private Go modules have nowhere to live: goproxy is remote-only, so internal modules need VCS access or a hand-rolled proxy. - serve local goproxy repos over the GOPROXY protocol (`@v/list`, `@latest`, `.info`/`.mod`/`.zip`) with case-encoded module paths - accept PUT uploads of `.zip`/`.mod`/`.info` per version; synthesize `.info` when absent - validate zips with `golang.org/x/mod/zip.CheckZip`, `.mod` module path and `.info` version before storing - add a ContentValidator hook so providers can check upload bytes - document publishing in the UI and `docs/goproxy/local.md`; add an e2e-docker case Reviewed-on: #143 Co-authored-by: unkin-agent <unkin-agent@unkin.net> Co-committed-by: unkin-agent <unkin-agent@unkin.net>
199 lines
6.4 KiB
Go
199 lines
6.4 KiB
Go
package goproxy
|
|
|
|
import (
|
|
"archive/zip"
|
|
"context"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"golang.org/x/mod/module"
|
|
modzip "golang.org/x/mod/zip"
|
|
|
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
|
)
|
|
|
|
type fakeFileStore struct{ paths []string }
|
|
|
|
func (f *fakeFileStore) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
|
|
|
|
func (f *fakeFileStore) ListFilesByPrefix(_ context.Context, _, prefix string) ([]provider.FileEntry, error) {
|
|
var out []provider.FileEntry
|
|
for _, p := range f.paths {
|
|
if strings.HasPrefix(p, prefix) {
|
|
out = append(out, provider.FileEntry{FilePath: p, ContentHash: "sha256:x"})
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func TestValidateUpload(t *testing.T) {
|
|
p := &Provider{}
|
|
cases := []struct {
|
|
in, want string
|
|
ok bool
|
|
}{
|
|
{"example.unkin.net/hello/@v/v0.1.0.zip", "example.unkin.net/hello/@v/v0.1.0.zip", true},
|
|
{"/example.unkin.net/hello/@v/v0.1.0.mod", "example.unkin.net/hello/@v/v0.1.0.mod", true},
|
|
{"github.com/!burnt!sushi/toml/@v/v1.2.0.info", "github.com/!burnt!sushi/toml/@v/v1.2.0.info", true},
|
|
{"example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", "example.unkin.net/hello/v2/@v/v2.0.0-rc.1.zip", true},
|
|
{"github.com/BurntSushi/toml/@v/v1.2.0.zip", "", false},
|
|
{"example.unkin.net/hello/@v/v0.1.zip", "", false},
|
|
{"example.unkin.net/hello/@v/v2.0.0.zip", "", false},
|
|
{"example.unkin.net/hello/@v/v0.1.0.tar.gz", "", false},
|
|
{"example.unkin.net/hello/v0.1.0.zip", "", false},
|
|
{"example.unkin.net/hello/@v/list", "", false},
|
|
}
|
|
for _, c := range cases {
|
|
got, ct, err := p.ValidateUpload(c.in)
|
|
if (err == nil) != c.ok {
|
|
t.Errorf("%s: err=%v, want ok=%v", c.in, err, c.ok)
|
|
continue
|
|
}
|
|
if c.ok && (got != c.want || ct == "") {
|
|
t.Errorf("%s: got %q %q, want %q", c.in, got, ct, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeZip(t *testing.T, files map[string]string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "m.zip")
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zw := zip.NewWriter(f)
|
|
for name, body := range files {
|
|
w, err := zw.Create(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := w.Write([]byte(body)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func writeFile(t *testing.T, body string) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "f")
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func TestValidateContentZip(t *testing.T) {
|
|
p := &Provider{}
|
|
sp := "example.unkin.net/hello/@v/v0.1.0.zip"
|
|
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.unkin.net/hello\n"), 0o600)
|
|
os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0o600)
|
|
good := filepath.Join(t.TempDir(), "good.zip")
|
|
f, _ := os.Create(good)
|
|
if err := modzip.CreateFromDir(f, module.Version{Path: "example.unkin.net/hello", Version: "v0.1.0"}, dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f.Close()
|
|
if err := p.ValidateContent(sp, good); err != nil {
|
|
t.Errorf("valid zip rejected: %v", err)
|
|
}
|
|
|
|
bad := map[string]map[string]string{
|
|
"no prefix": {"go.mod": "module example.unkin.net/hello\n"},
|
|
"wrong version": {"example.unkin.net/hello@v0.2.0/go.mod": "module example.unkin.net/hello\n"},
|
|
"wrong module": {"example.unkin.net/other@v0.1.0/go.mod": "module example.unkin.net/other\n"},
|
|
}
|
|
for name, files := range bad {
|
|
if err := p.ValidateContent(sp, writeZip(t, files)); err == nil {
|
|
t.Errorf("%s: expected rejection", name)
|
|
}
|
|
}
|
|
if err := p.ValidateContent(sp, writeFile(t, "not a zip")); err == nil {
|
|
t.Error("non-zip accepted")
|
|
}
|
|
}
|
|
|
|
func TestValidateContentModInfo(t *testing.T) {
|
|
p := &Provider{}
|
|
mod := "example.unkin.net/hello/@v/v0.1.0.mod"
|
|
info := "example.unkin.net/hello/@v/v0.1.0.info"
|
|
cases := []struct {
|
|
path, body string
|
|
ok bool
|
|
}{
|
|
{mod, "module example.unkin.net/hello\n\ngo 1.25\n", true},
|
|
{mod, "module example.unkin.net/other\n", false},
|
|
{mod, "go 1.25\n", false},
|
|
{mod, "module (\n", false},
|
|
{info, `{"Version":"v0.1.0","Time":"2026-01-01T00:00:00Z"}`, true},
|
|
{info, `{"Version":"v0.2.0"}`, false},
|
|
{info, `nope`, false},
|
|
}
|
|
for _, c := range cases {
|
|
if err := p.ValidateContent(c.path, writeFile(t, c.body)); (err == nil) != c.ok {
|
|
t.Errorf("%s %q: err=%v, want ok=%v", c.path, c.body, err, c.ok)
|
|
}
|
|
}
|
|
}
|
|
|
|
func serve(t *testing.T, fs provider.FileStore, path string) (bool, int, string) {
|
|
t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
handled := (&Provider{}).ServeLocalIndex(rec, httptest.NewRequest("GET", "/"+path, nil), fs, "go-local", path)
|
|
return handled, rec.Code, rec.Body.String()
|
|
}
|
|
|
|
func TestServeLocalIndex(t *testing.T) {
|
|
fs := &fakeFileStore{paths: []string{
|
|
"example.unkin.net/hello/@v/v0.10.0.mod",
|
|
"example.unkin.net/hello/@v/v0.10.0.zip",
|
|
"example.unkin.net/hello/@v/v0.2.0.mod",
|
|
"example.unkin.net/hello/@v/v0.2.0.zip",
|
|
"example.unkin.net/hello/@v/v0.2.0.info",
|
|
"example.unkin.net/hello/@v/v0.11.0-rc.1.mod",
|
|
"example.unkin.net/hello/@v/v0.3.0.zip",
|
|
"example.unkin.net/hello/v2/@v/v2.0.0.mod",
|
|
"example.unkin.net/!pre/@v/v1.0.0-beta.mod",
|
|
}}
|
|
|
|
if h, code, body := serve(t, fs, "example.unkin.net/hello/@v/list"); !h || code != 200 || body != "v0.2.0\nv0.10.0\nv0.11.0-rc.1\n" {
|
|
t.Errorf("list: %v %d %q", h, code, body)
|
|
}
|
|
if h, code, body := serve(t, fs, "example.unkin.net/hello/@latest"); !h || code != 200 || body != `{"Version":"v0.10.0"}` {
|
|
t.Errorf("latest: %v %d %q", h, code, body)
|
|
}
|
|
if h, _, body := serve(t, fs, "example.unkin.net/!pre/@latest"); !h || body != `{"Version":"v1.0.0-beta"}` {
|
|
t.Errorf("prerelease-only latest: %q", body)
|
|
}
|
|
if h, code, _ := serve(t, fs, "example.unkin.net/missing/@latest"); !h || code != 404 {
|
|
t.Errorf("missing latest: %v %d", h, code)
|
|
}
|
|
if h, code, body := serve(t, fs, "example.unkin.net/missing/@v/list"); !h || code != 200 || body != "" {
|
|
t.Errorf("missing list: %v %d %q", h, code, body)
|
|
}
|
|
if h, _, body := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.info"); !h || body != `{"Version":"v0.10.0"}` {
|
|
t.Errorf("synthesized info: %v %q", h, body)
|
|
}
|
|
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.2.0.info"); h {
|
|
t.Error("uploaded .info must be served from storage")
|
|
}
|
|
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.3.0.info"); h {
|
|
t.Error(".info without a .mod must not be synthesized")
|
|
}
|
|
if h, _, _ := serve(t, fs, "example.unkin.net/hello/@v/v0.10.0.zip"); h {
|
|
t.Error(".zip must be served from storage")
|
|
}
|
|
}
|