diff --git a/server/dl_routes_test.go b/server/dl_routes_test.go new file mode 100644 index 0000000..bdd3f78 --- /dev/null +++ b/server/dl_routes_test.go @@ -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) + } + } +} diff --git a/server/download.go b/server/download.go index 2645d74..7ae0f3a 100644 --- a/server/download.go +++ b/server/download.go @@ -44,34 +44,46 @@ 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", `; 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: @@ -79,6 +91,24 @@ func (s *Server) handleArtifactDownload(w http.ResponseWriter, r *http.Request) } } +// 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) } diff --git a/server/server.go b/server/server.go index 091666c..c734e63 100644 --- a/server/server.go +++ b/server/server.go @@ -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) {