diff --git a/README.md b/README.md index 660a658..cd926b1 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,27 @@ there's nothing to provision. To bring your own key instead, point `TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one. `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols. +### Local Go module proxy + +A local `goproxy` repo serves private modules over the GOPROXY protocol +(`@v/list`, `@latest`, `@v/.info|.mod|.zip`). Publish a version by +uploading its files under the case-encoded module path (uppercase as `!lower`): + +```sh +for ext in zip mod info; do + curl -fsSL --upload-file v0.1.0.$ext \ + https://artifactapi.k8s.syd1.au.unkin.net/api/v2/remotes/go-internal/files/example.unkin.net/hello/@v/v0.1.0.$ext +done +GOPROXY=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/go-internal,https://proxy.golang.org,direct \ +GONOSUMDB=example.unkin.net go get example.unkin.net/hello@latest +``` + +Build the zip with `golang.org/x/mod/zip` or reuse the files `go mod download` +leaves in `$(go env GOMODCACHE)/cache/download`. Uploads are validated: zips must +pass `zip.CheckZip` (every file under `module@version/`), `.mod` must declare the +module, and `.info` must match the version. `.info` is optional; it is +synthesized from the version when absent. `@v/list` lists versions with a `.mod`. + ### Local docker registry A local `docker` repo is a real container registry, not a mirror: it serves the diff --git a/e2e-docker/goproxy_local_test.go b/e2e-docker/goproxy_local_test.go new file mode 100644 index 0000000..8e190d6 --- /dev/null +++ b/e2e-docker/goproxy_local_test.go @@ -0,0 +1,57 @@ +//go:build dockere2e + +package e2edocker + +import ( + "bytes" + "net/http" + "os" + "path/filepath" + "testing" + + "golang.org/x/mod/module" + modzip "golang.org/x/mod/zip" +) + +// TestLocalGoProxy publishes two module versions and reads them back over the +// GOPROXY protocol. +func TestLocalGoProxy(t *testing.T) { + createRepo(t, `{"name":"local-go","package_type":"goproxy","repo_type":"local"}`) + defer deleteRepo(t, "local-go") + + const mod = "example.unkin.net/!hello" + gomod := []byte("module example.unkin.net/Hello\n\ngo 1.25\n") + var zip010 []byte + for _, ver := range []string{"v0.1.0", "v0.2.0"} { + dir := t.TempDir() + os.WriteFile(filepath.Join(dir, "go.mod"), gomod, 0o600) + os.WriteFile(filepath.Join(dir, "hello.go"), []byte("package hello\n"), 0o600) + var buf bytes.Buffer + if err := modzip.CreateFromDir(&buf, module.Version{Path: "example.unkin.net/Hello", Version: ver}, dir); err != nil { + t.Fatal(err) + } + if ver == "v0.1.0" { + zip010 = buf.Bytes() + } + uploadFile(t, "local-go", mod+"/@v/"+ver+".zip", buf.Bytes(), "application/zip") + uploadFile(t, "local-go", mod+"/@v/"+ver+".mod", gomod, "text/plain") + } + + resp, body := doRequest(t, http.MethodPut, api("/api/v2/remotes/local-go/files/"+mod+"/@v/v0.3.0.zip"), []byte("junk"), "application/zip") + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid zip: status %d: %s", resp.StatusCode, body) + } + + for path, want := range map[string]string{ + "/@v/list": "v0.1.0\nv0.2.0\n", + "/@latest": `{"Version":"v0.2.0"}`, + "/@v/v0.1.0.info": `{"Version":"v0.1.0"}`, + "/@v/v0.1.0.mod": string(gomod), + "/@v/v0.1.0.zip": string(zip010), + } { + resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/local-go/"+mod+path), nil, "") + if resp.StatusCode != http.StatusOK || string(body) != want { + t.Errorf("%s: status %d body %q", path, resp.StatusCode, body) + } + } +} diff --git a/go.mod b/go.mod index 73858b7..085b049 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 github.com/ulikunitz/xz v0.5.16 golang.org/x/crypto v0.54.0 + golang.org/x/mod v0.37.0 golang.org/x/sync v0.22.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index c0b4cad..a77e077 100644 --- a/go.sum +++ b/go.sum @@ -227,6 +227,8 @@ golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= @@ -247,6 +249,8 @@ golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/api/v2/local.go b/internal/api/v2/local.go index 52f2849..da5c947 100644 --- a/internal/api/v2/local.go +++ b/internal/api/v2/local.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "os" "github.com/go-chi/chi/v5" @@ -83,7 +84,22 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r return } - result, err := h.cas.Store(r.Context(), r.Body, contentType) + var body io.Reader = r.Body + if cv, ok := prov.(provider.ContentValidator); ok { + f, err := spoolBody(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("read upload: %v", err), http.StatusInternalServerError) + return + } + defer discard(f) + if err := cv.ValidateContent(storagePath, f.Name()); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + body = f + } + + result, err := h.cas.Store(r.Context(), body, contentType) if err != nil { http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError) return @@ -110,6 +126,27 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r writeJSON(w, http.StatusCreated, uploader.UploadResponse(storagePath, result.ContentHash, result.SizeBytes)) } +func spoolBody(r io.Reader) (*os.File, error) { + f, err := os.CreateTemp("", "artifactapi-upload-*") + if err != nil { + return nil, err + } + if _, err := io.Copy(f, r); err != nil { + discard(f) + return nil, err + } + if _, err := f.Seek(0, io.SeekStart); err != nil { + discard(f) + return nil, err + } + return f, nil +} + +func discard(f *os.File) { + _ = f.Close() + _ = os.Remove(f.Name()) +} + func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) { existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath) if err != nil { diff --git a/internal/api/v2/local_validate_test.go b/internal/api/v2/local_validate_test.go new file mode 100644 index 0000000..955f018 --- /dev/null +++ b/internal/api/v2/local_validate_test.go @@ -0,0 +1,55 @@ +package v2 + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "git.unkin.net/unkin/artifactapi/internal/database" + _ "git.unkin.net/unkin/artifactapi/internal/provider/goproxy" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +// TestLocalUploadContentRejected verifies a ContentValidator provider rejects +// bad bytes with 400 before anything is stored. +func TestLocalUploadContentRejected(t *testing.T) { + if testDSN == "" { + t.Skip("Docker unavailable") + } + ctx := context.Background() + db, err := database.New(testDSN) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + const repo = "go-local-validate" + if err := db.CreateRemote(ctx, &models.Remote{Name: repo, PackageType: models.PackageGoProxy, RepoType: models.RepoTypeLocal}); err != nil { + t.Fatal(err) + } + + router := chi.NewRouter() + router.Route("/remotes/{name}/files", func(r chi.Router) { + r.Put("/*", NewLocalHandler(db, nil).Routes().ServeHTTP) + }) + + cases := map[string]string{ + "example.unkin.net/hello/@v/v0.1.0.zip": "not a zip", + "example.unkin.net/hello/@v/v0.1.0.mod": "module example.unkin.net/other\n", + "example.unkin.net/hello/@v/v0.1.0.info": `{"Version":"v9.9.9"}`, + "example.unkin.net/Hello/@v/v0.1.0.mod": "module example.unkin.net/Hello\n", + } + for path, body := range cases { + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest("PUT", "/remotes/"+repo+"/files/"+path, strings.NewReader(body))) + if w.Code != 400 { + t.Errorf("%s: status %d, want 400 (%s)", path, w.Code, w.Body.String()) + } + if f, _ := db.GetLocalFile(ctx, repo, path); f != nil { + t.Errorf("%s: rejected upload was recorded", path) + } + } +} diff --git a/internal/provider/goproxy/local.go b/internal/provider/goproxy/local.go new file mode 100644 index 0000000..e7a4a96 --- /dev/null +++ b/internal/provider/goproxy/local.go @@ -0,0 +1,222 @@ +package goproxy + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "slices" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" + "golang.org/x/mod/semver" + modzip "golang.org/x/mod/zip" + + "git.unkin.net/unkin/artifactapi/internal/provider" +) + +type versionFile struct { + module string + version string + ext string +} + +// parseVersionFile splits "/@v/." into its parts. +func parseVersionFile(path string) (versionFile, error) { + escMod, file, ok := strings.Cut(strings.TrimPrefix(path, "/"), "/@v/") + if !ok || escMod == "" || strings.Contains(file, "/") { + return versionFile{}, fmt.Errorf("path %q must be /@v/.{zip,mod,info}", path) + } + dot := strings.LastIndex(file, ".") + if dot <= 0 { + return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path) + } + ext := file[dot+1:] + if ext != "zip" && ext != "mod" && ext != "info" { + return versionFile{}, fmt.Errorf("path %q must end in .zip, .mod or .info", path) + } + mod, err := module.UnescapePath(escMod) + if err != nil { + return versionFile{}, fmt.Errorf("module path must be case-encoded (uppercase as !lower): %w", err) + } + ver, err := module.UnescapeVersion(file[:dot]) + if err != nil { + return versionFile{}, err + } + if err := module.Check(mod, ver); err != nil { + return versionFile{}, err + } + if semver.Canonical(ver) != ver { + return versionFile{}, fmt.Errorf("version %q is not canonical semver", ver) + } + return versionFile{module: mod, version: ver, ext: ext}, nil +} + +func (v versionFile) storagePath() string { + escMod, _ := module.EscapePath(v.module) + escVer, _ := module.EscapeVersion(v.version) + return escMod + "/@v/" + escVer + "." + v.ext +} + +func (p *Provider) ValidateUpload(filePath string) (storagePath, contentType string, err error) { + vf, err := parseVersionFile(filePath) + if err != nil { + return "", "", err + } + sp := vf.storagePath() + return sp, p.ContentType(sp), nil +} + +func (p *Provider) ValidateContent(storagePath, localFile string) error { + vf, err := parseVersionFile(storagePath) + if err != nil { + return err + } + switch vf.ext { + case "zip": + if _, err := modzip.CheckZip(module.Version{Path: vf.module, Version: vf.version}, localFile); err != nil { + return fmt.Errorf("invalid module zip (files must sit under %s@%s/): %w", vf.module, vf.version, err) + } + case "mod": + data, err := os.ReadFile(localFile) + if err != nil { + return err + } + mf, err := modfile.ParseLax("go.mod", data, nil) + if err != nil { + return fmt.Errorf("invalid go.mod: %w", err) + } + if mf.Module == nil || mf.Module.Mod.Path != vf.module { + return fmt.Errorf("go.mod must declare module %s", vf.module) + } + case "info": + data, err := os.ReadFile(localFile) + if err != nil { + return err + } + var info struct{ Version string } + if err := json.Unmarshal(data, &info); err != nil { + return fmt.Errorf("invalid .info JSON: %w", err) + } + if info.Version != vf.version { + return fmt.Errorf(".info Version %q does not match %s", info.Version, vf.version) + } + } + return nil +} + +func (p *Provider) UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any { + vf, _ := parseVersionFile(storagePath) + return map[string]any{ + "module": vf.module, + "version": vf.version, + "path": storagePath, + "content_hash": contentHash, + "size_bytes": sizeBytes, + } +} + +// versions returns the canonical versions of escMod that have a .mod uploaded, sorted ascending. +func versions(ctx context.Context, files provider.FileStore, repoName, escMod string) (versions []string, infos map[string]bool, err error) { + entries, err := files.ListFilesByPrefix(ctx, repoName, escMod+"/@v/") + if err != nil { + return nil, nil, err + } + infos = map[string]bool{} + for _, e := range entries { + vf, err := parseVersionFile(e.FilePath) + if err != nil || vf.storagePath() != e.FilePath { + continue + } + switch vf.ext { + case "mod": + versions = append(versions, vf.version) + case "info": + infos[vf.version] = true + } + } + semver.Sort(versions) + return versions, infos, nil +} + +func latest(vs []string) string { + for i := len(vs) - 1; i >= 0; i-- { + if semver.Prerelease(vs[i]) == "" { + return vs[i] + } + } + if len(vs) == 0 { + return "" + } + return vs[len(vs)-1] +} + +func infoJSON(version string) []byte { + b, _ := json.Marshal(map[string]string{"Version": version}) + return b +} + +func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool { + path = strings.TrimPrefix(path, "/") + switch { + case strings.HasSuffix(path, "/@v/list"), strings.HasSuffix(path, "/@latest"): + case strings.HasSuffix(path, ".info"): + vf, err := parseVersionFile(path) + if err != nil { + return false + } + escMod, _ := module.EscapePath(vf.module) + vs, infos, err := versions(r.Context(), files, repoName, escMod) + if err != nil || infos[vf.version] || !slices.Contains(vs, vf.version) { + return false + } + default: + return false + } + + body, err := p.GenerateLocalIndex(r.Context(), files, repoName, path) + if err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return true + } + w.Header().Set("Content-Type", p.ContentType(path)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + return true +} + +func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) { + path = strings.TrimPrefix(path, "/") + if escMod, ok := strings.CutSuffix(path, "/@v/list"); ok { + vs, _, err := versions(ctx, files, repoName, escMod) + if err != nil { + return nil, err + } + var b strings.Builder + for _, v := range vs { + b.WriteString(v + "\n") + } + return []byte(b.String()), nil + } + if escMod, ok := strings.CutSuffix(path, "/@latest"); ok { + vs, _, err := versions(ctx, files, repoName, escMod) + if err != nil { + return nil, err + } + v := latest(vs) + if v == "" { + return nil, fmt.Errorf("no versions of %s", escMod) + } + return infoJSON(v), nil + } + if strings.HasSuffix(path, ".info") { + vf, err := parseVersionFile(path) + if err != nil { + return nil, err + } + return infoJSON(vf.version), nil + } + return nil, fmt.Errorf("unsupported index path: %q", path) +} diff --git a/internal/provider/goproxy/local_test.go b/internal/provider/goproxy/local_test.go new file mode 100644 index 0000000..bcadd37 --- /dev/null +++ b/internal/provider/goproxy/local_test.go @@ -0,0 +1,198 @@ +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") + } +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 8de9201..947ea5f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -41,6 +41,11 @@ type LocalUploader interface { UploadResponse(storagePath, contentHash string, sizeBytes int64) map[string]any } +// ContentValidator checks an upload's bytes (spooled to localFile) before it is stored. +type ContentValidator interface { + ValidateContent(storagePath, localFile string) error +} + type LocalIndexer interface { ServeLocalIndex(w http.ResponseWriter, r *http.Request, files FileStore, repoName, path string) bool GenerateLocalIndex(ctx context.Context, files FileStore, repoName, path string) ([]byte, error) diff --git a/ui/src/components/UsageInstructions.tsx b/ui/src/components/UsageInstructions.tsx index 228a924..9a2f593 100644 --- a/ui/src/components/UsageInstructions.tsx +++ b/ui/src/components/UsageInstructions.tsx @@ -218,15 +218,38 @@ sudo apk add --allow-untrusted `, ]; case 'goproxy': - return [ - { - title: 'Point the Go module proxy here', - language: 'bash', - code: `export GOPROXY=${proxy} + return isLocal + ? [ + { + title: 'Fetch private modules from this proxy', + language: 'bash', + code: `export GOPROXY=${url}/api/v1/local/${name},https://proxy.golang.org,direct +export GONOSUMDB=example.unkin.net +go get example.unkin.net/hello@latest`, + note: 'Set GONOSUMDB (or GOPRIVATE) to your module prefix: private modules are not in sum.golang.org.', + }, + { + title: 'Publish a module version', + language: 'bash', + code: `# build the zip with golang.org/x/mod/zip (CreateFromDir), or reuse +# the .zip/.mod/.info from $(go env GOMODCACHE)/cache/download after go mod download +MOD=example.unkin.net/hello VER=v0.1.0 +for ext in zip mod info; do + curl -fsSL --upload-file $VER.$ext \\ + ${url}/api/v2/remotes/${name}/files/$MOD/@v/$VER.$ext +done`, + note: 'Paths use the case-encoded module path (uppercase as !lower). Zips must hold every file under module@version/ and are rejected otherwise; .info is optional.', + }, + ] + : [ + { + title: 'Point the Go module proxy here', + language: 'bash', + code: `export GOPROXY=${proxy} go mod download`, - note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.', - }, - ]; + note: 'Append ,direct to fall back to VCS for modules this proxy does not cover.', + }, + ]; case 'cargo': return [