Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c321a089d |
@@ -279,19 +279,19 @@ 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 npm registry
|
||||
### Local cargo registry
|
||||
|
||||
A local `npm` repo is a real npm registry: `npm publish` (scoped and unscoped)
|
||||
and `npm install` work against `/api/v1/local/{name}/`. npm requires an
|
||||
`_authToken` for the registry before it will publish; any value works.
|
||||
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.
|
||||
|
||||
```ini
|
||||
# .npmrc
|
||||
registry=https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/
|
||||
//artifactapi.k8s.syd1.au.unkin.net/api/v1/local/npm-internal/:_authToken=unused
|
||||
```toml
|
||||
[registries.<repo>]
|
||||
index = "sparse+https://artifactapi.k8s.syd1.au.unkin.net/api/v1/local/<repo>/"
|
||||
```
|
||||
|
||||
Versions are immutable; dist-tags sent with a publish move to that version.
|
||||
cargo requires a token for publish/yank (`CARGO_REGISTRIES_<REPO>_TOKEN`); like
|
||||
other local repos, artifactapi does not check it.
|
||||
|
||||
## Access Control
|
||||
|
||||
@@ -311,7 +311,6 @@ No patterns + no blocklist = open proxy. Provider handles mutability classificat
|
||||
```
|
||||
GET /api/v1/remote/{name}/{path} Proxy/cache artifact
|
||||
GET /api/v1/virtual/{name}/{path} Virtual repo (merged index)
|
||||
PUT /api/v1/local/{name}/{package} npm publish (local npm repos)
|
||||
GET /v2/{name}/{path} Docker Registry v2
|
||||
```
|
||||
|
||||
|
||||
@@ -27,9 +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), npm (stock
|
||||
`node:22` client: `npm publish` unscoped + scoped, `npm install` + `require()`,
|
||||
second version, republish rejected).
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build dockere2e
|
||||
|
||||
package e2edocker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestLocalNPMPublishInstall drives a stock npm client against a local npm
|
||||
// repo: publish unscoped and scoped packages, install and require() them in a
|
||||
// fresh project, publish a second version, and reject a republish.
|
||||
func TestLocalNPMPublishInstall(t *testing.T) {
|
||||
network := os.Getenv("COMPOSE_NETWORK")
|
||||
internal := os.Getenv("ARTIFACTAPI_INTERNAL")
|
||||
if network == "" || internal == "" {
|
||||
t.Skip("COMPOSE_NETWORK/ARTIFACTAPI_INTERNAL not set; run via scripts/docker-e2e.sh")
|
||||
}
|
||||
if _, err := exec.LookPath("docker"); err != nil {
|
||||
t.Skip("docker not available on the test host")
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("e2e-npm-local-%d", time.Now().UnixNano())
|
||||
createRepo(t, fmt.Sprintf(`{"name":%q,"package_type":"npm","repo_type":"local"}`, name))
|
||||
defer deleteRepo(t, name)
|
||||
|
||||
reg := strings.TrimRight(internal, "/") + "/api/v1/local/" + name + "/"
|
||||
script := `set -euo pipefail
|
||||
printf 'registry=%s\n%s:_authToken=unused\n' "$REG" "${REG#http:}" > ~/.npmrc
|
||||
mk() { d="/w/src/$1-$2"; mkdir -p "$d"; cd "$d"
|
||||
printf '{"name":"%s","version":"%s","main":"index.js"}' "$1" "$2" > package.json
|
||||
echo "module.exports = '$1@$2';" > index.js; }
|
||||
mk e2e-npm-plain 1.0.0 && npm publish
|
||||
mk @e2e/npm-scoped 1.0.0 && npm publish
|
||||
mk e2e-npm-plain 1.0.0
|
||||
if npm publish 2>/dev/null; then echo "republish accepted"; exit 1; fi
|
||||
mkdir -p /w/app && cd /w/app && npm init -y >/dev/null
|
||||
npm install --no-audit --cache /tmp/fresh-cache e2e-npm-plain @e2e/npm-scoped
|
||||
node -e "console.log('REQ', require('e2e-npm-plain'), require('@e2e/npm-scoped'))"
|
||||
mk e2e-npm-plain 1.1.0 && npm publish
|
||||
echo "VERSIONS $(npm view e2e-npm-plain versions --json | tr -d ' \n')"
|
||||
echo "LATEST $(npm view e2e-npm-plain dist-tags.latest)"`
|
||||
|
||||
out, err := exec.Command("docker", "run", "--rm", "--network", network,
|
||||
"-e", "REG="+reg, "node:22", "bash", "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("npm client run failed: %v\n%s", err, out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"REQ e2e-npm-plain@1.0.0 @e2e/npm-scoped@1.0.0",
|
||||
`VERSIONS ["1.0.0","1.1.0"]`,
|
||||
"LATEST 1.1.0",
|
||||
} {
|
||||
if !strings.Contains(string(out), want) {
|
||||
t.Fatalf("missing %q in npm output:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
// Local npm repos speak the npm registry publish/read protocol. Per package:
|
||||
// <pkg>/-/<base>-<version>.tgz tarball (immutable)
|
||||
// <pkg>/-/<base>-<version>.json version manifest (immutable)
|
||||
// <pkg>/-/dist-tags/<tag> version string (mutable)
|
||||
|
||||
var (
|
||||
npmNameRe = regexp.MustCompile(`^(@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*$`)
|
||||
npmRefRe = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z.+_-]*$`)
|
||||
)
|
||||
|
||||
func (h *ProxyHandler) localNPMRemote(r *http.Request, name string) (*models.Remote, bool) {
|
||||
remote, err := h.db.GetRemote(r.Context(), name)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return remote, remote.RepoType == models.RepoTypeLocal && remote.PackageType == models.PackageNPM
|
||||
}
|
||||
|
||||
// npmPackageName returns the package a packument/publish path addresses, or ""
|
||||
// when the path is something else (e.g. a tarball under <pkg>/-/).
|
||||
func npmPackageName(raw string) string {
|
||||
p, err := url.PathUnescape(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
p = strings.TrimSuffix(p, "/")
|
||||
if !npmNameRe.MatchString(p) {
|
||||
return ""
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type npmRelease struct {
|
||||
version string
|
||||
manifest []byte
|
||||
tarball []byte
|
||||
tags []string
|
||||
}
|
||||
|
||||
func parseNPMPublish(body io.Reader, pkg string) (*npmRelease, error) {
|
||||
var doc struct {
|
||||
Name string `json:"name"`
|
||||
DistTags map[string]string `json:"dist-tags"`
|
||||
Versions map[string]json.RawMessage `json:"versions"`
|
||||
Attachments map[string]struct {
|
||||
Data string `json:"data"`
|
||||
} `json:"_attachments"`
|
||||
}
|
||||
if err := json.NewDecoder(body).Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("invalid publish body: %w", err)
|
||||
}
|
||||
if doc.Name != pkg {
|
||||
return nil, fmt.Errorf("body name %q does not match %q", doc.Name, pkg)
|
||||
}
|
||||
if len(doc.Versions) != 1 || len(doc.Attachments) != 1 {
|
||||
return nil, errors.New("publish must carry exactly one version and one tarball")
|
||||
}
|
||||
|
||||
rel := &npmRelease{}
|
||||
for v, m := range doc.Versions {
|
||||
rel.version, rel.manifest = v, m
|
||||
}
|
||||
if !npmRefRe.MatchString(rel.version) {
|
||||
return nil, fmt.Errorf("invalid version %q", rel.version)
|
||||
}
|
||||
var meta struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Dist struct {
|
||||
Integrity string `json:"integrity"`
|
||||
Shasum string `json:"shasum"`
|
||||
} `json:"dist"`
|
||||
}
|
||||
if err := json.Unmarshal(rel.manifest, &meta); err != nil {
|
||||
return nil, fmt.Errorf("invalid version manifest: %w", err)
|
||||
}
|
||||
if meta.Name != pkg || meta.Version != rel.version {
|
||||
return nil, errors.New("version manifest name/version mismatch")
|
||||
}
|
||||
|
||||
for _, a := range doc.Attachments {
|
||||
tgz, err := base64.StdEncoding.DecodeString(a.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid tarball encoding: %w", err)
|
||||
}
|
||||
rel.tarball = tgz
|
||||
}
|
||||
if err := verifyNPMDist(rel.tarball, meta.Dist.Integrity, meta.Dist.Shasum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for tag, v := range doc.DistTags {
|
||||
if !npmRefRe.MatchString(tag) || v != rel.version {
|
||||
return nil, fmt.Errorf("dist-tag %q must point at the published version", tag)
|
||||
}
|
||||
rel.tags = append(rel.tags, tag)
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func verifyNPMDist(tgz []byte, integrity, shasum string) error {
|
||||
if b64, ok := strings.CutPrefix(integrity, "sha512-"); ok {
|
||||
sum := sha512.Sum512(tgz)
|
||||
if base64.StdEncoding.EncodeToString(sum[:]) != b64 {
|
||||
return errors.New("tarball does not match dist.integrity")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if shasum != "" {
|
||||
sum := sha1.Sum(tgz)
|
||||
if hex.EncodeToString(sum[:]) != shasum {
|
||||
return errors.New("tarball does not match dist.shasum")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errors.New("version manifest carries no sha512 integrity or shasum")
|
||||
}
|
||||
|
||||
func npmError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) handleLocalPut(w http.ResponseWriter, r *http.Request) {
|
||||
remote, ok := h.localNPMRemote(r, chi.URLParam(r, "localName"))
|
||||
if !ok {
|
||||
npmError(w, http.StatusMethodNotAllowed, "publish is only supported on local npm repositories")
|
||||
return
|
||||
}
|
||||
pkg := npmPackageName(chi.URLParam(r, "*"))
|
||||
if pkg == "" {
|
||||
npmError(w, http.StatusBadRequest, "invalid package name")
|
||||
return
|
||||
}
|
||||
rel, err := parseNPMPublish(r.Body, pkg)
|
||||
if err != nil {
|
||||
npmError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
base := pkg[strings.LastIndex(pkg, "/")+1:]
|
||||
stem := pkg + "/-/" + base + "-" + rel.version
|
||||
// The tarball's immutable row is the version's uniqueness gate.
|
||||
if err := h.npmStore(r.Context(), remote.Name, stem+".tgz", rel.tarball, "application/octet-stream", false); err != nil {
|
||||
if errors.Is(err, database.ErrAlreadyExists) {
|
||||
npmError(w, http.StatusConflict, fmt.Sprintf("%s@%s already exists; overwrites are not allowed", pkg, rel.version))
|
||||
return
|
||||
}
|
||||
npmError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.npmStore(r.Context(), remote.Name, stem+".json", rel.manifest, "application/json", false); err != nil {
|
||||
npmError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
for _, tag := range rel.tags {
|
||||
if err := h.npmStore(r.Context(), remote.Name, pkg+"/-/dist-tags/"+tag, []byte(rel.version), "text/plain", true); err != nil {
|
||||
npmError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("local npm package published", "repo", remote.Name, "package", pkg, "version", rel.version)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": pkg})
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) npmStore(ctx context.Context, repo, path string, data []byte, contentType string, mutable bool) error {
|
||||
result, err := h.cas.Store(ctx, bytes.NewReader(data), contentType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.db.UpsertBlob(ctx, result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
|
||||
return err
|
||||
}
|
||||
if mutable {
|
||||
return h.db.UpsertLocalFile(ctx, repo, path, result.ContentHash)
|
||||
}
|
||||
return h.db.CreateLocalFile(ctx, repo, path, result.ContentHash)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) readLocalBlob(ctx context.Context, contentHash string) ([]byte, error) {
|
||||
reader, _, err := h.store.Download(ctx, storage.BlobKey(strings.TrimPrefix(contentHash, "sha256:")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
return io.ReadAll(reader)
|
||||
}
|
||||
|
||||
func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, remote *models.Remote, pkg string) {
|
||||
files, err := h.db.ListLocalFilesByPrefix(r.Context(), remote.Name, pkg+"/-/")
|
||||
if err != nil {
|
||||
npmError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) }
|
||||
tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, remote.Name)
|
||||
doc, err := buildNPMPackument(pkg, files, read, tarballBase)
|
||||
if err != nil {
|
||||
npmError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if doc == nil {
|
||||
npmError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("X-Artifact-Source", "local")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(doc)
|
||||
}
|
||||
|
||||
// buildNPMPackument returns nil when pkg has no published versions.
|
||||
func buildNPMPackument(pkg string, files []database.LocalFile, read func(hash string) ([]byte, error), tarballBase string) (map[string]any, error) {
|
||||
prefix := pkg + "/-/"
|
||||
versions := map[string]any{}
|
||||
tags := map[string]string{}
|
||||
for _, f := range files {
|
||||
// LIKE treats '_' as a wildcard, so the prefix match can over-select.
|
||||
rest, ok := strings.CutPrefix(f.FilePath, prefix)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tag, isTag := strings.CutPrefix(rest, "dist-tags/"); isTag {
|
||||
v, err := read(f.ContentHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags[tag] = string(v)
|
||||
continue
|
||||
}
|
||||
stem, isManifest := strings.CutSuffix(rest, ".json")
|
||||
if !isManifest {
|
||||
continue
|
||||
}
|
||||
raw, err := read(f.ContentHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil, fmt.Errorf("manifest %s: %w", f.FilePath, err)
|
||||
}
|
||||
dist, _ := m["dist"].(map[string]any)
|
||||
if dist == nil {
|
||||
dist = map[string]any{}
|
||||
m["dist"] = dist
|
||||
}
|
||||
dist["tarball"] = tarballBase + prefix + stem + ".tgz"
|
||||
v, _ := m["version"].(string)
|
||||
versions[v] = m
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]any{"_id": pkg, "name": pkg, "dist-tags": tags, "versions": versions}, nil
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||
)
|
||||
|
||||
func TestNPMPackageName(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"left-pad": "left-pad",
|
||||
"left-pad/": "left-pad",
|
||||
"@acme%2fwidget": "@acme/widget",
|
||||
"@acme%2Fwidget": "@acme/widget",
|
||||
"@acme/widget": "@acme/widget",
|
||||
"left-pad/-/left-pad.tgz": "",
|
||||
"@acme/widget/-/w-1.tgz": "",
|
||||
"a/b": "",
|
||||
"../etc": "",
|
||||
"Upper": "",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range tests {
|
||||
if got := npmPackageName(in); got != want {
|
||||
t.Errorf("npmPackageName(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type publishOpts struct {
|
||||
bodyName, manifestName, version, integrity, shasum string
|
||||
tags map[string]string
|
||||
extraVersion bool
|
||||
}
|
||||
|
||||
func publishBody(t *testing.T, pkg string, tgz []byte, o publishOpts) string {
|
||||
t.Helper()
|
||||
sha := sha512.Sum512(tgz)
|
||||
sh1 := sha1.Sum(tgz)
|
||||
if o.bodyName == "" {
|
||||
o.bodyName = pkg
|
||||
}
|
||||
if o.manifestName == "" {
|
||||
o.manifestName = pkg
|
||||
}
|
||||
if o.version == "" {
|
||||
o.version = "1.0.0"
|
||||
}
|
||||
if o.integrity == "" {
|
||||
o.integrity = "sha512-" + base64.StdEncoding.EncodeToString(sha[:])
|
||||
}
|
||||
if o.shasum == "" {
|
||||
o.shasum = hex.EncodeToString(sh1[:])
|
||||
}
|
||||
if o.tags == nil {
|
||||
o.tags = map[string]string{"latest": o.version}
|
||||
}
|
||||
versions := map[string]any{o.version: map[string]any{
|
||||
"name": o.manifestName, "version": o.version, "main": "index.js",
|
||||
"dist": map[string]any{"integrity": o.integrity, "shasum": o.shasum, "tarball": "http://elsewhere/x.tgz"},
|
||||
}}
|
||||
if o.extraVersion {
|
||||
versions["2.0.0"] = map[string]any{"name": pkg, "version": "2.0.0"}
|
||||
}
|
||||
b, _ := json.Marshal(map[string]any{
|
||||
"_id": pkg, "name": o.bodyName, "dist-tags": o.tags, "versions": versions,
|
||||
"_attachments": map[string]any{pkg + "-" + o.version + ".tgz": map[string]any{
|
||||
"content_type": "application/octet-stream", "data": base64.StdEncoding.EncodeToString(tgz), "length": len(tgz),
|
||||
}},
|
||||
})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestParseNPMPublish(t *testing.T) {
|
||||
tgz := []byte("fake tarball bytes")
|
||||
|
||||
rel, err := parseNPMPublish(strings.NewReader(publishBody(t, "@acme/widget", tgz, publishOpts{tags: map[string]string{"latest": "1.0.0", "beta": "1.0.0"}})), "@acme/widget")
|
||||
if err != nil {
|
||||
t.Fatalf("valid scoped publish rejected: %v", err)
|
||||
}
|
||||
if rel.version != "1.0.0" || string(rel.tarball) != string(tgz) || len(rel.tags) != 2 {
|
||||
t.Fatalf("unexpected release: %+v", rel)
|
||||
}
|
||||
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none", shasum: hex.EncodeToString(make([]byte, 20))})), "left-pad"); err == nil {
|
||||
t.Fatal("shasum fallback should reject a mismatched tarball")
|
||||
}
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, publishOpts{integrity: "none"})), "left-pad"); err != nil {
|
||||
t.Fatalf("matching shasum fallback rejected: %v", err)
|
||||
}
|
||||
|
||||
bad := map[string]publishOpts{
|
||||
"body name mismatch": {bodyName: "other"},
|
||||
"manifest name mismatch": {manifestName: "other"},
|
||||
"integrity mismatch": {integrity: "sha512-AAAA"},
|
||||
"path-like version": {version: "../1.0.0"},
|
||||
"tag at another version": {tags: map[string]string{"latest": "9.9.9"}},
|
||||
"path-like tag": {tags: map[string]string{"../x": "1.0.0"}},
|
||||
"multiple versions at once": {extraVersion: true},
|
||||
}
|
||||
for name, o := range bad {
|
||||
if _, err := parseNPMPublish(strings.NewReader(publishBody(t, "left-pad", tgz, o)), "left-pad"); err == nil {
|
||||
t.Errorf("%s: expected rejection", name)
|
||||
}
|
||||
}
|
||||
if _, err := parseNPMPublish(strings.NewReader(`{"name":"left-pad","versions":{}}`), "left-pad"); err == nil {
|
||||
t.Error("deprecate-style body without attachments should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildNPMPackument(t *testing.T) {
|
||||
blobs := map[string]string{
|
||||
"h1": `{"name":"my_pkg","version":"1.0.0","dist":{"shasum":"aa","tarball":"http://elsewhere/x.tgz"}}`,
|
||||
"h2": `{"name":"my_pkg","version":"1.1.0"}`,
|
||||
"h3": "1.1.0",
|
||||
"hx": `{"name":"myXpkg","version":"6.6.6"}`,
|
||||
}
|
||||
read := func(h string) ([]byte, error) {
|
||||
b, ok := blobs[h]
|
||||
if !ok {
|
||||
return nil, errors.New("missing blob")
|
||||
}
|
||||
return []byte(b), nil
|
||||
}
|
||||
files := []database.LocalFile{
|
||||
{FilePath: "my_pkg/-/dist-tags/latest", ContentHash: "h3"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.0.0.json", ContentHash: "h1"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.0.0.tgz", ContentHash: "nope"},
|
||||
{FilePath: "my_pkg/-/my_pkg-1.1.0.json", ContentHash: "h2"},
|
||||
{FilePath: "myXpkg/-/myXpkg-6.6.6.json", ContentHash: "hx"},
|
||||
}
|
||||
|
||||
doc, err := buildNPMPackument("my_pkg", files, read, "http://reg/api/v1/local/npm/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tags := doc["dist-tags"].(map[string]string); tags["latest"] != "1.1.0" {
|
||||
t.Errorf("dist-tags = %v", tags)
|
||||
}
|
||||
versions := doc["versions"].(map[string]any)
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("versions = %v (LIKE over-match must be dropped)", versions)
|
||||
}
|
||||
v1 := versions["1.0.0"].(map[string]any)["dist"].(map[string]any)
|
||||
if v1["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.0.0.tgz" || v1["shasum"] != "aa" {
|
||||
t.Errorf("1.0.0 dist = %v", v1)
|
||||
}
|
||||
if versions["1.1.0"].(map[string]any)["dist"].(map[string]any)["tarball"] != "http://reg/api/v1/local/npm/my_pkg/-/my_pkg-1.1.0.tgz" {
|
||||
t.Error("1.1.0 missing rewritten tarball")
|
||||
}
|
||||
|
||||
if doc, err := buildNPMPackument("absent", files, read, "x/"); err != nil || doc != nil {
|
||||
t.Errorf("absent package: doc=%v err=%v", doc, err)
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||
)
|
||||
|
||||
type ProxyHandler struct {
|
||||
@@ -42,7 +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}/*", h.handleLocalPut)
|
||||
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
|
||||
}
|
||||
@@ -201,13 +202,6 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if remote.PackageType == models.PackageNPM {
|
||||
if pkg := npmPackageName(path); pkg != "" {
|
||||
h.serveNPMPackument(w, r, remote, pkg)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
prov, _ := provider.Get(remote.PackageType)
|
||||
if indexer, ok := prov.(provider.LocalIndexer); ok {
|
||||
if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
@@ -101,27 +101,6 @@ pip install <package>`,
|
||||
];
|
||||
|
||||
case 'npm':
|
||||
if (isLocal) {
|
||||
const reg = `${url}/api/v1/local/${name}/`;
|
||||
return [
|
||||
{
|
||||
title: 'Configure .npmrc (registry + publish token)',
|
||||
language: 'bash',
|
||||
code: `cat >> .npmrc <<'EOF'
|
||||
registry=${reg}
|
||||
//${host}/api/v1/local/${name}/:_authToken=unused
|
||||
EOF`,
|
||||
note: 'npm refuses to publish without an _authToken for the registry; any value works. For a scope only, use @<scope>:registry=… instead of registry=.',
|
||||
},
|
||||
{
|
||||
title: 'Publish and install',
|
||||
language: 'bash',
|
||||
code: `npm publish
|
||||
npm install <package>`,
|
||||
note: 'Versions are immutable: republishing an existing version is rejected. The dist-tags sent with the publish (latest, or --tag) move to the new version.',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
title: 'Point npm at this registry',
|
||||
@@ -250,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