Compare commits

..

1 Commits

Author SHA1 Message Date
unkin-agent 4c321a089d Add local cargo registry type
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
- serve sparse index and config.json for local cargo repos from a cargo_index table
- add Web API publish, yank and unyank under the local repo api root
- drop index lines when the crate file is deleted
2026-10-16 23:35:35 +11:00
20 changed files with 902 additions and 633 deletions
+14 -21
View File
@@ -261,27 +261,6 @@ 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/<ver>.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
@@ -300,6 +279,20 @@ content-addressable store (deduplicated by digest, reaped by GC once
unreferenced); tags are mutable references and re-pushing a tag moves it. Blob
uploads support both the monolithic and chunked (`POST`/`PATCH`/`PUT`) flows.
### Local cargo registry
A local `cargo` repo is a private crate registry: it serves the sparse index and
the Web API publish, yank and unyank endpoints, so `cargo publish` works against
it directly.
```toml
[registries.<repo>]
index = "sparse+https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>/"
```
cargo requires a token for publish/yank (`CARGO_REGISTRIES_<REPO>_TOKEN`); like
other local repos, artifactapi does not check it.
## Access Control
| Field | Default | Behaviour |
+2 -1
View File
@@ -27,7 +27,8 @@ already-running stack.
mock upstream: first fetch `X-Artifact-Source: remote`, second `cache`, bytes
verified against the origin fixture.
- **Local uploads** — generic (upload/download), pypi (wheel + generated `simple/`
index), rpm (real package + **automatic repodata** generation).
index), rpm (real package + **automatic repodata** generation), cargo (publish,
sparse index, download, yank).
- **Virtual repositories** — pypi simple-index merge and helm `index.yaml` merge
across two members.
- **Mirrorlist** — an rpm remote with a `mirrorlist` of extra upstream mirrors
+56
View File
@@ -0,0 +1,56 @@
//go:build dockere2e
package e2edocker
import (
"bytes"
"encoding/binary"
"net/http"
"strings"
"testing"
)
// TestLocalCargoRegistry publishes through the cargo Web API, then reads the
// sparse index, downloads the crate and yanks it.
func TestLocalCargoRegistry(t *testing.T) {
createRepo(t, `{"name":"local-cargo","package_type":"cargo","repo_type":"local"}`)
defer deleteRepo(t, "local-cargo")
base := api("/api/v1/local/local-cargo")
meta := `{"name":"e2e-crate","vers":"0.1.0","deps":[],"features":{}}`
crate := []byte("e2e crate bytes")
var body []byte
body = binary.LittleEndian.AppendUint32(body, uint32(len(meta)))
body = append(body, meta...)
body = binary.LittleEndian.AppendUint32(body, uint32(len(crate)))
body = append(body, crate...)
if resp, b := doRequest(t, http.MethodPut, base+"/api/v1/crates/new", body, "application/octet-stream"); resp.StatusCode != http.StatusOK {
t.Fatalf("publish: status %d: %s", resp.StatusCode, b)
}
if resp, _ := doRequest(t, http.MethodPut, base+"/api/v1/crates/new", body, "application/octet-stream"); resp.StatusCode != http.StatusConflict {
t.Fatalf("republish: status %d, want 409", resp.StatusCode)
}
resp, b := doRequest(t, http.MethodGet, base+"/config.json", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(b), `"api":"`) {
t.Fatalf("config.json: status %d: %s", resp.StatusCode, b)
}
resp, b = doRequest(t, http.MethodGet, base+"/e2/e-/e2e-crate", nil, "")
if resp.StatusCode != http.StatusOK || !strings.Contains(string(b), `"cksum":"`) || !strings.Contains(string(b), `"yanked":false`) {
t.Fatalf("index: status %d: %s", resp.StatusCode, b)
}
resp, b = doRequest(t, http.MethodGet, base+"/crates/e2e-crate/e2e-crate-0.1.0.crate", nil, "")
if resp.StatusCode != http.StatusOK || !bytes.Equal(b, crate) {
t.Fatalf("download: status %d", resp.StatusCode)
}
if resp, b := doRequest(t, http.MethodDelete, base+"/api/v1/crates/e2e-crate/0.1.0/yank", nil, ""); resp.StatusCode != http.StatusOK {
t.Fatalf("yank: status %d: %s", resp.StatusCode, b)
}
if _, b := doRequest(t, http.MethodGet, base+"/e2/e-/e2e-crate", nil, ""); !strings.Contains(string(b), `"yanked":true`) {
t.Fatalf("index after yank: %s", b)
}
}
-57
View File
@@ -1,57 +0,0 @@
//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)
}
}
}
-1
View File
@@ -20,7 +20,6 @@ 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
-4
View File
@@ -227,8 +227,6 @@ 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=
@@ -249,8 +247,6 @@ 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=
+148
View File
@@ -0,0 +1,148 @@
package v1
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.unkin.net/unkin/artifactapi/internal/database"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/internal/provider/cargo"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
// This file implements the cargo registry Web API (publish, yank, unyank) for
// local cargo repositories. config.json points api at /api/v1/local/<repo>, so
// cargo appends its own /api/v1/crates/... below that.
const maxCratePublish = 64 << 20
func (h *ProxyHandler) localCargoRemote(r *http.Request) (*models.Remote, bool) {
remote, err := h.db.GetRemote(r.Context(), chi.URLParam(r, "localName"))
if err != nil {
return nil, false
}
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageCargo
}
func cargoError(w http.ResponseWriter, status int, format string, args ...any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{
"errors": []map[string]string{{"detail": fmt.Sprintf(format, args...)}},
})
}
func cargoOK(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(v)
}
func (h *ProxyHandler) cargoPublish(w http.ResponseWriter, r *http.Request) {
remote, ok := h.localCargoRemote(r)
if !ok {
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
return
}
ctx := r.Context()
body, err := io.ReadAll(io.LimitReader(r.Body, maxCratePublish+1))
if err != nil {
cargoError(w, http.StatusBadRequest, "read body: %v", err)
return
}
if len(body) > maxCratePublish {
cargoError(w, http.StatusRequestEntityTooLarge, "publish body exceeds %d bytes", maxCratePublish)
return
}
meta, crate, err := cargo.ParsePublish(body)
if err != nil {
cargoError(w, http.StatusBadRequest, "%v", err)
return
}
line, err := cargo.NewIndexLine(meta, crate)
if err != nil {
cargoError(w, http.StatusBadRequest, "%v", err)
return
}
existing, err := h.db.ListCargoIndexEntries(ctx, remote.Name, meta.Name)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
for _, e := range existing {
if e.Name != meta.Name {
cargoError(w, http.StatusBadRequest, "crate %q already exists as %q", meta.Name, e.Name)
return
}
if e.Version == meta.Vers {
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
return
}
}
lineJSON, err := json.Marshal(line)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
path := cargo.CratePath(meta.Name, meta.Vers)
result, err := h.cas.Store(ctx, bytes.NewReader(crate), "application/gzip")
if err != nil {
cargoError(w, http.StatusInternalServerError, "store failed: %v", err)
return
}
if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, "application/gzip"); err != nil {
cargoError(w, http.StatusInternalServerError, "record blob: %v", err)
return
}
if err := h.db.CreateLocalFile(ctx, remote.Name, path, result.ContentHash); err != nil {
if errors.Is(err, database.ErrAlreadyExists) {
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
return
}
cargoError(w, http.StatusInternalServerError, "record file: %v", err)
return
}
entry := &provider.CargoIndexEntry{RepoName: remote.Name, FilePath: path, Name: meta.Name, Version: meta.Vers, Line: string(lineJSON)}
if err := h.db.InsertCargoIndexEntry(ctx, entry); err != nil {
// Drop the file so a retry is not rejected as a duplicate.
_ = h.db.DeleteLocalFile(ctx, remote.Name, path)
cargoError(w, http.StatusInternalServerError, "record index: %v", err)
return
}
slog.Info("cargo: published", "repo", remote.Name, "crate", meta.Name, "version", meta.Vers)
cargoOK(w, map[string]any{"warnings": map[string][]string{"invalid_categories": {}, "invalid_badges": {}, "other": {}}})
}
func (h *ProxyHandler) cargoYank(yanked bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
remote, ok := h.localCargoRemote(r)
if !ok {
cargoError(w, http.StatusNotFound, "no local cargo registry %q", chi.URLParam(r, "localName"))
return
}
name, version := chi.URLParam(r, "crate"), chi.URLParam(r, "version")
found, err := h.db.SetCargoYanked(r.Context(), remote.Name, name, version, yanked)
if err != nil {
cargoError(w, http.StatusInternalServerError, "%v", err)
return
}
if !found {
cargoError(w, http.StatusNotFound, "crate version `%s@%s` not found", name, version)
return
}
slog.Info("cargo: yank updated", "repo", remote.Name, "crate", name, "version", version, "yanked", yanked)
cargoOK(w, map[string]bool{"ok": true})
}
}
+3
View File
@@ -41,6 +41,9 @@ func (h *ProxyHandler) Routes() chi.Router {
r := chi.NewRouter()
r.Get("/remote/{remoteName}/*", h.handleProxy)
r.Get("/local/{localName}/*", h.handleLocal)
r.Put("/local/{localName}/api/v1/crates/new", h.cargoPublish)
r.Delete("/local/{localName}/api/v1/crates/{crate}/{version}/yank", h.cargoYank(true))
r.Put("/local/{localName}/api/v1/crates/{crate}/{version}/unyank", h.cargoYank(false))
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
return r
}
+1 -38
View File
@@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"os"
"github.com/go-chi/chi/v5"
@@ -84,22 +83,7 @@ func (h *LocalHandler) uploadValidated(w http.ResponseWriter, r *http.Request, r
return
}
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)
result, err := h.cas.Store(r.Context(), r.Body, contentType)
if err != nil {
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
return
@@ -126,27 +110,6 @@ 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 {
-55
View File
@@ -1,55 +0,0 @@
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)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
package database
import (
"context"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
func (db *DB) InsertCargoIndexEntry(ctx context.Context, e *provider.CargoIndexEntry) error {
_, err := db.Pool.Exec(ctx, `
INSERT INTO cargo_index (repo_name, file_path, name, version, line, yanked)
VALUES ($1, $2, $3, $4, $5, $6)
`, e.RepoName, e.FilePath, e.Name, e.Version, e.Line, e.Yanked)
return err
}
func (db *DB) ListCargoIndexEntries(ctx context.Context, repoName, name string) ([]provider.CargoIndexEntry, error) {
rows, err := db.Pool.Query(ctx, `
SELECT repo_name, file_path, name, version, line, yanked
FROM cargo_index
WHERE repo_name = $1 AND lower(name) = $2
ORDER BY id
`, repoName, strings.ToLower(name))
if err != nil {
return nil, err
}
defer rows.Close()
var result []provider.CargoIndexEntry
for rows.Next() {
var e provider.CargoIndexEntry
if err := rows.Scan(&e.RepoName, &e.FilePath, &e.Name, &e.Version, &e.Line, &e.Yanked); err != nil {
return nil, err
}
result = append(result, e)
}
return result, rows.Err()
}
// SetCargoYanked reports false when no such crate version exists.
func (db *DB) SetCargoYanked(ctx context.Context, repoName, name, version string, yanked bool) (bool, error) {
tag, err := db.Pool.Exec(ctx, `
UPDATE cargo_index SET yanked = $4
WHERE repo_name = $1 AND lower(name) = $2 AND version = $3
`, repoName, strings.ToLower(name), version, yanked)
if err != nil {
return false, err
}
return tag.RowsAffected() > 0, nil
}
func (db *DB) DeleteCargoIndexEntry(ctx context.Context, repoName, filePath string) error {
_, err := db.Pool.Exec(ctx, `DELETE FROM cargo_index WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
return err
}
+35
View File
@@ -425,3 +425,38 @@ func TestRPMMetadata(t *testing.T) {
t.Fatalf("list rpm rows: %v %v", len(rows), err)
}
}
func TestCargoIndex(t *testing.T) {
requireDB(t)
for _, v := range []string{"0.1.0", "0.2.0"} {
if err := testDB.InsertCargoIndexEntry(ctx(), &provider.CargoIndexEntry{
RepoName: "cargo-db", FilePath: "crates/Unkin-Hello/Unkin-Hello-" + v + ".crate",
Name: "Unkin-Hello", Version: v, Line: `{"vers":"` + v + `"}`,
}); err != nil {
t.Fatal(err)
}
}
found, err := testDB.SetCargoYanked(ctx(), "cargo-db", "unkin-hello", "0.1.0", true)
if err != nil || !found {
t.Fatalf("yank: found=%v err=%v", found, err)
}
if found, _ := testDB.SetCargoYanked(ctx(), "cargo-db", "unkin-hello", "9.9.9", true); found {
t.Error("yank of a missing version reported found")
}
entries, err := testDB.ListCargoIndexEntries(ctx(), "cargo-db", "unkin-hello")
if err != nil {
t.Fatal(err)
}
if len(entries) != 2 || entries[0].Version != "0.1.0" || !entries[0].Yanked || entries[1].Yanked {
t.Fatalf("entries = %+v", entries)
}
if err := testDB.DeleteCargoIndexEntry(ctx(), "cargo-db", entries[0].FilePath); err != nil {
t.Fatal(err)
}
if entries, _ = testDB.ListCargoIndexEntries(ctx(), "cargo-db", "UNKIN-HELLO"); len(entries) != 1 {
t.Fatalf("after delete: %+v", entries)
}
}
+303
View File
@@ -0,0 +1,303 @@
package cargo
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
// publishDep is a dependency as cargo sends it in the publish metadata.
type publishDep struct {
Name string `json:"name"`
VersionReq string `json:"version_req"`
Features []string `json:"features"`
Optional bool `json:"optional"`
DefaultFeatures bool `json:"default_features"`
Target *string `json:"target"`
Kind string `json:"kind"`
Registry *string `json:"registry"`
ExplicitNameInToml *string `json:"explicit_name_in_toml"`
}
// PublishMeta is the JSON half of a `PUT /api/v1/crates/new` body; fields the
// index does not carry are ignored.
type PublishMeta struct {
Name string `json:"name"`
Vers string `json:"vers"`
Deps []publishDep `json:"deps"`
Features map[string][]string `json:"features"`
Links *string `json:"links"`
RustVersion *string `json:"rust_version"`
}
type indexDep struct {
Name string `json:"name"`
Req string `json:"req"`
Features []string `json:"features"`
Optional bool `json:"optional"`
DefaultFeatures bool `json:"default_features"`
Target *string `json:"target"`
Kind string `json:"kind"`
Registry *string `json:"registry,omitempty"`
Package *string `json:"package,omitempty"`
}
// IndexLine is one line of a sparse index file.
type IndexLine struct {
Name string `json:"name"`
Vers string `json:"vers"`
Deps []indexDep `json:"deps"`
Cksum string `json:"cksum"`
Features map[string][]string `json:"features"`
Features2 map[string][]string `json:"features2,omitempty"`
Yanked bool `json:"yanked"`
Links *string `json:"links,omitempty"`
V int `json:"v,omitempty"`
RustVersion *string `json:"rust_version,omitempty"`
}
// ParsePublish splits a publish body: u32le JSON length, JSON, u32le crate length, crate.
func ParsePublish(body []byte) (PublishMeta, []byte, error) {
var meta PublishMeta
jsonBytes, rest, err := lengthPrefixed(body)
if err != nil {
return meta, nil, fmt.Errorf("metadata: %w", err)
}
crate, rest, err := lengthPrefixed(rest)
if err != nil {
return meta, nil, fmt.Errorf("crate: %w", err)
}
if len(rest) != 0 {
return meta, nil, errors.New("trailing bytes after crate")
}
if err := json.Unmarshal(jsonBytes, &meta); err != nil {
return meta, nil, fmt.Errorf("metadata: %w", err)
}
return meta, crate, nil
}
func lengthPrefixed(b []byte) ([]byte, []byte, error) {
if len(b) < 4 {
return nil, nil, errors.New("truncated length")
}
n := binary.LittleEndian.Uint32(b)
b = b[4:]
if uint64(n) > uint64(len(b)) {
return nil, nil, errors.New("truncated body")
}
return b[:n], b[n:], nil
}
// NewIndexLine validates the metadata and translates it into index form.
func NewIndexLine(meta PublishMeta, crate []byte) (IndexLine, error) {
if !validName(meta.Name) {
return IndexLine{}, fmt.Errorf("invalid crate name %q", meta.Name)
}
if !validVersion(meta.Vers) {
return IndexLine{}, fmt.Errorf("invalid version %q", meta.Vers)
}
sum := sha256.Sum256(crate)
line := IndexLine{
Name: meta.Name,
Vers: meta.Vers,
Deps: make([]indexDep, 0, len(meta.Deps)),
Cksum: hex.EncodeToString(sum[:]),
Features: map[string][]string{},
Links: meta.Links,
RustVersion: meta.RustVersion,
}
for _, d := range meta.Deps {
dep := indexDep{
Name: d.Name,
Req: d.VersionReq,
Features: d.Features,
Optional: d.Optional,
DefaultFeatures: d.DefaultFeatures,
Target: d.Target,
Kind: d.Kind,
Registry: d.Registry,
}
if dep.Features == nil {
dep.Features = []string{}
}
if dep.Kind == "" {
dep.Kind = "normal"
}
if d.ExplicitNameInToml != nil && *d.ExplicitNameInToml != "" {
pkg := d.Name
dep.Name, dep.Package = *d.ExplicitNameInToml, &pkg
}
line.Deps = append(line.Deps, dep)
}
// Older cargo cannot parse dep:/?/ feature syntax, so those features live in
// features2 behind v:2, as on crates.io.
for name, vals := range meta.Features {
if vals == nil {
vals = []string{}
}
if usesNewFeatureSyntax(vals) {
if line.Features2 == nil {
line.Features2 = map[string][]string{}
}
line.Features2[name] = vals
continue
}
line.Features[name] = vals
}
if line.Features2 != nil {
line.V = 2
}
return line, nil
}
func usesNewFeatureSyntax(vals []string) bool {
for _, v := range vals {
if strings.HasPrefix(v, "dep:") || strings.Contains(v, "?/") {
return true
}
}
return false
}
func validName(name string) bool {
if name == "" || len(name) > 64 {
return false
}
for i, c := range name {
alpha := (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
if i == 0 && !alpha {
return false
}
if !alpha && (c < '0' || c > '9') && c != '-' && c != '_' {
return false
}
}
return true
}
func validVersion(v string) bool {
if v == "" || len(v) > 128 || !strings.ContainsAny(v[:1], "0123456789") {
return false
}
return strings.Trim(v, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-+") == ""
}
// IndexPath is the sparse-index file path for a crate name.
func IndexPath(name string) string {
n := strings.ToLower(name)
switch len(n) {
case 0:
return ""
case 1:
return "1/" + n
case 2:
return "2/" + n
case 3:
return "3/" + n[:1] + "/" + n
}
return n[:2] + "/" + n[2:4] + "/" + n
}
// CratePath is where a published .crate is stored, matching config.json's dl.
func CratePath(name, version string) string {
return crateDir + name + "/" + name + "-" + version + ".crate"
}
// RenderIndex joins stored lines into an index file with each yanked flag applied.
func RenderIndex(entries []provider.CargoIndexEntry) ([]byte, error) {
var b strings.Builder
for _, e := range entries {
var line IndexLine
if err := json.Unmarshal([]byte(e.Line), &line); err != nil {
return nil, fmt.Errorf("%s %s: %w", e.Name, e.Version, err)
}
line.Yanked = e.Yanked
raw, err := json.Marshal(line)
if err != nil {
return nil, err
}
b.Write(raw)
b.WriteByte('\n')
}
return []byte(b.String()), nil
}
// LocalConfig is config.json for a local registry rooted at base.
func LocalConfig(base string) map[string]string {
return map[string]string{
"dl": base + "/" + crateDir + "{crate}/{crate}-{version}.crate",
"api": base,
}
}
func (p *Provider) ServeLocalIndex(w http.ResponseWriter, r *http.Request, files provider.FileStore, repoName, path string) bool {
path = strings.TrimLeft(path, "/")
if path == "config.json" {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(LocalConfig(requestBase(r) + "/api/v1/local/" + repoName))
return true
}
name := path[strings.LastIndex(path, "/")+1:]
if name == "" || IndexPath(name) != path {
return false
}
reader, ok := files.(provider.CargoIndexReader)
if !ok {
http.Error(w, "cargo index not available", http.StatusInternalServerError)
return true
}
entries, err := reader.ListCargoIndexEntries(r.Context(), repoName, name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return true
}
if len(entries) == 0 {
http.Error(w, "not found", http.StatusNotFound)
return true
}
body, err := RenderIndex(entries)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return true
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
return true
}
func (p *Provider) GenerateLocalIndex(ctx context.Context, files provider.FileStore, repoName, path string) ([]byte, error) {
return nil, fmt.Errorf("cargo local index generation for virtual repos not supported")
}
func (p *Provider) AfterDelete(ctx context.Context, repoName, storagePath string, db provider.MetadataDeleter) error {
deleter, ok := db.(provider.CargoIndexDeleter)
if !ok {
return nil
}
if err := deleter.DeleteCargoIndexEntry(ctx, repoName, storagePath); err != nil {
slog.Error("cargo index: delete failed", "repo", repoName, "path", storagePath, "error", err)
return err
}
return nil
}
func requestBase(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
} else if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
scheme = fwd
}
return scheme + "://" + r.Host
}
+218
View File
@@ -0,0 +1,218 @@
package cargo
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
)
func publishBody(meta string, crate []byte) []byte {
var b []byte
b = binary.LittleEndian.AppendUint32(b, uint32(len(meta)))
b = append(b, meta...)
b = binary.LittleEndian.AppendUint32(b, uint32(len(crate)))
return append(b, crate...)
}
const sampleMeta = `{
"name": "unkin-hello", "vers": "0.2.0",
"deps": [
{"name": "serde", "version_req": "^1", "features": ["derive"], "optional": false,
"default_features": true, "target": null, "kind": "normal",
"registry": "https://github.com/rust-lang/crates.io-index"},
{"name": "rand_core", "version_req": "^0.6", "features": null, "optional": true,
"default_features": false, "target": "cfg(unix)", "kind": "dev",
"explicit_name_in_toml": "rc"}
],
"features": {"default": ["std"], "std": [], "extra": ["dep:rc"], "weak": ["serde?/std"]},
"links": null, "rust_version": "1.70", "description": "ignored"
}`
func TestParsePublishAndIndexLine(t *testing.T) {
crate := []byte("fake crate bytes")
meta, gotCrate, err := ParsePublish(publishBody(sampleMeta, crate))
if err != nil {
t.Fatal(err)
}
if string(gotCrate) != string(crate) {
t.Fatalf("crate = %q", gotCrate)
}
line, err := NewIndexLine(meta, crate)
if err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(crate)
if line.Cksum != hex.EncodeToString(sum[:]) {
t.Errorf("cksum = %s", line.Cksum)
}
raw, _ := json.Marshal(line)
var got map[string]any
_ = json.Unmarshal(raw, &got)
if _, ok := got["version_req"]; ok {
t.Error("publish-only field leaked into index line")
}
if got["v"] != float64(2) {
t.Errorf("v = %v, want 2 with features2 present", got["v"])
}
if got["rust_version"] != "1.70" {
t.Errorf("rust_version = %v", got["rust_version"])
}
if _, ok := line.Features["extra"]; ok {
t.Error("dep: feature must move to features2")
}
if len(line.Features2) != 2 || len(line.Features) != 2 {
t.Errorf("features = %v features2 = %v", line.Features, line.Features2)
}
deps := got["deps"].([]any)
serde := deps[0].(map[string]any)
if serde["req"] != "^1" || serde["registry"] != "https://github.com/rust-lang/crates.io-index" || serde["target"] != nil {
t.Errorf("serde dep = %v", serde)
}
if _, ok := serde["package"]; ok {
t.Error("package set without a rename")
}
renamed := deps[1].(map[string]any)
if renamed["name"] != "rc" || renamed["package"] != "rand_core" || renamed["kind"] != "dev" {
t.Errorf("renamed dep = %v", renamed)
}
if f, ok := renamed["features"].([]any); !ok || len(f) != 0 {
t.Errorf("null features must render as [], got %v", renamed["features"])
}
}
func TestIndexLineNoNewFeaturesOmitsV(t *testing.T) {
line, err := NewIndexLine(PublishMeta{Name: "a", Vers: "1.0.0", Features: map[string][]string{"x": nil}}, nil)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(line)
s := string(raw)
for _, bad := range []string{`"v":`, `"features2"`, `"links"`} {
if strings.Contains(s, bad) {
t.Errorf("%s present in %s", bad, s)
}
}
if !strings.Contains(s, `"deps":[]`) || !strings.Contains(s, `"features":{"x":[]}`) {
t.Errorf("deps/features must be non-null: %s", s)
}
}
func TestParsePublishRejectsMalformed(t *testing.T) {
good := publishBody(`{"name":"a","vers":"1.0.0"}`, []byte("c"))
for name, body := range map[string][]byte{
"empty": nil,
"short": good[:len(good)-1],
"trailing": append(append([]byte{}, good...), 'x'),
"bad json": publishBody(`{`, []byte("c")),
"no length": good[:6],
} {
if _, _, err := ParsePublish(body); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestNewIndexLineValidation(t *testing.T) {
for _, m := range []PublishMeta{
{Name: "", Vers: "1.0.0"},
{Name: "1abc", Vers: "1.0.0"},
{Name: "../x", Vers: "1.0.0"},
{Name: "ok", Vers: "1.0.0/../../x"},
{Name: "ok", Vers: ""},
{Name: strings.Repeat("a", 65), Vers: "1.0.0"},
} {
if _, err := NewIndexLine(m, nil); err == nil {
t.Errorf("%q@%q accepted", m.Name, m.Vers)
}
}
if _, err := NewIndexLine(PublishMeta{Name: "Foo_bar-2", Vers: "1.0.0-rc.1+build.5"}, nil); err != nil {
t.Error(err)
}
}
func TestIndexPath(t *testing.T) {
for name, want := range map[string]string{
"a": "1/a", "ab": "2/ab", "abc": "3/a/abc", "Serde": "se/rd/serde", "unkin-hello": "un/ki/unkin-hello",
} {
if got := IndexPath(name); got != want {
t.Errorf("IndexPath(%q) = %q, want %q", name, got, want)
}
}
}
type fakeIndex struct {
entries []provider.CargoIndexEntry
asked string
}
func (f *fakeIndex) ListCargoIndexEntries(_ context.Context, _, name string) ([]provider.CargoIndexEntry, error) {
f.asked = name
return f.entries, nil
}
func (f *fakeIndex) ListFilesByPrefix(context.Context, string, string) ([]provider.FileEntry, error) {
return nil, nil
}
func (f *fakeIndex) ListPackages(context.Context, string) ([]string, error) { return nil, nil }
func TestServeLocalIndex(t *testing.T) {
line, _ := NewIndexLine(PublishMeta{Name: "unkin-hello", Vers: "0.1.0"}, []byte("x"))
raw, _ := json.Marshal(line)
files := &fakeIndex{entries: []provider.CargoIndexEntry{
{Name: "unkin-hello", Version: "0.1.0", Line: string(raw), Yanked: true},
{Name: "unkin-hello", Version: "0.2.0", Line: strings.Replace(string(raw), "0.1.0", "0.2.0", 1)},
}}
p := &Provider{}
rec := httptest.NewRecorder()
if !p.ServeLocalIndex(rec, httptest.NewRequest("GET", "http://aa.example/x", nil), files, "crates-local", "un/ki/unkin-hello") {
t.Fatal("index path not served")
}
lines := strings.Split(strings.TrimSpace(rec.Body.String()), "\n")
if len(lines) != 2 || !strings.Contains(lines[0], `"yanked":true`) || !strings.Contains(lines[1], `"yanked":false`) {
t.Errorf("index body = %q", rec.Body.String())
}
if files.asked != "unkin-hello" {
t.Errorf("looked up %q", files.asked)
}
rec = httptest.NewRecorder()
req := httptest.NewRequest("GET", "http://aa.example/config.json", nil)
req.Header.Set("X-Forwarded-Proto", "https")
p.ServeLocalIndex(rec, req, files, "crates-local", "config.json")
var cfg map[string]string
_ = json.Unmarshal(rec.Body.Bytes(), &cfg)
if cfg["api"] != "https://aa.example/api/v1/local/crates-local" ||
cfg["dl"] != "https://aa.example/api/v1/local/crates-local/crates/{crate}/{crate}-{version}.crate" {
t.Errorf("config = %v", cfg)
}
for _, path := range []string{"crates/unkin-hello/unkin-hello-0.1.0.crate", "xx/ki/unkin-hello", ""} {
if p.ServeLocalIndex(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), files, "r", path) {
t.Errorf("%q should fall through", path)
}
}
files.entries = nil
rec = httptest.NewRecorder()
p.ServeLocalIndex(rec, httptest.NewRequest("GET", "/", nil), files, "r", "3/a/abc")
if rec.Code != 404 {
t.Errorf("unknown crate code = %d", rec.Code)
}
}
func TestCratePathMatchesDL(t *testing.T) {
dl := LocalConfig("http://h/api/v1/local/r")["dl"]
want := strings.NewReplacer("{crate}", "unkin-hello", "{version}", "0.1.0").Replace(dl)
if got := "http://h/api/v1/local/r/" + CratePath("unkin-hello", "0.1.0"); got != want {
t.Errorf("CratePath = %q, dl expands to %q", got, want)
}
}
+24
View File
@@ -0,0 +1,24 @@
package provider
import "context"
// CargoIndexEntry is one published crate version in a local cargo registry.
// Line is the sparse-index JSON line; Yanked is applied when it is served.
type CargoIndexEntry struct {
RepoName string
FilePath string
Name string
Version string
Line string
Yanked bool
}
// CargoIndexReader lists every version of one crate (matched case-insensitively).
// *database.DB satisfies it and CargoIndexDeleter.
type CargoIndexReader interface {
ListCargoIndexEntries(ctx context.Context, repoName, name string) ([]CargoIndexEntry, error)
}
type CargoIndexDeleter interface {
DeleteCargoIndexEntry(ctx context.Context, repoName, filePath string) error
}
-222
View File
@@ -1,222 +0,0 @@
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 "<escaped module>/@v/<version>.<ext>" 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 <module>/@v/<version>.{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)
}
-198
View File
@@ -1,198 +0,0 @@
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")
}
}
-5
View File
@@ -41,11 +41,6 @@ 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)
+15
View File
@@ -0,0 +1,15 @@
-- Local cargo registries: one sparse-index line per published crate version.
-- line holds the index JSON minus yanked, which flips on yank/unyank.
CREATE TABLE IF NOT EXISTS cargo_index (
id BIGSERIAL PRIMARY KEY,
repo_name TEXT NOT NULL,
file_path TEXT NOT NULL,
name TEXT NOT NULL,
version TEXT NOT NULL,
line TEXT NOT NULL,
yanked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(repo_name, file_path)
);
CREATE INDEX IF NOT EXISTS idx_cargo_index_repo_name ON cargo_index(repo_name, lower(name));
+27 -31
View File
@@ -218,40 +218,36 @@ sudo apk add --allow-untrusted <package>`,
];
case 'goproxy':
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}
return [
{
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':
if (isLocal) {
return [
{
title: 'Add the private registry (real sparse registry)',
language: 'toml',
code: `# $CARGO_HOME/config.toml
[registries.${name}]
index = "sparse+${url}/api/v1/local/${name}/"`,
note: 'cargo requires a token for publish/yank: set CARGO_REGISTRIES_' + name.toUpperCase().replace(/-/g, '_') + '_TOKEN to any value.',
},
{
title: 'Publish, depend on and yank crates',
language: 'bash',
code: `cargo publish --registry ${name}
cargo add <crate> --registry ${name}
cargo yank --version <version> --registry ${name}`,
},
];
}
return [
{
title: 'Point crates.io at this sparse registry',