Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce8026d46e | |||
| 365d93d637 | |||
| 4c321a089d |
@@ -280,6 +280,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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
//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")
|
||||
publish := func(meta string) *http.Response {
|
||||
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...)
|
||||
resp, _ := doRequest(t, http.MethodPut, base+"/api/v1/crates/new", body, "application/octet-stream")
|
||||
return resp
|
||||
}
|
||||
|
||||
if resp := publish(meta); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("publish: status %d", resp.StatusCode)
|
||||
}
|
||||
if resp := publish(meta); resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("republish: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if resp := publish(`{"name":"e2e-crate","vers":"0.1.0+other","deps":[],"features":{}}`); resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("build-metadata republish: status %d, want 409", resp.StatusCode)
|
||||
}
|
||||
if resp := publish(`{"name":"E2E_crate","vers":"0.2.0","deps":[],"features":{}}`); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("name collision publish: status %d, want 400", 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)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/testsupport"
|
||||
)
|
||||
|
||||
// TestVirtualPyPIMerge uploads different packages to two pypi locals and
|
||||
@@ -138,104 +136,3 @@ func TestVirtualRPMMerge(t *testing.T) {
|
||||
t.Fatalf("real dnf against rpm virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebMerge merges two local deb repos carrying the same package: the
|
||||
// first member wins the duplicate, and a real apt sees both members' packages
|
||||
// and installs through the virtual.
|
||||
func TestVirtualDebMerge(t *testing.T) {
|
||||
createRepo(t, `{"name":"vdeb-a","package_type":"deb","repo_type":"local"}`)
|
||||
createRepo(t, `{"name":"vdeb-b","package_type":"deb","repo_type":"local"}`)
|
||||
defer deleteRepo(t, "vdeb-a")
|
||||
defer deleteRepo(t, "vdeb-b")
|
||||
|
||||
uploadFile(t, "vdeb-a", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-one_1.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-one", "1.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
uploadFile(t, "vdeb-b", "vdeb-two_2.0.0_amd64.deb", testsupport.MinimalDeb("vdeb-two", "2.0.0", "amd64"), "application/vnd.debian.binary-package")
|
||||
|
||||
// Local indexes regenerate asynchronously; merge only once vdeb-b lists both.
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for {
|
||||
resp, body := doRequest(t, http.MethodGet, api("/api/v1/local/vdeb-b/Packages"), nil, "")
|
||||
if resp.StatusCode == http.StatusOK && strings.Contains(string(body), "vdeb-two") || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
createVirtual(t, `{"name":"vdeb","package_type":"deb","members":["vdeb-a","vdeb-b"]}`)
|
||||
defer deleteVirtual(t, "vdeb")
|
||||
|
||||
_, body := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdeb/Packages"), nil, "")
|
||||
pk := string(body)
|
||||
if strings.Count(pk, "Package: vdeb-one\n") != 1 || !strings.Contains(pk, "Filename: vdeb-a/pool/vdeb-one_1.0.0_amd64.deb") ||
|
||||
!strings.Contains(pk, "Filename: vdeb-b/pool/vdeb-two_2.0.0_amd64.deb") {
|
||||
t.Fatalf("merged Packages should hold vdeb-one from the first member and vdeb-two:\n%s", pk)
|
||||
}
|
||||
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL ./\" > /etc/apt/sources.list.d/vdeb.list; " +
|
||||
"apt-get update; " +
|
||||
"apt-cache policy vdeb-one vdeb-two | tee /dev/stderr | grep -c virtual/vdeb | grep -qx 2; " +
|
||||
"apt-get install -y vdeb-one vdeb-two; " +
|
||||
"dpkg -s vdeb-one vdeb-two"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdeb/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual failed: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVirtualDebSuite merges two proxied deb.debian.org remotes and drives a
|
||||
// real apt through the virtual's dists/<suite> layout (by-hash indexes).
|
||||
func TestVirtualDebSuite(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; skipping real apt")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
for _, n := range []string{"vdebs-a", "vdebs-b"} {
|
||||
createRepo(t, `{"name":"`+n+`","package_type":"deb","repo_type":"remote","base_url":"http://deb.debian.org/debian"}`)
|
||||
defer deleteRepo(t, n)
|
||||
}
|
||||
createVirtual(t, `{"name":"vdebs","package_type":"deb","members":["vdebs-a","vdebs-b"]}`)
|
||||
defer deleteVirtual(t, "vdebs")
|
||||
|
||||
resp, rel := doRequest(t, http.MethodGet, api("/api/v1/virtual/vdebs/dists/trixie-updates/Release"), nil, "")
|
||||
if resp.StatusCode != http.StatusOK || !strings.Contains(string(rel), "Acquire-By-Hash: yes\n") ||
|
||||
!strings.Contains(string(rel), " main/binary-amd64/Packages.gz\n") {
|
||||
t.Fatalf("merged suite Release: status %d:\n%s", resp.StatusCode, rel)
|
||||
}
|
||||
|
||||
script := "set -euo pipefail; " +
|
||||
"rm -f /etc/apt/sources.list.d/*; " +
|
||||
"echo \"deb [trusted=yes] $URL trixie-updates main\" > /etc/apt/sources.list.d/vdebs.list; " +
|
||||
"apt-get update; " +
|
||||
"idx=/tmp/avail; apt-cache dumpavail > $idx; " +
|
||||
"grep -q '^Filename: vdebs-a/pool/' $idx; if grep -q '^Filename: vdebs-b/' $idx; then echo 'vdebs-b not deduped'; exit 1; fi; " +
|
||||
"pkg=$(awk '/^Package:/{p=$2} /^Size:/{if(!m||$2<m){m=$2;b=p}} END{print b}' $idx); " +
|
||||
"cd /tmp && apt-get download $pkg && ls -l /tmp/*.deb"
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "URL="+strings.TrimRight(internal, "/")+"/api/v1/virtual/vdebs/",
|
||||
"debian:13", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("real apt against deb virtual suite failed: %v\n%s", err, out)
|
||||
}
|
||||
t.Logf("%s", out)
|
||||
resp, _ = doRequest(t, http.MethodGet, api("/api/v1/remote/vdebs-a/dists/trixie-updates/main/binary-amd64/Packages.xz"), nil, "")
|
||||
if src := resp.Header.Get("X-Artifact-Source"); resp.StatusCode != http.StatusOK || src != "cache" {
|
||||
t.Fatalf("member index the virtual merged should be cached: status %d source %q", resp.StatusCode, src)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
switch {
|
||||
case errors.Is(err, database.ErrCargoNameTaken):
|
||||
cargoError(w, http.StatusBadRequest, "%v", err)
|
||||
case errors.Is(err, database.ErrAlreadyExists):
|
||||
cargoError(w, http.StatusConflict, "crate version `%s@%s` already exists", meta.Name, meta.Vers)
|
||||
default:
|
||||
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})
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||
)
|
||||
|
||||
var ErrCargoNameTaken = errors.New("crate name already taken")
|
||||
|
||||
func cargoNormName(name string) string {
|
||||
return strings.ReplaceAll(strings.ToLower(name), "-", "_")
|
||||
}
|
||||
|
||||
// InsertCargoIndexEntry rejects a name that differs from an existing crate only
|
||||
// by case or -/_ (ErrCargoNameTaken) and a version equal up to build metadata
|
||||
// (ErrAlreadyExists). A per-crate advisory lock serialises concurrent publishes.
|
||||
func (db *DB) InsertCargoIndexEntry(ctx context.Context, e *provider.CargoIndexEntry) error {
|
||||
tx, err := db.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
norm := cargoNormName(e.Name)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('cargo:' || $1 || '/' || $2))`, e.RepoName, norm); err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := tx.Query(ctx, `
|
||||
SELECT name, version FROM cargo_index
|
||||
WHERE repo_name = $1 AND replace(lower(name), '-', '_') = $2
|
||||
`, e.RepoName, norm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
base, _, _ := strings.Cut(e.Version, "+")
|
||||
for rows.Next() {
|
||||
var name, version string
|
||||
if err := rows.Scan(&name, &version); err != nil {
|
||||
return err
|
||||
}
|
||||
if name != e.Name {
|
||||
return fmt.Errorf("%w: %q exists as %q", ErrCargoNameTaken, e.Name, name)
|
||||
}
|
||||
if v, _, _ := strings.Cut(version, "+"); v == base {
|
||||
return fmt.Errorf("%w: %s@%s", ErrAlreadyExists, name, version)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
if _, err := tx.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); err != nil {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%w: %s@%s", ErrAlreadyExists, e.Name, e.Version)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -425,3 +428,115 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCargoIndexPublishConflicts(t *testing.T) {
|
||||
requireDB(t)
|
||||
entry := func(name, version string) *provider.CargoIndexEntry {
|
||||
return &provider.CargoIndexEntry{
|
||||
RepoName: "cargo-conflict", FilePath: "crates/" + name + "/" + name + "-" + version + ".crate",
|
||||
Name: name, Version: version, Line: "{}",
|
||||
}
|
||||
}
|
||||
if err := testDB.InsertCargoIndexEntry(ctx(), entry("foo-bar", "1.0.0+a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name, version string
|
||||
want error
|
||||
}{
|
||||
{"foo-bar", "1.0.0+b", ErrAlreadyExists},
|
||||
{"foo-bar", "1.0.0", ErrAlreadyExists},
|
||||
{"Foo-Bar", "2.0.0", ErrCargoNameTaken},
|
||||
{"foo_bar", "2.0.0", ErrCargoNameTaken},
|
||||
} {
|
||||
if err := testDB.InsertCargoIndexEntry(ctx(), entry(tc.name, tc.version)); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s@%s: err = %v, want %v", tc.name, tc.version, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := testDB.InsertCargoIndexEntry(ctx(), entry("foo-bar", "1.0.1")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCargoIndexConcurrentPublish(t *testing.T) {
|
||||
requireDB(t)
|
||||
names := []string{"Race", "race", "RACE", "race", "rAce", "RaCe", "racE", "race"}
|
||||
errs := make(chan error, len(names))
|
||||
var wg sync.WaitGroup
|
||||
for i, name := range names {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- testDB.InsertCargoIndexEntry(ctx(), &provider.CargoIndexEntry{
|
||||
RepoName: "cargo-race", FilePath: fmt.Sprintf("crates/%s/%d.crate", name, i),
|
||||
Name: name, Version: "1.0.0", Line: "{}",
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
ok := 0
|
||||
for err := range errs {
|
||||
switch {
|
||||
case err == nil:
|
||||
ok++
|
||||
case !errors.Is(err, ErrAlreadyExists) && !errors.Is(err, ErrCargoNameTaken):
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if ok != 1 {
|
||||
t.Fatalf("%d concurrent publishes succeeded, want 1", ok)
|
||||
}
|
||||
if entries, _ := testDB.ListCargoIndexEntries(ctx(), "cargo-race", "race"); len(entries) > 1 {
|
||||
t.Fatalf("entries = %+v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCargoIndexUniqueIndex(t *testing.T) {
|
||||
requireDB(t)
|
||||
insert := func(name, version, path string) error {
|
||||
_, err := testDB.Pool.Exec(ctx(), `INSERT INTO cargo_index (repo_name, file_path, name, version, line) VALUES ('cargo-uniq', $1, $2, $3, '{}')`, path, name, version)
|
||||
return err
|
||||
}
|
||||
if err := insert("a-b", "1.0.0+x", "p1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := insert("A_B", "1.0.0+y", "p2"); err == nil {
|
||||
t.Fatal("unique index allowed a normalised duplicate")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package cargo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"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)
|
||||
}
|
||||
if len(crate) == 0 {
|
||||
return IndexLine{}, errors.New("empty .crate")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// semverRe is the SemVer 2.0.0 grammar from semver.org.
|
||||
var semverRe = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)
|
||||
|
||||
func validVersion(v string) bool {
|
||||
return len(v) <= 128 && semverRe.MatchString(v)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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}}, []byte("x"))
|
||||
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"},
|
||||
{Name: "ok", Vers: "1"},
|
||||
{Name: "ok", Vers: "1.0"},
|
||||
{Name: "ok", Vers: "1..2"},
|
||||
{Name: "ok", Vers: "01.0.0"},
|
||||
{Name: "ok", Vers: "1.0.0-"},
|
||||
{Name: "ok", Vers: "1.0.0-01"},
|
||||
{Name: "ok", Vers: "1.0.0+"},
|
||||
{Name: "ok", Vers: "1.0.0+a..b"},
|
||||
{Name: "ok", Vers: "v1.0.0"},
|
||||
} {
|
||||
if _, err := NewIndexLine(m, []byte("x")); err == nil {
|
||||
t.Errorf("%q@%q accepted", m.Name, m.Vers)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"0.0.0", "1.0.0-rc.1+build.5", "1.2.3-alpha.0.x-y", "10.20.30+20261017"} {
|
||||
if _, err := NewIndexLine(PublishMeta{Name: "Foo_bar-2", Vers: v}, []byte("x")); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
if _, err := NewIndexLine(PublishMeta{Name: "ok", Vers: "1.0.0"}, nil); err == nil {
|
||||
t.Error("empty .crate accepted")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// debIndexRe matches the binary Packages indexes a virtual merges: the flat
|
||||
// repo's root Packages and a suite's <component>/binary-<arch>/Packages.
|
||||
var debIndexRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?Packages(\.[a-z0-9]+)?$`)
|
||||
|
||||
// debByHashRe matches an index requested by its SHA256 (Acquire-By-Hash).
|
||||
var debByHashRe = regexp.MustCompile(`^([^/]+/binary-[^/]+/)?by-hash/SHA256/[0-9a-f]{64}$`)
|
||||
|
||||
// debCompressions is the order a member's index variants are fetched in.
|
||||
var debCompressions = []string{".xz", ".gz", ".zst", ".bz2", ""}
|
||||
|
||||
// DebMember is one member's Release plus its decompressed Packages indexes,
|
||||
// keyed by index directory relative to the Release ("" for a flat repo).
|
||||
type DebMember struct {
|
||||
RemoteName string
|
||||
Release []byte
|
||||
Packages map[string][]byte
|
||||
}
|
||||
|
||||
// DebRepo is a merged apt repo: Release plus the indexes it lists, keyed by
|
||||
// their path relative to the Release.
|
||||
type DebRepo struct {
|
||||
Release []byte
|
||||
Files map[string][]byte
|
||||
}
|
||||
|
||||
type debRelease struct {
|
||||
fields map[string]string
|
||||
files []string
|
||||
}
|
||||
|
||||
// parseDebRelease reads a Release file's single-line fields and the names
|
||||
// listed in its checksum sections.
|
||||
func parseDebRelease(body []byte) debRelease {
|
||||
rel := debRelease{fields: map[string]string{}}
|
||||
inSums := false
|
||||
seen := map[string]bool{}
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line[0] == ' ' || line[0] == '\t' {
|
||||
if f := strings.Fields(line); inSums && len(f) == 3 && !seen[f[2]] {
|
||||
seen[f[2]] = true
|
||||
rel.files = append(rel.files, f[2])
|
||||
}
|
||||
continue
|
||||
}
|
||||
k, v, _ := strings.Cut(line, ":")
|
||||
v = strings.TrimSpace(v)
|
||||
inSums = v == "" && (k == "MD5Sum" || strings.HasPrefix(k, "SHA"))
|
||||
if _, ok := rel.fields[k]; !ok {
|
||||
rel.fields[k] = v
|
||||
}
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
// debIndexFiles returns, per index directory, the variant of its Packages file
|
||||
// to fetch from a member's Release listing.
|
||||
func debIndexFiles(rel debRelease) map[string]string {
|
||||
listed := map[string]bool{}
|
||||
for _, f := range rel.files {
|
||||
listed[f] = true
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, f := range rel.files {
|
||||
m := debIndexRe.FindStringSubmatch(f)
|
||||
if m == nil || out[m[1]] != "" {
|
||||
continue
|
||||
}
|
||||
for _, c := range debCompressions {
|
||||
if listed[m[1]+"Packages"+c] {
|
||||
out[m[1]] = m[1] + "Packages" + c
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitStanzas(body []byte) []string {
|
||||
var out []string
|
||||
for s := range strings.SplitSeq(strings.ReplaceAll(string(body), "\r\n", "\n"), "\n\n") {
|
||||
if s = strings.Trim(s, "\n"); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stanzaField(stanza, key string) string {
|
||||
for line := range strings.SplitSeq(stanza, "\n") {
|
||||
if k, v, ok := strings.Cut(line, ":"); ok && k == key {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func unionFields(dst []string, field string) []string {
|
||||
for f := range strings.FieldsSeq(field) {
|
||||
if !slices.Contains(dst, f) {
|
||||
dst = append(dst, f)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
func parseDebDate(s string) (time.Time, bool) {
|
||||
for _, layout := range []string{time.RFC1123Z, time.RFC1123} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
// MergeDeb merges members into one apt repo. Members are in priority order: the
|
||||
// first member to carry a (Package, Version, Architecture) in an index wins;
|
||||
// stanzas missing any of those are always kept. Filename is prefixed with the
|
||||
// owning member's name so the virtual can route downloads back to it.
|
||||
func MergeDeb(members []DebMember) *DebRepo {
|
||||
var suite, codename string
|
||||
var arches, comps, dirs []string
|
||||
date := time.Unix(0, 0)
|
||||
for _, m := range members {
|
||||
rel := parseDebRelease(m.Release)
|
||||
if suite == "" {
|
||||
suite, codename = rel.fields["Suite"], rel.fields["Codename"]
|
||||
}
|
||||
arches = unionFields(arches, rel.fields["Architectures"])
|
||||
comps = unionFields(comps, rel.fields["Components"])
|
||||
if t, ok := parseDebDate(rel.fields["Date"]); ok && t.After(date) {
|
||||
date = t
|
||||
}
|
||||
for d := range m.Packages {
|
||||
if !slices.Contains(dirs, d) {
|
||||
dirs = append(dirs, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(dirs)
|
||||
|
||||
repo := &DebRepo{Files: map[string][]byte{}}
|
||||
for _, d := range dirs {
|
||||
seen := map[string]bool{}
|
||||
var b bytes.Buffer
|
||||
for _, m := range members {
|
||||
for _, s := range splitStanzas(m.Packages[d]) {
|
||||
pkg, ver, arch := stanzaField(s, "Package"), stanzaField(s, "Version"), stanzaField(s, "Architecture")
|
||||
if pkg != "" && ver != "" && arch != "" {
|
||||
key := pkg + "\x00" + ver + "\x00" + arch
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if fn := stanzaField(s, "Filename"); fn != "" {
|
||||
s = rewriteFilename(s, m.RemoteName+"/"+strings.TrimPrefix(path.Clean("/"+fn), "/"))
|
||||
}
|
||||
b.WriteString(s)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
gz := gzipDeterministic(b.Bytes())
|
||||
repo.Files[d+"Packages"] = b.Bytes()
|
||||
repo.Files[d+"Packages.gz"] = gz
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(b.Bytes())] = b.Bytes()
|
||||
repo.Files[d+"by-hash/SHA256/"+sha256Hex(gz)] = gz
|
||||
}
|
||||
|
||||
var r bytes.Buffer
|
||||
if suite != "" {
|
||||
fmt.Fprintf(&r, "Suite: %s\n", suite)
|
||||
}
|
||||
if codename != "" {
|
||||
fmt.Fprintf(&r, "Codename: %s\n", codename)
|
||||
}
|
||||
fmt.Fprintf(&r, "Date: %s\n", date.UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&r, "Architectures: %s\n", strings.Join(arches, " "))
|
||||
if len(comps) > 0 {
|
||||
fmt.Fprintf(&r, "Components: %s\n", strings.Join(comps, " "))
|
||||
}
|
||||
r.WriteString("Acquire-By-Hash: yes\n")
|
||||
for _, sec := range []struct {
|
||||
name string
|
||||
sum func([]byte) string
|
||||
}{{"MD5Sum", md5Hex}, {"SHA256", sha256Hex}} {
|
||||
r.WriteString(sec.name + ":\n")
|
||||
for _, d := range dirs {
|
||||
for _, n := range []string{d + "Packages", d + "Packages.gz"} {
|
||||
fmt.Fprintf(&r, " %s %d %s\n", sec.sum(repo.Files[n]), len(repo.Files[n]), n)
|
||||
}
|
||||
}
|
||||
}
|
||||
repo.Release = r.Bytes()
|
||||
return repo
|
||||
}
|
||||
|
||||
func rewriteFilename(stanza, fn string) string {
|
||||
lines := strings.Split(stanza, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.HasPrefix(l, "Filename:") {
|
||||
lines[i] = "Filename: " + fn
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// debIndexPath splits an index request into its Release directory and the file
|
||||
// under it. ok is false for anything a deb virtual does not merge.
|
||||
func debIndexPath(p string) (base, name string, ok bool) {
|
||||
if rest, found := strings.CutPrefix(p, "dists/"); found {
|
||||
suite, n, found := strings.Cut(rest, "/")
|
||||
if !found || suite == "" {
|
||||
return "", "", false
|
||||
}
|
||||
base, p = "dists/"+suite+"/", n
|
||||
}
|
||||
if p != "Release" && !debIndexRe.MatchString(p) && !debByHashRe.MatchString(p) {
|
||||
return "", "", false
|
||||
}
|
||||
return base, p, true
|
||||
}
|
||||
|
||||
func md5Hex(data []byte) string {
|
||||
h := md5.Sum(data)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package virtual
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func debStanza(pkg, ver, arch, filename string) string {
|
||||
return fmt.Sprintf("Package: %s\nVersion: %s\nArchitecture: %s\nDescription: test\n multi-line body\nFilename: %s\nSize: 1\n", pkg, ver, arch, filename)
|
||||
}
|
||||
|
||||
func flatDebMember(name, date string, stanzas ...string) DebMember {
|
||||
rel := "Date: " + date + "\nArchitectures: amd64\nAcquire-By-Hash: no\nSHA256:\n x 1 Packages\n x 1 Packages.gz\n"
|
||||
return DebMember{RemoteName: name, Release: []byte(rel), Packages: map[string][]byte{"": []byte(strings.Join(stanzas, "\n"))}}
|
||||
}
|
||||
|
||||
func TestMergeDebDedupesByMemberOrder(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{
|
||||
flatDebMember("a", "Mon, 05 Oct 2026 10:00:00 +0000",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo_1.0_amd64.deb"),
|
||||
debStanza("bar", "2.0", "amd64", "/pool/bar.deb"),
|
||||
debStanza("dot", "1.0", "amd64", "./dot.deb"),
|
||||
"Package: noversion\nFilename: nv1.deb\n",
|
||||
"Package: noversion\nFilename: nv2.deb\n"),
|
||||
flatDebMember("b", "Tue, 06 Oct 2026 10:00:00 UTC",
|
||||
debStanza("foo", "1.0", "amd64", "pool/foo-from-b.deb"),
|
||||
debStanza("foo", "1.0", "arm64", "pool/foo_arm64.deb"),
|
||||
debStanza("foo", "1.1", "amd64", "o/r/releases/download/v1/foo.deb")),
|
||||
})
|
||||
pk := string(repo.Files["Packages"])
|
||||
stanzas := splitStanzas(repo.Files["Packages"])
|
||||
if len(stanzas) != 7 {
|
||||
t.Fatalf("want 7 stanzas (one duplicate dropped), got %d:\n%s", len(stanzas), pk)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Filename: a/pool/foo_1.0_amd64.deb",
|
||||
"Filename: a/pool/bar.deb",
|
||||
"Filename: a/dot.deb",
|
||||
"Filename: a/nv1.deb",
|
||||
"Filename: a/nv2.deb",
|
||||
"Filename: b/pool/foo_arm64.deb",
|
||||
"Filename: b/o/r/releases/download/v1/foo.deb",
|
||||
" multi-line body",
|
||||
} {
|
||||
if !strings.Contains(pk, want) {
|
||||
t.Errorf("merged Packages missing %q:\n%s", want, pk)
|
||||
}
|
||||
}
|
||||
if strings.Contains(pk, "foo-from-b") {
|
||||
t.Fatalf("second member's duplicate must lose:\n%s", pk)
|
||||
}
|
||||
if !bytes.Equal(gunzip(t, repo.Files["Packages.gz"]), repo.Files["Packages"]) {
|
||||
t.Fatal("Packages.gz does not match Packages")
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Date: Tue, 06 Oct 2026 10:00:00 +0000\n") {
|
||||
t.Fatalf("Date should be the newest member date:\n%s", repo.Release)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebReleaseHashesFiles(t *testing.T) {
|
||||
repo := MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))})
|
||||
rel := parseDebRelease(repo.Release)
|
||||
if got := slices.Sorted(slices.Values(rel.files)); !slices.Equal(got, []string{"Packages", "Packages.gz"}) {
|
||||
t.Fatalf("Release lists %v", got)
|
||||
}
|
||||
inSHA := false
|
||||
checked := 0
|
||||
for line := range strings.SplitSeq(string(repo.Release), "\n") {
|
||||
if !strings.HasPrefix(line, " ") {
|
||||
inSHA = line == "SHA256:"
|
||||
continue
|
||||
}
|
||||
var sum, name string
|
||||
var size int
|
||||
if _, err := fmt.Sscan(line, &sum, &size, &name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := repo.Files[name]
|
||||
want := sha256Hex(body)
|
||||
if !inSHA {
|
||||
h := md5.Sum(body)
|
||||
want = hex.EncodeToString(h[:])
|
||||
}
|
||||
if sum != want || size != len(body) {
|
||||
t.Errorf("%s: Release has %s %d, file is %s %d", name, sum, size, want, len(body))
|
||||
}
|
||||
checked++
|
||||
}
|
||||
if checked != 4 {
|
||||
t.Fatalf("checked %d Release entries, want 4:\n%s", checked, repo.Release)
|
||||
}
|
||||
if !bytes.Equal(MergeDeb([]DebMember{flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))}).Release, repo.Release) {
|
||||
t.Fatal("merge must be deterministic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDebSuiteLayout(t *testing.T) {
|
||||
rel := func(comps string) []byte {
|
||||
return []byte("Suite: stable\nCodename: trixie\nArchitectures: amd64 arm64\nComponents: " + comps + "\nSHA256:\n x 1 main/binary-amd64/Packages.xz\n")
|
||||
}
|
||||
repo := MergeDeb([]DebMember{
|
||||
{RemoteName: "deb1", Release: rel("main"), Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", "1", "amd64", "pool/main/f/foo.deb"))}},
|
||||
{RemoteName: "deb2", Release: rel("main contrib"), Packages: map[string][]byte{
|
||||
"main/binary-amd64/": []byte(debStanza("bar", "1", "amd64", "pool/main/b/bar.deb")),
|
||||
"contrib/binary-amd64/": []byte(debStanza("baz", "1", "amd64", "pool/contrib/b/baz.deb")),
|
||||
}},
|
||||
})
|
||||
for _, want := range []string{"Suite: stable\n", "Codename: trixie\n", "Components: main contrib\n", "Architectures: amd64 arm64\n"} {
|
||||
if !strings.Contains(string(repo.Release), want) {
|
||||
t.Errorf("Release missing %q:\n%s", want, repo.Release)
|
||||
}
|
||||
}
|
||||
want := []string{"contrib/binary-amd64/Packages", "contrib/binary-amd64/Packages.gz", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}
|
||||
if got := slices.Sorted(slices.Values(parseDebRelease(repo.Release).files)); !slices.Equal(got, want) {
|
||||
t.Fatalf("Release lists %v, want %v", got, want)
|
||||
}
|
||||
if !strings.Contains(string(repo.Release), "Acquire-By-Hash: yes\n") {
|
||||
t.Fatalf("Release must advertise by-hash:\n%s", repo.Release)
|
||||
}
|
||||
for _, n := range want {
|
||||
byHash := path.Dir(n) + "/by-hash/SHA256/" + sha256Hex(repo.Files[n])
|
||||
if !bytes.Equal(repo.Files[byHash], repo.Files[n]) {
|
||||
t.Errorf("%s not served at %s", n, byHash)
|
||||
}
|
||||
}
|
||||
if len(repo.Files) != 2*len(want) {
|
||||
t.Fatalf("files %v, want each index plus its by-hash copy", slices.Sorted(maps.Keys(repo.Files)))
|
||||
}
|
||||
if n := len(splitStanzas(repo.Files["main/binary-amd64/Packages"])); n != 2 {
|
||||
t.Fatalf("main/binary-amd64 should merge both members, got %d stanzas", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexFiles(t *testing.T) {
|
||||
rel := parseDebRelease([]byte("Origin: Debian\nMD5Sum:\n a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.gz\nSHA256:\n" +
|
||||
" a 1 main/binary-amd64/Packages\n a 1 main/binary-amd64/Packages.xz\n a 1 main/binary-amd64/Packages.gz\n" +
|
||||
" a 1 main/debian-installer/binary-amd64/Packages.xz\n a 1 main/i18n/Translation-en.xz\n a 1 contrib/binary-all/Packages\n"))
|
||||
got := debIndexFiles(rel)
|
||||
want := map[string]string{"main/binary-amd64/": "main/binary-amd64/Packages.xz", "contrib/binary-all/": "contrib/binary-all/Packages"}
|
||||
if !maps.Equal(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
if flat := debIndexFiles(parseDebRelease([]byte("SHA256:\n a 1 Packages\n a 1 Packages.gz\n"))); flat[""] != "Packages.gz" {
|
||||
t.Fatalf("flat repo should fetch Packages.gz, got %v", flat)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebIndexPath(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, base, name string
|
||||
ok bool
|
||||
}{
|
||||
{"Release", "", "Release", true},
|
||||
{"Packages.gz", "", "Packages.gz", true},
|
||||
{"dists/trixie/Release", "dists/trixie/", "Release", true},
|
||||
{"dists/trixie/main/binary-amd64/Packages", "dists/trixie/", "main/binary-amd64/Packages", true},
|
||||
{"by-hash/SHA256/abababababababababababababababababababababababababababababababab", "", "by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", "dists/trixie/", "main/binary-amd64/by-hash/SHA256/abababababababababababababababababababababababababababababababab", true},
|
||||
{"dists/trixie/main/binary-amd64/by-hash/MD5Sum/abababababababababababababababab", "", "", false},
|
||||
{"InRelease", "", "", false},
|
||||
{"Release.gpg", "", "", false},
|
||||
{"dists/trixie/InRelease", "", "", false},
|
||||
{"dists/trixie/main/i18n/Translation-en", "", "", false},
|
||||
{"dists/Release", "", "", false},
|
||||
{"pool/foo.deb", "", "", false},
|
||||
} {
|
||||
base, name, ok := debIndexPath(tc.in)
|
||||
if base != tc.base || name != tc.name || ok != tc.ok {
|
||||
t.Errorf("%s: got (%q, %q, %v), want (%q, %q, %v)", tc.in, base, name, ok, tc.base, tc.name, tc.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-163
@@ -9,7 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -27,51 +26,32 @@ type Engine struct {
|
||||
proxyEngine *proxy.Engine
|
||||
getRemote func(context.Context, string) (*models.Remote, error)
|
||||
rpmMember func(context.Context, string) (*RPMMember, error)
|
||||
debMember func(ctx context.Context, name, base string) (*DebMember, error)
|
||||
|
||||
mergeTTL time.Duration
|
||||
|
||||
sf singleflight.Group
|
||||
mu sync.Mutex
|
||||
gens map[string]*repoGen
|
||||
sf singleflight.Group
|
||||
mu sync.Mutex
|
||||
rpm map[string]*rpmGen
|
||||
}
|
||||
|
||||
// mergedRepo is a merged index document plus the files it references.
|
||||
type mergedRepo interface {
|
||||
index() []byte
|
||||
files() map[string][]byte
|
||||
}
|
||||
|
||||
func (r *RPMRepo) index() []byte { return r.Repomd }
|
||||
func (r *RPMRepo) files() map[string][]byte { return r.Files }
|
||||
func (r *DebRepo) index() []byte { return r.Release }
|
||||
func (r *DebRepo) files() map[string][]byte { return r.Files }
|
||||
|
||||
// repoGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous index can still fetch the files it references.
|
||||
type repoGen struct {
|
||||
cur, prev mergedRepo
|
||||
at, used time.Time
|
||||
// rpmGen holds a virtual's current merge plus the previous one, so a client
|
||||
// holding the previous repomd can still fetch its content-hashed data files.
|
||||
type rpmGen struct {
|
||||
cur, prev *RPMRepo
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const rpmMergeTTL = 60 * time.Second
|
||||
|
||||
// maxDebSuites bounds the client-chosen suites cached per deb virtual.
|
||||
const maxDebSuites = 32
|
||||
|
||||
func NewEngine(db *database.DB, proxyEngine *proxy.Engine) *Engine {
|
||||
e := &Engine{db: db, proxyEngine: proxyEngine, getRemote: db.GetRemote, mergeTTL: rpmMergeTTL}
|
||||
e.rpmMember = e.fetchRPMMember
|
||||
e.debMember = e.fetchDebMember
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Engine) Fetch(ctx context.Context, virt models.Virtual, path string, proxyBaseURL string) ([]byte, string, error) {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
if virt.PackageType == models.PackageRPM {
|
||||
return e.fetchRPM(ctx, virt, path)
|
||||
case models.PackageDeb:
|
||||
return e.fetchDeb(ctx, virt, path)
|
||||
}
|
||||
|
||||
merger, err := GetMerger(virt.PackageType)
|
||||
@@ -190,22 +170,11 @@ var (
|
||||
ErrBadPath = errors.New("invalid path")
|
||||
)
|
||||
|
||||
// MemberRedirect maps an rpm or deb virtual package path (<member>/<href>, as
|
||||
// written by MergeRPM/MergeDeb) to the owning member's route. ok is false for
|
||||
// any other path; ErrBadPath means the path is absolute or escapes the member.
|
||||
// MemberRedirect maps an rpm virtual package path (<member>/<href>, as written
|
||||
// by MergeRPM) to the owning member's route. ok is false for any other path;
|
||||
// ErrBadPath means the path is absolute or escapes the member.
|
||||
func (e *Engine) MemberRedirect(ctx context.Context, virt models.Virtual, path, rawQuery, proxyBaseURL string) (string, bool, error) {
|
||||
switch virt.PackageType {
|
||||
case models.PackageRPM:
|
||||
if strings.HasPrefix(path, "repodata/") {
|
||||
return "", false, nil
|
||||
}
|
||||
case models.PackageDeb:
|
||||
// apt requests a flat repo's files under its "./" dist verbatim.
|
||||
path = strings.TrimPrefix(path, "./")
|
||||
if strings.HasPrefix(path, "dists/") {
|
||||
return "", false, nil
|
||||
}
|
||||
default:
|
||||
if virt.PackageType != models.PackageRPM || strings.HasPrefix(path, "repodata/") {
|
||||
return "", false, nil
|
||||
}
|
||||
name, rest, found := strings.Cut(path, "/")
|
||||
@@ -248,93 +217,76 @@ func (e *Engine) fetchRPM(ctx context.Context, virt models.Virtual, path string)
|
||||
if !strings.HasPrefix(path, "repodata/") {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "rpm\x00" + virt.Name
|
||||
if path != "repodata/repomd.xml" {
|
||||
if body, ok := e.cachedFile(key, path); ok {
|
||||
if body, ok := e.cachedRPMFile(virt.Name, path); ok {
|
||||
return body, "application/gzip", nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
return e.mergeRPM(ctx, virt)
|
||||
})
|
||||
repo, err := e.mergedRPM(ctx, virt)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if path == "repodata/repomd.xml" {
|
||||
return repo.index(), "application/xml", nil
|
||||
return repo.Repomd, "application/xml", nil
|
||||
}
|
||||
if body, ok := repo.files()[path]; ok {
|
||||
if body, ok := repo.Files[path]; ok {
|
||||
return body, "application/gzip", nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
|
||||
// merged returns the merge cached under key, reusing it for mergeTTL and
|
||||
// collapsing concurrent merges of the same key into one.
|
||||
// mergedRPM returns the virtual's merge, reusing it for mergeTTL and
|
||||
// collapsing concurrent merges of the same virtual into one.
|
||||
// ponytail: per-replica cache; behind a non-sticky LB a data request landing on
|
||||
// another replica re-merges and 404s if a member changed in between. Move to the
|
||||
// shared redis cache if that shows up.
|
||||
func (e *Engine) merged(ctx context.Context, key string, merge func(context.Context) (mergedRepo, error)) (mergedRepo, error) {
|
||||
func (e *Engine) mergedRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||
e.mu.Lock()
|
||||
g := e.gens[key]
|
||||
g := e.rpm[virt.Name]
|
||||
if g != nil && time.Since(g.at) < e.mergeTTL {
|
||||
g.used = time.Now()
|
||||
e.mu.Unlock()
|
||||
return g.cur, nil
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
v, err, _ := e.sf.Do(key, func() (any, error) {
|
||||
repo, err := merge(context.WithoutCancel(ctx))
|
||||
v, err, _ := e.sf.Do(virt.Name, func() (any, error) {
|
||||
repo, err := e.mergeRPM(context.WithoutCancel(ctx), virt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if e.gens == nil {
|
||||
e.gens = map[string]*repoGen{}
|
||||
if e.rpm == nil {
|
||||
e.rpm = map[string]*rpmGen{}
|
||||
}
|
||||
g := e.gens[key]
|
||||
g := e.rpm[virt.Name]
|
||||
switch {
|
||||
case g == nil:
|
||||
e.gens[key] = &repoGen{cur: repo, at: time.Now(), used: time.Now()}
|
||||
case string(g.cur.index()) == string(repo.index()):
|
||||
g.at, g.used = time.Now(), time.Now()
|
||||
e.rpm[virt.Name] = &rpmGen{cur: repo, at: time.Now()}
|
||||
case string(g.cur.Repomd) == string(repo.Repomd):
|
||||
g.at = time.Now()
|
||||
repo = g.cur
|
||||
default:
|
||||
g.prev, g.cur, g.at, g.used = g.cur, repo, time.Now(), time.Now()
|
||||
g.prev, g.cur, g.at = g.cur, repo, time.Now()
|
||||
}
|
||||
return repo, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.(mergedRepo), nil
|
||||
return v.(*RPMRepo), nil
|
||||
}
|
||||
|
||||
func (e *Engine) mergeRPM(ctx context.Context, virt models.Virtual) (*RPMRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*RPMMember, error) { return e.rpmMember(ctx, name) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repo, err := MergeRPM(members)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// fetchMembers reads every member in parallel, failing if any member fails.
|
||||
func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error)) ([]T, error) {
|
||||
members := make([]T, len(virt.Members))
|
||||
members := make([]RPMMember, len(virt.Members))
|
||||
errs := make([]error, len(virt.Members))
|
||||
var wg sync.WaitGroup
|
||||
for i, name := range virt.Members {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
m, err := fetch(name)
|
||||
m, err := e.rpmMember(ctx, name)
|
||||
if err != nil {
|
||||
errs[i] = fmt.Errorf("member %q: %w", name, err)
|
||||
return
|
||||
@@ -347,101 +299,26 @@ func fetchMembers[T any](virt models.Virtual, fetch func(name string) (*T, error
|
||||
if err := errors.Join(errs...); err != nil {
|
||||
return nil, fmt.Errorf("virtual %q: %v", virt.Name, err)
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
func (e *Engine) fetchDeb(ctx context.Context, virt models.Virtual, reqPath string) ([]byte, string, error) {
|
||||
base, name, ok := debIndexPath(strings.TrimPrefix(path.Clean("/"+reqPath), "/"))
|
||||
if !ok {
|
||||
return nil, "", ErrNotFound
|
||||
}
|
||||
key := "deb\x00" + virt.Name + "\x00" + base
|
||||
if name != "Release" {
|
||||
if body, ok := e.cachedFile(key, name); ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
}
|
||||
|
||||
repo, err := e.merged(ctx, key, func(ctx context.Context) (mergedRepo, error) {
|
||||
members, err := fetchMembers(virt, func(name string) (*DebMember, error) { return e.debMember(ctx, name, base) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return MergeDeb(members), nil
|
||||
})
|
||||
repo, err := MergeRPM(members)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
return nil, fmt.Errorf("merge rpm repodata: %w", err)
|
||||
}
|
||||
e.evictLRU("deb\x00"+virt.Name+"\x00", maxDebSuites)
|
||||
if name == "Release" {
|
||||
return repo.index(), "text/plain", nil
|
||||
}
|
||||
if body, ok := repo.files()[name]; ok {
|
||||
return body, debContentType(name), nil
|
||||
}
|
||||
return nil, "", ErrNotFound
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
func debContentType(name string) string {
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
return "application/gzip"
|
||||
}
|
||||
return "text/plain"
|
||||
}
|
||||
|
||||
// fetchDebMember reads a member's Release under base and each binary Packages
|
||||
// index it lists.
|
||||
func (e *Engine) fetchDebMember(ctx context.Context, name, base string) (*DebMember, error) {
|
||||
remote, err := e.getRemote(ctx, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("remote %q: %w", name, err)
|
||||
}
|
||||
rel, err := e.fetchMemberPath(ctx, *remote, base+"Release")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &DebMember{RemoteName: name, Release: rel, Packages: map[string][]byte{}}
|
||||
for dir, file := range debIndexFiles(parseDebRelease(rel)) {
|
||||
raw, err := e.fetchMemberPath(ctx, *remote, base+file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if m.Packages[dir], err = decompress(file, raw); err != nil {
|
||||
return nil, fmt.Errorf("decompress %s: %w", file, err)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// evictLRU drops the least recently used merges under prefix beyond keep.
|
||||
func (e *Engine) evictLRU(prefix string, keep int) {
|
||||
func (e *Engine) cachedRPMFile(virt, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
var keys []string
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(keys, func(a, b string) int { return e.gens[b].used.Compare(e.gens[a].used) })
|
||||
for _, k := range keys[min(len(keys), keep):] {
|
||||
delete(e.gens, k)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) cachedFile(key, path string) ([]byte, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
g := e.gens[key]
|
||||
g := e.rpm[virt]
|
||||
if g == nil {
|
||||
return nil, false
|
||||
}
|
||||
for _, r := range []mergedRepo{g.cur, g.prev} {
|
||||
for _, r := range []*RPMRepo{g.cur, g.prev} {
|
||||
if r == nil {
|
||||
continue
|
||||
}
|
||||
if body, ok := r.files()[path]; ok {
|
||||
g.used = time.Now()
|
||||
if body, ok := r.Files[path]; ok {
|
||||
return body, true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -157,213 +156,3 @@ func TestMemberRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func debEngine(members map[string]*DebMember) *Engine {
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
if m := members[name]; m != nil && base == "" {
|
||||
return m, nil
|
||||
}
|
||||
return nil, errors.New("upstream down")
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func debVirt(members ...string) models.Virtual {
|
||||
return models.Virtual{Name: "v", PackageType: models.PackageDeb, Members: members}
|
||||
}
|
||||
|
||||
func TestFetchDebFailsClosed(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
for _, p := range []string{"Release", "./Packages.gz", "dists/trixie/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a", "b"), p, ""); err == nil || errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want upstream error with a member down, got %v", p, err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"InRelease", "./Release.gpg", "Packages.xz"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("%s: want not found, got %v", p, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebRemergesAfterTTL(t *testing.T) {
|
||||
a := flatDebMember("a", "", debStanza("foo", "1", "amd64", "pool/foo_1.deb"))
|
||||
e := debEngine(map[string]*DebMember{"a": &a})
|
||||
virt := debVirt("a")
|
||||
|
||||
rel, _, err := e.Fetch(context.Background(), virt, "./Release", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.Packages[""] = []byte(debStanza("foo", "2", "amd64", "pool/foo_2.deb"))
|
||||
e.mergeTTL = 0
|
||||
newRel, _, err := e.Fetch(context.Background(), virt, "Release", "")
|
||||
if err != nil || string(newRel) == string(rel) {
|
||||
t.Fatalf("Release should reflect the member change (err %v)", err)
|
||||
}
|
||||
pk, ct, err := e.Fetch(context.Background(), virt, "./Packages", "")
|
||||
if err != nil || ct != "text/plain" || !strings.Contains(string(pk), "Version: 2") {
|
||||
t.Fatalf("Packages should be the current merge (ct %q, err %v):\n%s", ct, err, pk)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebMergeIsSharedPerSuite(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, base string) (*DebMember, error) {
|
||||
calls.Add(1)
|
||||
m := flatDebMember(name, "", debStanza("foo", "1", "amd64", "pool/foo.deb"))
|
||||
return &m, nil
|
||||
}
|
||||
for _, p := range []string{"Release", "Packages", "Packages.gz", "./Release", "dists/a/Release", "dists/b/Release"} {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), p, ""); err != nil && !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("member fetched %d times, want once per Release dir (3)", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemberRedirectDeb(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
virt := debVirt("gh", "loc")
|
||||
for _, tc := range []struct {
|
||||
path, want string
|
||||
ok bool
|
||||
err error
|
||||
}{
|
||||
{path: "gh/o/r/releases/download/v1/foo_1.0_amd64.deb", ok: true, want: "http://h/api/v1/remote/gh/o/r/releases/download/v1/foo_1.0_amd64.deb"},
|
||||
{path: "./gh/pool/a+b.deb", ok: true, want: "http://h/api/v1/remote/gh/pool/a+b.deb"},
|
||||
{path: "gh/../loc/x.deb", err: ErrBadPath},
|
||||
{path: "dists/trixie/Release"},
|
||||
{path: "Packages"},
|
||||
} {
|
||||
got, ok, err := e.MemberRedirect(context.Background(), virt, tc.path, "", "http://h/")
|
||||
if got != tc.want || ok != tc.ok || !errors.Is(err, tc.err) {
|
||||
t.Errorf("%s: got (%q, %v, %v), want (%q, %v, %v)", tc.path, got, ok, err, tc.want, tc.ok, tc.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRepo struct {
|
||||
idx string
|
||||
fs map[string][]byte
|
||||
}
|
||||
|
||||
func (r *fakeRepo) index() []byte { return []byte(r.idx) }
|
||||
func (r *fakeRepo) files() map[string][]byte { return r.fs }
|
||||
|
||||
func genRepo(gen string) func(context.Context) (mergedRepo, error) {
|
||||
return func(context.Context) (mergedRepo, error) {
|
||||
return &fakeRepo{idx: gen, fs: map[string][]byte{gen + ".gz": []byte(gen), "fixed": []byte(gen)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedRetainsPreviousGeneration(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
for _, gen := range []string{"g1", "g2", "g3"} {
|
||||
if _, err := e.merged(context.Background(), "k", genRepo(gen)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for path, want := range map[string]string{"g3.gz": "g3", "g2.gz": "g2", "fixed": "g3"} {
|
||||
if body, ok := e.cachedFile("k", path); !ok || string(body) != want {
|
||||
t.Errorf("%s: got (%q, %v), want %q", path, body, ok, want)
|
||||
}
|
||||
}
|
||||
if _, ok := e.cachedFile("k", "g1.gz"); ok {
|
||||
t.Error("generation two merges back must be dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedReusesWithinTTLAndIsolatesKeys(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
for _, step := range []struct{ key, gen, want string }{
|
||||
{"a", "a1", "a1"}, {"b", "b1", "b1"}, {"a", "a2", "a1"},
|
||||
} {
|
||||
repo, err := e.merged(context.Background(), step.key, genRepo(step.gen))
|
||||
if err != nil || string(repo.index()) != step.want {
|
||||
t.Fatalf("%s: got %v (err %v), want %s", step.key, repo, err, step.want)
|
||||
}
|
||||
}
|
||||
if _, ok := e.cachedFile("b", "a1.gz"); ok {
|
||||
t.Error("key b served key a's file")
|
||||
}
|
||||
if _, ok := e.cachedFile("c", "a1.gz"); ok {
|
||||
t.Error("unknown key served a file")
|
||||
}
|
||||
}
|
||||
|
||||
func suiteDebMember(name, version string) *DebMember {
|
||||
return &DebMember{RemoteName: name, Release: []byte("Suite: s\nSHA256:\n x 1 main/binary-amd64/Packages.gz\n"),
|
||||
Packages: map[string][]byte{"main/binary-amd64/": []byte(debStanza("foo", version, "amd64", "pool/foo_"+version+".deb"))}}
|
||||
}
|
||||
|
||||
func TestFetchDebByHashSurvivesRemerge(t *testing.T) {
|
||||
version := "1"
|
||||
e := fakeEngine(nil)
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, version), nil }
|
||||
virt := debVirt("a")
|
||||
fetch := func(p string) []byte {
|
||||
body, _, err := e.Fetch(context.Background(), virt, p, "")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
byHash := func(rel []byte) string {
|
||||
m := regexp.MustCompile(`(?m)^SHA256:\n(?: .*\n)*? ([0-9a-f]{64}) \d+ main/binary-amd64/Packages.gz$`).FindSubmatch(rel)
|
||||
if m == nil {
|
||||
t.Fatalf("no Packages.gz SHA256 in Release:\n%s", rel)
|
||||
}
|
||||
return "dists/trixie/main/binary-amd64/by-hash/SHA256/" + string(m[1])
|
||||
}
|
||||
|
||||
oldRel := fetch("dists/trixie/Release")
|
||||
version, e.mergeTTL = "2", 0
|
||||
if newRel := fetch("dists/trixie/Release"); string(newRel) == string(oldRel) {
|
||||
t.Fatal("Release should reflect the member change")
|
||||
}
|
||||
if got := string(gunzip(t, fetch(byHash(oldRel)))); !strings.Contains(got, "Version: 1\n") {
|
||||
t.Fatalf("by-hash from the previous Release served the wrong index:\n%s", got)
|
||||
}
|
||||
if got := string(fetch("dists/trixie/main/binary-amd64/Packages")); !strings.Contains(got, "Version: 2\n") {
|
||||
t.Fatalf("fixed-name Packages should be the current merge:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchDebBoundsSuitesPerVirtual(t *testing.T) {
|
||||
e := fakeEngine(nil)
|
||||
e.mergeTTL = time.Minute
|
||||
e.debMember = func(_ context.Context, name, _ string) (*DebMember, error) { return suiteDebMember(name, "1"), nil }
|
||||
other := models.Virtual{Name: "other", PackageType: models.PackageDeb, Members: []string{"a"}}
|
||||
if _, _, err := e.Fetch(context.Background(), other, "dists/keep/Release", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range maxDebSuites + 5 {
|
||||
if _, _, err := e.Fetch(context.Background(), debVirt("a"), fmt.Sprintf("dists/s%d/Release", i), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
time.Sleep(time.Microsecond)
|
||||
}
|
||||
n := 0
|
||||
for k := range e.gens {
|
||||
if strings.HasPrefix(k, "deb\x00v\x00") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != maxDebSuites {
|
||||
t.Fatalf("virtual v caches %d suites, want %d", n, maxDebSuites)
|
||||
}
|
||||
if e.gens["deb\x00v\x00dists/s0/"] != nil || e.gens["deb\x00v\x00dists/s4/"] != nil {
|
||||
t.Fatal("least recently used suites should be evicted first")
|
||||
}
|
||||
if e.gens["deb\x00other\x00dists/keep/"] == nil {
|
||||
t.Fatal("another virtual's suites must not be evicted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 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));
|
||||
|
||||
-- Cargo treats names equal up to case and -/_, and versions equal up to build metadata.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_cargo_index_unique_version
|
||||
ON cargo_index(repo_name, replace(lower(name), '-', '_'), split_part(version, '+', 1));
|
||||
@@ -229,6 +229,25 @@ go mod download`,
|
||||
];
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user