diff --git a/internal/handler/composer.go b/internal/handler/composer.go index 065ddf9..e4dec41 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -1,6 +1,7 @@ package handler import ( + "context" "encoding/json" "errors" "fmt" @@ -307,50 +308,91 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) h.proxy.Logger.Info("composer download request", "package", packageName, "version", version, "filename", filename) - // We need to fetch the metadata to get the actual download URL - // since Packagist URLs include a hash - metaURL := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) + // We need to fetch the metadata to get the actual download URL since + // Packagist URLs include a hash. Packagist serves dev versions (e.g. + // "3.x-dev", "dev-master") from a separate "~dev" metadata file, while + // tagged releases live in the regular file. Try the file most likely to + // contain this version first, then fall back to the other so that both + // stable and dev versions resolve correctly. + metaURLs := h.metadataURLsForVersion(vendor, pkg, version) + + var downloadURL string + for _, metaURL := range metaURLs { + url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version) + if err != nil { + h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL) + http.Error(w, "failed to fetch metadata", http.StatusBadGateway) + return + } + if url != "" { + downloadURL = url + break + } + } - req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, metaURL, nil) + if downloadURL == "" { + http.Error(w, "version not found", http.StatusNotFound) + return + } + + result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) if err != nil { - http.Error(w, "failed to create request", http.StatusInternalServerError) + h.proxy.Logger.Error("failed to get artifact", "error", err) + http.Error(w, "failed to fetch package", http.StatusBadGateway) return } + ServeArtifact(w, result) +} + +// isDevVersion reports whether a Composer version string refers to a +// development (unstable, branch) version rather than a tagged release. +// Composer formats these as either "dev-" (e.g. "dev-master") or +// "-dev" (e.g. "3.x-dev"). +func isDevVersion(version string) bool { + return strings.HasPrefix(version, "dev-") || strings.HasSuffix(version, "-dev") +} + +// metadataURLsForVersion returns the upstream metadata URLs to consult for a +// given version, in priority order. Dev versions are served from the "~dev" +// file, tagged releases from the regular file; the other file is included as a +// fallback so an unexpected classification still resolves. +func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []string { + stable := fmt.Sprintf("%s/p2/%s/%s.json", h.repoURL, vendor, pkg) + dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", h.repoURL, vendor, pkg) + + if isDevVersion(version) { + return []string{dev, stable} + } + return []string{stable, dev} +} + +// findDownloadURLFromMetadata fetches a metadata document and returns the dist +// URL for the given version, or an empty string if the version is not present. +// An error is returned only on transport failure; a missing document (non-200) +// or a missing version both yield an empty string so the caller can fall back. +func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) + if err != nil { + return "", err + } + resp, err := h.proxy.HTTPClient.Do(req) if err != nil { - h.proxy.Logger.Error("failed to fetch metadata", "error", err) - http.Error(w, "failed to fetch metadata", http.StatusBadGateway) - return + return "", err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - http.Error(w, "package not found", http.StatusNotFound) - return + return "", nil } var metadata map[string]any if err := json.NewDecoder(resp.Body).Decode(&metadata); err != nil { - http.Error(w, "failed to parse metadata", http.StatusInternalServerError) - return - } - - // Find the download URL for this version - downloadURL := h.findDownloadURL(metadata, packageName, version) - if downloadURL == "" { - http.Error(w, "version not found", http.StatusNotFound) - return - } - - result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) - if err != nil { - h.proxy.Logger.Error("failed to get artifact", "error", err) - http.Error(w, "failed to fetch package", http.StatusBadGateway) - return + return "", err } - ServeArtifact(w, result) + return h.findDownloadURL(metadata, packageName, version), nil } // findDownloadURL finds the dist URL for a specific version in metadata. diff --git a/internal/handler/composer_test.go b/internal/handler/composer_test.go index baf13b6..9898664 100644 --- a/internal/handler/composer_test.go +++ b/internal/handler/composer_test.go @@ -1,8 +1,11 @@ package handler import ( + "context" "encoding/json" "log/slog" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -465,6 +468,100 @@ func TestComposerExpandMinifiedSharedDistReferences(t *testing.T) { } } +// TestComposerDownloadDevVersionUsesDevMetadata is a regression test for the +// bug that made it impossible to install a *-dev dependency from dist. +// +// Packagist serves development versions (e.g. "3.x-dev", "dev-master") from a +// separate "{package}~dev.json" metadata file; the regular "{package}.json" +// file contains only tagged releases. The download handler used to fetch only +// the regular file, so it could never find the dist URL for a dev version and +// returned 404 — causing Composer to silently fall back to a git clone. +// +// This test serves both files from a mock upstream and asserts that: +// - the OLD behavior (regular file only) cannot resolve the dev version, and +// - the FIXED behavior (consulting the ~dev file) does. +func TestComposerDownloadDevVersionUsesDevMetadata(t *testing.T) { + const ( + pkg = "phpmd/phpmd" + vendor = "phpmd" + name = "phpmd" + version = "3.x-dev" + distURL = "https://api.github.com/repos/phpmd/phpmd/zipball/2a9217f60aaf27bf6ddad9188f254d020ab70745" + ) + + // Regular metadata: tagged releases only — no dev versions. + stableBody := `{ + "packages": { + "phpmd/phpmd": [ + {"version": "2.15.0", "dist": {"url": "https://example.com/2.15.0.zip", "type": "zip"}} + ] + } + }` + + // ~dev metadata: where the 3.x-dev version actually lives. + devBody := `{ + "packages": { + "phpmd/phpmd": [ + {"version": "3.x-dev", "dist": {"url": "` + distURL + `", "type": "zip"}} + ] + } + }` + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/p2/phpmd/phpmd.json": + _, _ = w.Write([]byte(stableBody)) + case "/p2/phpmd/phpmd~dev.json": + _, _ = w.Write([]byte(devBody)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + h := &ComposerHandler{ + proxy: testProxy(), + repoURL: srv.URL, + proxyURL: "http://localhost:8080", + } + + ctx := context.Background() + + // OLD behavior: fetching only the regular file fails to resolve the dev + // version, which is what produced the 404 before the fix. + stableURL := srv.URL + "/p2/phpmd/phpmd.json" + got, err := h.findDownloadURLFromMetadata(ctx, stableURL, pkg, version) + if err != nil { + t.Fatalf("unexpected error fetching regular metadata: %v", err) + } + if got != "" { + t.Fatalf("regular metadata unexpectedly contained dev version %q (got %q); "+ + "the test no longer reproduces the original bug", version, got) + } + + // FIXED behavior: the handler consults the ~dev file (it is first in the + // candidate list for dev versions) and resolves the dist URL. + urls := h.metadataURLsForVersion(vendor, name, version) + if len(urls) == 0 || !strings.HasSuffix(urls[0], "/p2/phpmd/phpmd~dev.json") { + t.Fatalf("dev version should consult the ~dev metadata file first, got %v", urls) + } + + var resolved string + for _, u := range urls { + resolved, err = h.findDownloadURLFromMetadata(ctx, u, pkg, version) + if err != nil { + t.Fatalf("unexpected error fetching metadata %q: %v", u, err) + } + if resolved != "" { + break + } + } + + if resolved != distURL { + t.Errorf("dev version dist URL = %q, want %q", resolved, distURL) + } +} + func TestComposerRewriteMetadataCooldown(t *testing.T) { now := time.Now() old := now.Add(-10 * 24 * time.Hour).Format(time.RFC3339)