4c321a089d
- serve sparse index and config.json for local cargo repos from a cargo_index table - add Web API publish, yank and unyank under the local repo api root - drop index lines when the crate file is deleted
304 lines
8.6 KiB
Go
304 lines
8.6 KiB
Go
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
|
|
}
|