Skip to content
Open
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
113 changes: 84 additions & 29 deletions internal/handler/composer.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"net/http"
"path"
"regexp"
"strings"
"time"
)
Expand Down Expand Up @@ -329,7 +330,17 @@ func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distTy
if upstreamURL == "" {
return "", false
}
parts := strings.SplitN(packageName, "/", vendorPackageParts)
if len(parts) != vendorPackageParts {
return "", false
}
return fmt.Sprintf("%s/composer/files/%s/%s/%s/%s",
h.proxyURL, parts[0], parts[1], version, distFilename(upstreamURL, distType)), true
}

// distFilename returns the file name this proxy serves an archive under: the
// last segment of its upstream URL.
func distFilename(upstreamURL, distType string) string {
filename := "package.zip"
if idx := strings.LastIndex(upstreamURL, "/"); idx >= 0 {
filename = upstreamURL[idx+1:]
Expand All @@ -340,13 +351,36 @@ func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distTy
if path.Ext(filename) == "" && distType == "zip" {
filename += ".zip"
}
return filename
}

parts := strings.SplitN(packageName, "/", vendorPackageParts)
if len(parts) != vendorPackageParts {
// composerCommitPattern matches a full git commit hash, SHA-1 or SHA-256.
var composerCommitPattern = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`)

// upstreamDistURL returns the upstream URL of the archive served as filename,
// given the dist URL the version's metadata lists now. A branch version only
// lists its current head while lock files pin older commits, so for a branch
// a dist URL ending in a commit hash is rebuilt for the requested commit. A
// tagged release has to ask for the listed name. Any other name gives false:
// fetching the listed archive would cache it under a name it does not have.
func upstreamDistURL(listedURL, distType, filename string, branch bool) (string, bool) {
listed := distFilename(listedURL, distType)
// Proxy versions before the .zip suffix handed out bare commit names,
// and lock files written back then still ask for them.
if bare := distFilename(listedURL, ""); listed != bare && path.Ext(filename) == "" {
filename += listed[len(bare):]
}
if filename == listed {
return listedURL, true
}
ext := path.Ext(listed)
commit := strings.TrimSuffix(listed, ext)
requested, ok := strings.CutSuffix(filename, ext)
if !branch || !ok || !composerCommitPattern.MatchString(commit) || !composerCommitPattern.MatchString(requested) {
return "", false
}
return fmt.Sprintf("%s/composer/files/%s/%s/%s/%s",
h.proxyURL, parts[0], parts[1], version, filename), true
idx := strings.LastIndex(listedURL, commit)
return listedURL[:idx] + requested + listedURL[idx+len(commit):], true
}

// composerFields holds one version's fields as Composer sees them after
Expand Down Expand Up @@ -449,16 +483,16 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
"package", packageName, "version", version,
"metadata_urls", metaURLs)

var downloadURL string
var downloadURL, distType string
for _, metaURL := range metaURLs {
url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version)
url, typ, 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
downloadURL, distType = url, typ
break
}
}
Expand All @@ -471,6 +505,24 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
return
}

// The archive is cached under the file name from the path, so fetch the
// one that name stands for. Compare it as sent, the way rewriteDist handed
// it out. A query such as GitLab's ?sha= is compared but is not part of
// the cache key.
requested := path.Base(r.URL.EscapedPath())
if r.URL.RawQuery != "" {
requested += "?" + r.URL.RawQuery
}
listedURL := downloadURL
downloadURL, ok := upstreamDistURL(listedURL, distType, requested, isDevVersion(version))
if !ok {
h.proxy.Logger.Info("composer file not in upstream metadata",
"package", packageName, "version", version,
"filename", requested, "listed_url", listedURL)
http.Error(w, "file not found", http.StatusNotFound)
return
}

h.proxy.Logger.Debug("resolved download URL",
"package", packageName, "version", version,
"download_url", downloadURL)
Expand Down Expand Up @@ -507,64 +559,65 @@ func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) []
}

// 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) {
// URL and type for the given version, or an empty URL 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 URL so the caller can
// fall back.
func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, string, error) {
h.proxy.Logger.Debug("fetching upstream metadata for download lookup",
"url", metaURL, "package", packageName, "version", version)

req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil)
if err != nil {
return "", err
return "", "", err
}

resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return "", err
return "", "", err
}
defer func() { _ = resp.Body.Close() }()

h.proxy.Logger.Debug("upstream metadata response",
"url", metaURL, "status", resp.StatusCode)

if resp.StatusCode != http.StatusOK {
return "", nil
return "", "", nil
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
return "", "", err
}

url, err := composerDistURL(body, packageName, version)
url, distType, err := composerDistURL(body, packageName, version)
if err != nil {
return "", err
return "", "", err
}
h.proxy.Logger.Debug("download URL lookup result",
"url", metaURL, "package", packageName, "version", version,
"download_url", url)
return url, nil
return url, distType, nil
}

// composerDistURL returns the upstream dist URL of version from Composer
// metadata, or "" when the package has no such version or it has no dist URL.
// It walks the version list in place, expanding only as much of the minified
// format as it needs, since it runs on each archive the proxy has not cached
// yet.
func composerDistURL(body []byte, packageName, version string) (string, error) {
// composerDistURL returns the upstream dist URL and type of version from
// Composer metadata, or "" when the package has no such version or it has no
// dist URL. It walks the version list in place, expanding only as much of the
// minified format as it needs, since it runs on each archive the proxy has not
// cached yet.
func composerDistURL(body []byte, packageName, version string) (string, string, error) {
format, _, err := lookupJSONString(body, "minified")
if err != nil {
return "", err
return "", "", err
}
versions, err := lookupJSON(body, "packages", packageName)
if err != nil || len(versions) == 0 || versions[0] != '[' {
return "", err
return "", "", err
}
minified := format == composerMinified

fields := newComposerFields()
var url string
var url, distType string
err = forEachJSONElement(versions, func(entry []byte) error {
if minified && jsonStringIs(entry, composerDevReset) {
fields.reset()
Expand All @@ -582,16 +635,18 @@ func composerDistURL(body []byte, packageName, version string) (string, error) {
if !jsonStringIs(fields.get("version"), version) {
return nil
}
url, _, _ = lookupJSONString(fields.get("dist"), "url")
dist := fields.get("dist")
url, _, _ = lookupJSONString(dist, "url")
if url != "" {
distType, _, _ = lookupJSONString(dist, "type")
return errStopScan
}
return nil
})
if errors.Is(err, errStopScan) {
err = nil
}
return url, err
return url, distType, err
}

// proxyUpstream forwards a request to packagist.org without caching.
Expand Down
2 changes: 1 addition & 1 deletion internal/handler/composer_rewrite_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func BenchmarkComposerFindDownloadURL(b *testing.B) {
b.SetBytes(int64(len(body)))
b.ReportAllocs()
for b.Loop() {
url, err := h.findDownloadURLFromMetadata(context.Background(), srv.URL, "big/sdk", "3.2500.0")
url, _, err := h.findDownloadURLFromMetadata(context.Background(), srv.URL, "big/sdk", "3.2500.0")
if err != nil || url == "" {
b.Fatalf("download URL not found: %q, %v", url, err)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/composer_rewrite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ func TestComposerDistURL(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := composerDistURL([]byte(tt.body), "v/p", tt.version)
got, _, err := composerDistURL([]byte(tt.body), "v/p", tt.version)
if err != nil {
t.Fatal(err)
}
Expand All @@ -252,7 +252,7 @@ func TestComposerDistURL(t *testing.T) {
}

func TestComposerDistURLMalformed(t *testing.T) {
if _, err := composerDistURL([]byte(`{"packages":{"v/p":[{"version":`), "v/p", "1.0.0"); err == nil {
if _, _, err := composerDistURL([]byte(`{"packages":{"v/p":[{"version":`), "v/p", "1.0.0"); err == nil {
t.Error("expected an error for truncated JSON")
}
}
Expand Down
Loading
Loading