Add cargo sparse registry remote type (#132)

Rust crate builds (rpmbuilder CI) fetch from crates.io directly because artifactapi has no Cargo remote type.

- add `cargo` package type proxying the sparse registry protocol (RFC 2789)
- synthesize `config.json` so `dl` points crate downloads back at the remote
- treat index files as mutable and `crates/*/*.crate` as immutable, fetched from static.crates.io for crates.io
- add a `~/.cargo/config.toml` usage snippet to the UI and a cargo e2e caching case

Reviewed-on: #132
Co-authored-by: unkin-agent <unkin-agent@unkin.net>
Co-committed-by: unkin-agent <unkin-agent@unkin.net>
This commit was merged in pull request #132.
This commit is contained in:
2026-10-09 21:44:17 +11:00
committed by BenVincent
parent bda762b10e
commit 80368986bb
12 changed files with 192 additions and 2 deletions
+92
View File
@@ -0,0 +1,92 @@
// Package cargo proxies a Cargo sparse registry (RFC 2789): mutable index
// files plus immutable .crate downloads, with config.json synthesized so
// cargo fetches crates back through artifactapi.
package cargo
import (
"context"
"encoding/json"
"net/http"
"net/url"
"strings"
"git.unkin.net/unkin/artifactapi/internal/auth"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func init() {
provider.Register(&Provider{})
}
const (
crateDir = "crates/"
cratesIOIndex = "index.crates.io"
cratesIODownload = "https://static.crates.io"
)
type Provider struct{}
func (p *Provider) Type() models.PackageType { return models.PackageCargo }
func (p *Provider) Classify(path string) provider.Mutability {
if isCrate(path) {
return provider.Immutable
}
return provider.Mutable
}
func (p *Provider) ContentType(path string) string {
if isCrate(path) {
return "application/gzip"
}
if path == "config.json" {
return "application/json"
}
return "text/plain"
}
// UpstreamURL maps crates/{crate}/{crate}-{version}.crate to the download host
// and everything else to the index. crates.io splits the two across hosts;
// any other upstream is expected to serve both.
func (p *Provider) UpstreamURL(remote models.Remote, path string) string {
path = strings.TrimLeft(path, "/")
base := strings.TrimRight(remote.BaseURL, "/")
if isCrate(path) {
if u, err := url.Parse(base); err == nil && u.Host == cratesIOIndex {
base = cratesIODownload
}
}
return base + "/" + path
}
func (p *Provider) RewriteResponse(_ []byte, _ models.Remote, _ string) ([]byte, error) {
return nil, nil
}
func (p *Provider) AuthHeaders(_ context.Context, remote models.Remote) (http.Header, error) {
return auth.BasicHeaders(remote), nil
}
// ServeRemote answers config.json itself: dl must name the requesting host,
// which a cached upstream body cannot.
func (p *Provider) ServeRemote(w http.ResponseWriter, r *http.Request, remote models.Remote, path, proxyBaseURL string, _ provider.RemoteMetadataStore) bool {
if strings.TrimLeft(path, "/") != "config.json" {
return false
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(Config(remote, proxyBaseURL))
return true
}
// Config builds the sparse registry config.json pointing downloads at proxyBaseURL.
func Config(remote models.Remote, proxyBaseURL string) map[string]string {
return map[string]string{
"dl": strings.TrimRight(proxyBaseURL, "/") + "/api/v1/remote/" + remote.Name + "/" + crateDir + "{crate}/{crate}-{version}.crate",
}
}
func isCrate(path string) bool {
path = strings.TrimLeft(path, "/")
return strings.HasPrefix(path, crateDir) && strings.HasSuffix(path, ".crate")
}
+75
View File
@@ -0,0 +1,75 @@
package cargo
import (
"encoding/json"
"net/http/httptest"
"testing"
"git.unkin.net/unkin/artifactapi/internal/provider"
"git.unkin.net/unkin/artifactapi/pkg/models"
)
func TestClassify(t *testing.T) {
p := &Provider{}
tests := []struct {
path string
want provider.Mutability
}{
{"crates/serde/serde-1.0.200.crate", provider.Immutable},
{"se/rd/serde", provider.Mutable},
{"1/a", provider.Mutable},
{"3/a/anyhow", provider.Mutable},
{"config.json", provider.Mutable},
{"cr/at/crates", provider.Mutable},
}
for _, tt := range tests {
if got := p.Classify(tt.path); got != tt.want {
t.Errorf("Classify(%q) = %v, want %v", tt.path, got, tt.want)
}
}
}
func TestUpstreamURL(t *testing.T) {
p := &Provider{}
cratesIO := models.Remote{BaseURL: "https://index.crates.io/"}
mirror := models.Remote{BaseURL: "http://mirror.example"}
tests := []struct {
remote models.Remote
path, want string
}{
{cratesIO, "se/rd/serde", "https://index.crates.io/se/rd/serde"},
{cratesIO, "crates/serde/serde-1.0.200.crate", "https://static.crates.io/crates/serde/serde-1.0.200.crate"},
{mirror, "crates/serde/serde-1.0.200.crate", "http://mirror.example/crates/serde/serde-1.0.200.crate"},
{mirror, "/2/ab", "http://mirror.example/2/ab"},
}
for _, tt := range tests {
if got := p.UpstreamURL(tt.remote, tt.path); got != tt.want {
t.Errorf("UpstreamURL(%q, %q) = %q, want %q", tt.remote.BaseURL, tt.path, got, tt.want)
}
}
}
func TestServeRemoteConfig(t *testing.T) {
p := &Provider{}
remote := models.Remote{Name: "crates-io", BaseURL: "https://index.crates.io"}
w := httptest.NewRecorder()
if !p.ServeRemote(w, httptest.NewRequest("GET", "/", nil), remote, "config.json", "https://aa.example/", nil) {
t.Fatal("config.json not served")
}
var cfg map[string]string
if err := json.Unmarshal(w.Body.Bytes(), &cfg); err != nil {
t.Fatal(err)
}
want := "https://aa.example/api/v1/remote/crates-io/crates/{crate}/{crate}-{version}.crate"
if cfg["dl"] != want {
t.Errorf("dl = %q, want %q", cfg["dl"], want)
}
if _, ok := cfg["api"]; ok {
t.Error("api must be omitted: publishing through the proxy is unsupported")
}
if p.ServeRemote(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil), remote, "se/rd/serde", "https://aa.example", nil) {
t.Error("index files must fall through to the proxy engine")
}
}