Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions server/dl_routes_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package server

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/gorilla/mux"
)

// TestDLRouteResolution verifies /dl/ URLs split into (name, ref) the way
// handleArtifactDownload expects. The handler-side legacy fallback is
// integration-tested separately.
func TestDLRouteResolution(t *testing.T) {
tests := []struct {
path string
wantName string
wantRef string
wantMatched bool
}{
{"/dl/win11", "win11", "", true},
{"/dl/win11/latest", "win11", "latest", true},
{"/dl/win11/sha256:abc", "win11", "sha256:abc", true},
{"/dl/simular/win11", "simular", "win11", true},
{"/dl/simular/win11/22h2-20260510", "simular/win11", "22h2-20260510", true},
{"/dl/simular/win11/sha256:adafd938", "simular/win11", "sha256:adafd938", true},
{"/dl/", "", "", false},
}

for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
gotName, gotRef, matched := resolveDLRoute(t, tt.path)
if matched != tt.wantMatched {
t.Fatalf("matched = %v, want %v", matched, tt.wantMatched)
}
if !matched {
return
}
if gotName != tt.wantName {
t.Errorf("name = %q, want %q", gotName, tt.wantName)
}
if gotRef != tt.wantRef {
t.Errorf("ref = %q, want %q", gotRef, tt.wantRef)
}
})
}
}

func resolveDLRoute(t *testing.T, path string) (name, ref string, matched bool) {
t.Helper()
r := mux.NewRouter()
capture := func(w http.ResponseWriter, req *http.Request) {
name = mux.Vars(req)["name"]
ref = mux.Vars(req)["ref"]
matched = true
}
r.HandleFunc("/dl/{name:.+}/{ref}", capture).Methods(http.MethodGet)
r.HandleFunc("/dl/{name:.+}", capture).Methods(http.MethodGet)

req := httptest.NewRequest(http.MethodGet, path, nil)
r.ServeHTTP(httptest.NewRecorder(), req)
return name, ref, matched
}

// TestDLPublicPathPostMigration ensures /dl/{name}/{ref} bypasses auth.
func TestDLPublicPathPostMigration(t *testing.T) {
paths := []string{
"/dl/win11/latest",
"/dl/simular/win11/22h2-20260510",
"/dl/simular/win11/sha256:adafd938",
}
for _, p := range paths {
if !isPublicPath(p) {
t.Errorf("isPublicPath(%q) = false, want true", p)
}
}
}
42 changes: 36 additions & 6 deletions server/download.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,41 +44,71 @@ func (d *registryDownloader) GetBlob(ctx context.Context, _, digest string) (io.
return body, err
}

// handleArtifactDownload streams a cloud image or snapshot by name. Auth-exempt.
// handleArtifactDownload streams a cloud image or snapshot. Auth-exempt.
// On 404 retries (name+"/"+ref, "latest") to keep the pre-2026-05
// /dl/{name-with-slash} form working under the new /dl/{name}/{ref} route,
// flagging the response with a Deprecation header so callers migrate.
func (s *Server) handleArtifactDownload(w http.ResponseWriter, r *http.Request) {
name := urlVar(r, "name")
ref := urlVar(r, "ref")
if ref == "" {
ref = "latest"
}
logger := log.WithFunc("server.handleArtifactDownload")

raw, err := s.reg.ManifestJSON(r.Context(), name, "latest")
raw, useName, useRef, err := s.fetchManifestWithLegacyFallback(r, name, ref)
if err != nil {
if isNotFound(err) {
http.Error(w, "artifact not found", http.StatusNotFound)
return
}
logger.Errorf(r.Context(), err, "fetch manifest %s", name)
logger.Errorf(r.Context(), err, "fetch manifest %s:%s", name, ref)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}
if useName != name || useRef != ref {
w.Header().Set("Deprecation", "true")
w.Header().Set("Link", `</dl/`+useName+`/`+useRef+`>; rel="successor-version"`)
logger.Warnf(r.Context(), "legacy /dl/ form %s:%s resolved via fallback to %s:%s", name, ref, useName, useRef)
}

m, err := manifest.Parse(raw)
if err != nil {
logger.Errorf(r.Context(), err, "parse manifest %s", name)
logger.Errorf(r.Context(), err, "parse manifest %s:%s", useName, useRef)
http.Error(w, "internal server error", http.StatusInternalServerError)
return
}

switch manifest.ClassifyParsed(m) {
case manifest.KindCloudImage:
s.streamCloudImage(w, r, name, m)
s.streamCloudImage(w, r, useName, m)
case manifest.KindSnapshot:
s.streamSnapshot(w, r, name, raw, m)
s.streamSnapshot(w, r, useName, raw, m)
case manifest.KindContainerImage:
http.Error(w, "container image — pull via OCI client (oras / crane / docker)", http.StatusMethodNotAllowed)
default:
http.Error(w, "unknown artifact kind", http.StatusMethodNotAllowed)
}
}

// fetchManifestWithLegacyFallback returns the resolved (name, ref) so the
// caller can detect when the fallback path fired.
func (s *Server) fetchManifestWithLegacyFallback(r *http.Request, name, ref string) ([]byte, string, string, error) {
raw, err := s.loadManifestRaw(r, name, ref)
if err == nil {
return raw, name, ref, nil
}
if !isNotFound(err) {
return nil, name, ref, err
}
legacyName := name + "/" + ref
legacy, lerr := s.loadManifestRaw(r, legacyName, "latest")
if lerr != nil {
return nil, name, ref, err
}
return legacy, legacyName, "latest", nil
}

type blobStreamer interface {
StreamBlob(ctx context.Context, digest string) (io.ReadCloser, int64, error)
}
Expand Down
1 change: 1 addition & 0 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func (s *Server) setupRoutes(ctx context.Context) {
api.HandleFunc("/repositories/{name:.+}/tags", s.apiListTags).Methods(http.MethodGet)
api.HandleFunc("/repositories/{name:.+}", s.apiGetRepository).Methods(http.MethodGet)

s.router.HandleFunc("/dl/{name:.+}/{ref}", s.handleArtifactDownload).Methods(http.MethodGet)
s.router.HandleFunc("/dl/{name:.+}", s.handleArtifactDownload).Methods(http.MethodGet)

s.router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
Expand Down