Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/handler/composer.go
2026-10-18 17:59:14 +01:00

929 lines
32 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"
"github.com/git-pkgs/proxy/internal/jsonscan"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
)
const (
composerUpstream = "https://packagist.org"
composerRepo = "https://repo.packagist.org"
composerMinified = "composer/2.0"
composerUnset = "__unset"
composerDevReset = "~dev"
composerDevFile = "~dev" // suffix of the metadata file holding dev versions
vendorPackageParts = 2
)
var composerUnsetJSON = []byte(`"` + composerUnset + `"`)
// Archives are stored under pkg:composer/{vendor}/{package}, which purl
// lowercases, so a package rule matches whatever case it is written in. The
// spec also claims the "packagist" spelling git-pkgs writes into
// packages.ecosystem.
func init() {
retention.Default.Register(retention.Spec{Key: "composer", CanonicalPackage: composerRetentionKey})
}
// composerRetentionKey maps a package rule to the stored package PURL. A
// key without a vendor parses fine but can never match, since every stored
// package has one, so it is refused.
func composerRetentionKey(p *purl.PURL) (string, bool) {
if p == nil || p.Namespace == "" {
return "", false
}
return retention.DefaultCanonical("composer")(p)
}
// ComposerHandler handles Composer/Packagist registry protocol requests.
type ComposerHandler struct {
proxy *Proxy
upstreamURL string
repoURL string
proxyURL string
routes packageRoutes
}
// NewComposerHandler creates a new Composer protocol handler.
func NewComposerHandler(proxy *Proxy, proxyURL string) *ComposerHandler {
return &ComposerHandler{
proxy: proxy,
upstreamURL: composerUpstream,
repoURL: composerRepo,
proxyURL: strings.TrimSuffix(proxyURL, "/"),
}
}
// NewComposerHandlerWithUpstreams creates a Composer handler with custom API
// and repository upstreams.
func NewComposerHandlerWithUpstreams(proxy *Proxy, proxyURL, upstreamURL, repoURL string) *ComposerHandler {
h := NewComposerHandler(proxy, proxyURL)
h.upstreamURL = configuredUpstreamURL(upstreamURL, composerUpstream)
h.repoURL = configuredUpstreamURL(repoURL, composerRepo)
return h
}
// WithPackageRoutes sends packages matching a pattern to a dedicated Composer
// repository instead of the default one. See packageRoutes.
func (h *ComposerHandler) WithPackageRoutes(routes map[string]string) *ComposerHandler {
h.routes = newPackageRoutes(routes)
return h
}
// Routes returns the HTTP handler for Composer requests.
func (h *ComposerHandler) Routes() http.Handler {
mux := http.NewServeMux()
// Service index
mux.HandleFunc("GET /packages.json", h.handleServiceIndex)
// Package metadata (Composer v2 format) - use prefix since {package}.json isn't allowed
mux.HandleFunc("GET /p2/", h.handlePackageMetadata)
// Package downloads
mux.HandleFunc("GET /files/{vendor}/{package}/{version}/{filename}", h.handleDownload)
// Search and list (proxy without modification)
mux.HandleFunc("GET /search.json", h.proxyUpstream)
mux.HandleFunc("GET /packages/list.json", h.proxyUpstream)
return mux
}
// handleServiceIndex returns the Composer repository service index.
func (h *ComposerHandler) handleServiceIndex(w http.ResponseWriter, r *http.Request) {
// Return a minimal service index pointing to our proxy
index := map[string]any{
"packages": map[string]any{},
"metadata-url": h.proxyURL + "/composer/p2/%package%.json",
"notify-batch": h.upstreamURL + "/downloads/",
"search": h.proxyURL + "/composer/search.json?q=%query%&type=%type%",
"providers-lazy-url": h.proxyURL + "/composer/p2/%package%.json",
}
w.Header().Set(headerContentType, "application/json")
_ = json.NewEncoder(w).Encode(index)
}
// sourceFor returns the repository serving a package and the key its metadata
// is cached under. packageName may carry the ~dev suffix of the file holding
// the package's dev versions: routes match the package without it, so its dev
// versions come from the same repository, while the cache key keeps it so the
// two files stay apart.
func (h *ComposerHandler) sourceFor(packageName string) (repoURL, cacheKey string) {
if route, routed := h.routes.match(strings.TrimSuffix(packageName, composerDevFile)); routed {
return route.url, route.cacheKey(packageName)
}
return h.repoURL, packageName
}
// handlePackageMetadata proxies and rewrites package metadata.
func (h *ComposerHandler) handlePackageMetadata(w http.ResponseWriter, r *http.Request) {
// Parse path: /p2/{vendor}/{package}.json
path := strings.TrimPrefix(r.URL.Path, "/p2/")
path = strings.TrimSuffix(path, ".json")
parts := strings.SplitN(path, "/", vendorPackageParts)
if len(parts) != vendorPackageParts || parts[0] == "" || parts[1] == "" {
http.Error(w, "invalid package path", http.StatusBadRequest)
return
}
vendor := parts[0]
pkg := parts[1]
packageName := vendor + "/" + pkg
h.proxy.Logger.Info("composer metadata request", "package", packageName)
repoURL, cacheKey := h.sourceFor(packageName)
upstreamURL := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg)
basePackage := strings.TrimSuffix(packageName, composerDevFile)
local, err := h.composerLocalEntries(basePackage, basePackage != packageName)
if err != nil {
h.proxy.Logger.Error("failed to read pushed composer versions", "package", basePackage, "error", err)
http.Error(w, "failed to read pushed versions", http.StatusInternalServerError)
return
}
if h.proxy.hostedOnly("composer", basePackage) {
h.serveHostedOnlyMetadata(w, r, basePackage, cacheKey, local)
return
}
if len(local) > 0 {
h.serveMetadataWithLocal(w, r, basePackage, upstreamURL, cacheKey, local)
return
}
if stored, ok := h.proxy.storedRewrite("composer", cacheKey, h.proxyURL, cacheKey); ok {
serveRewrittenMetadata(w, r, stored)
return
}
upstream := h.proxy.fetchMetadataDocument(r.Context(), "composer", cacheKey, upstreamURL, contentTypeJSON)
if err := upstream.err; err != nil {
if errors.Is(err, ErrUpstreamNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
rewritten, err := h.proxy.cachedRewrite(r.Context(), "composer", h.proxyURL, cacheKey, upstream.body, upstream.digest, h.rewriteMetadataKeeping)
if err != nil {
if r.Context().Err() != nil {
return // the client left while waiting on a shared rewrite
}
h.proxy.Logger.Warn("failed to rewrite metadata, proxying original", "error", err)
w.Header().Set(headerContentType, "application/json")
_, _ = w.Write(upstream.body)
return
}
serveRewrittenMetadata(w, r, rewritten)
}
// rewriteMetadata rewrites dist URLs in Composer metadata to point at this proxy.
// If cooldown is enabled, versions published too recently are filtered out.
//
// The document is never decoded as a whole. Packagist serves large packages
// as minified lists of thousands of versions, and expanding them into generic
// maps took many times the document's size for every rewrite. Instead each
// version list is walked in place and the response is assembled from slices
// of the original bytes: only dist values are written fresh, and minified
// metadata stays minified.
func (h *ComposerHandler) rewriteMetadata(body []byte) ([]byte, error) {
out, _, err := h.rewriteMetadataKeeping(body)
return out, err
}
// rewriteMetadataKeeping is rewriteMetadata that also reports the versions
// it kept, each as its package name and version.
func (h *ComposerHandler) rewriteMetadataKeeping(body []byte) ([]byte, []string, error) {
return h.rewriteMetadataWithLocal(body, "", nil)
}
// rewriteMetadataWithLocal is rewriteMetadataKeeping that also lists the
// pushed versions in local under localPackage, in place of any upstream entry
// with the same version. Cooldown only filters upstream versions. A document
// that does not list localPackage at all is replaced by one holding only the
// pushed versions.
func (h *ComposerHandler) rewriteMetadataWithLocal(body []byte, localPackage string, local []composerLocalEntry) ([]byte, []string, error) {
if !json.Valid(body) {
return nil, nil, errors.New("composer metadata is not valid JSON")
}
format, _, err := jsonscan.LookupString(body, "minified")
if err != nil {
return nil, nil, err
}
minified := format == composerMinified
var out bytes.Buffer
var kept []string
listed := false
out.Grow(len(body) + len(body)/8)
err = jsonscan.RewriteMembers(&out, body, func(out *bytes.Buffer, m jsonscan.Member) (bool, error) {
packages := m.Value(body)
if !jsonscan.KeyIs(m.Key(body), "packages") || packages[0] != '{' {
return false, nil
}
return true, jsonscan.RewriteMembers(out, packages, func(out *bytes.Buffer, p jsonscan.Member) (bool, error) {
versions := p.Value(packages)
if versions[0] != '[' {
return false, nil
}
packageName, err := jsonscan.Key(p.Key(packages))
if err != nil {
return false, err
}
var pushed []composerLocalEntry
if packageName == localPackage {
pushed, listed = local, true
}
return true, h.writeVersions(out, packageName, versions, minified, &kept, pushed)
})
})
if err != nil {
return nil, nil, err
}
if len(local) > 0 && !listed {
document, err := localComposerDocument(localPackage, local)
return document, nil, err
}
return out.Bytes(), kept, nil
}
// writeVersions writes one package's version list without the versions in
// cooldown and with each dist pointing at this proxy, and appends each version
// it writes to kept as its package name and version.
//
// Minified lists stay minified. Each version written carries the fields that
// differ from what the client has expanded so far, which is the upstream
// entry itself unless filtered versions were dropped before it; then it also
// carries the changes those versions made. Every version gets its own dist,
// since the proxied URL names the version.
//
// The pushed versions in local follow the upstream ones and take the place of
// upstream entries with the same version.
func (h *ComposerHandler) writeVersions(out *bytes.Buffer, packageName string, versions []byte, minified bool, kept *[]string, local []composerLocalEntry) error {
packagePURL := canonicalPackagePURL("composer", packageName)
upstream, written := newComposerFields(), newComposerFields()
devReset, first := false, true
_, routed := h.routes.match(packageName)
out.WriteByte('[')
err := jsonscan.EachElement(versions, func(entry []byte) error {
if minified && jsonscan.StringIs(entry, composerDevReset) {
upstream.reset()
written.reset()
devReset = true
return nil
}
if entry[0] != '{' {
return nil
}
if !minified {
upstream.reset()
written.reset()
}
if err := upstream.apply(entry, minified); err != nil {
return err
}
version := upstream.string("version")
if pushedComposerVersion(local, version) || h.shouldFilterVersion(packagePURL, packageName, version, upstream.get("time")) {
return nil
}
if !first {
out.WriteByte(',')
}
first = false
if devReset {
out.WriteString(`"` + composerDevReset + `",`)
devReset = false
}
if routed {
disableDefaultNotification(upstream)
}
h.writeVersion(out, packageName, version, upstream, written)
*kept = append(*kept, packageName+"\x00"+version)
return nil
})
if err != nil {
return err
}
for _, e := range local {
pushed := newComposerFields()
if err := pushed.apply(e.entry, false); err != nil {
return err
}
if !minified {
written.reset()
}
if !first {
out.WriteByte(',')
}
first = false
if devReset {
out.WriteString(`"` + composerDevReset + `",`)
devReset = false
}
h.writeVersion(out, packageName, e.version, pushed, written)
*kept = append(*kept, packageName+"\x00"+e.version)
}
out.WriteByte(']')
return nil
}
// writeVersion writes the fields of upstream that differ from written, with
// dist pointing at this proxy, and "__unset" for those written has and
// upstream lacks. written is then what the client expands this version to.
func (h *ComposerHandler) writeVersion(out *bytes.Buffer, packageName, version string, upstream, written *composerFields) {
out.WriteByte('{')
first := true
writeMember := func(key, value []byte) {
if !first {
out.WriteByte(',')
}
first = false
out.Write(key)
out.WriteByte(':')
out.Write(value)
}
for _, f := range upstream.fields {
value := f.value
if value == nil {
continue
}
if f.name == "dist" {
value = h.rewriteDist(packageName, version, value)
}
if bytes.Equal(value, written.get(f.name)) {
continue
}
writeMember(f.key, value)
written.set(f.name, f.key, value)
}
for _, f := range written.fields {
if f.value != nil && upstream.get(f.name) == nil {
writeMember(f.key, composerUnsetJSON)
written.set(f.name, f.key, nil)
}
}
out.WriteByte('}')
}
// shouldFilterVersion returns true if the version should be excluded due to cooldown.
func (h *ComposerHandler) shouldFilterVersion(packagePURL, packageName, version string, published []byte) bool {
if h.proxy.Cooldown == nil || !h.proxy.Cooldown.Enabled() {
return false
}
var timeStr string
if err := json.Unmarshal(published, &timeStr); err != nil {
return false
}
publishedAt, err := time.Parse(time.RFC3339, timeStr)
if err != nil {
return false
}
if !h.proxy.Cooldown.IsAllowed("composer", packagePURL, publishedAt) {
h.proxy.Logger.Info("cooldown: filtering composer version",
"package", packageName, "version", version)
return true
}
return false
}
// disableDefaultNotification stops Composer from reporting installs of a routed
// package to the default repository's notify-batch URL, which would disclose
// the package name to it. Composer only falls back to notify-batch when a
// version has no notification-url of its own, and skips an empty one. Setting
// it on the expanded fields keeps it set for the versions that inherit them.
func disableDefaultNotification(fields *composerFields) {
if fields.get("notification-url") == nil {
fields.set("notification-url", []byte(`"notification-url"`), []byte(`""`))
}
}
// rewriteDist returns a version's dist object with its url pointing at this
// proxy. A dist without a string url is returned unchanged.
func (h *ComposerHandler) rewriteDist(packageName, version string, dist []byte) []byte {
urlMember, ok, err := jsonscan.FindMember(dist, "url")
if err != nil || !ok || dist[urlMember.ValStart] != '"' {
return dist
}
var upstreamURL string
if err := json.Unmarshal(urlMember.Value(dist), &upstreamURL); err != nil {
return dist
}
distType, _, _ := jsonscan.LookupString(dist, "type")
proxyURL, ok := h.proxyDistURL(packageName, version, upstreamURL, distType)
if !ok {
return dist
}
encoded, err := json.Marshal(proxyURL)
if err != nil {
return dist
}
rewritten := make([]byte, 0, len(dist)-len(urlMember.Value(dist))+len(encoded))
rewritten = append(rewritten, dist[:urlMember.ValStart]...)
rewritten = append(rewritten, encoded...)
return append(rewritten, dist[urlMember.ValEnd:]...)
}
// proxyDistURL returns the URL this proxy serves a version's archive at, or
// false when the upstream URL or package name gives nothing to build it from.
func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distType string) (string, bool) {
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], url.PathEscape(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:]
}
// GitHub zipball URLs end with a bare commit hash (no extension).
// Append .zip so the archives library can detect the format.
if path.Ext(filename) == "" && distType == "zip" {
filename += ".zip"
}
return filename
}
// 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
}
if commit, ok := shaQueryCommit(listed); ok {
requested, ok := shaQueryCommit(filename)
listedSha, requestedSha := "sha="+commit, "sha="+requested
idx := strings.LastIndex(listed, listedSha)
if !branch || !ok || idx < 0 || listed[:idx]+requestedSha+listed[idx+len(listedSha):] != filename {
return "", false
}
idx = strings.LastIndex(listedURL, listedSha)
return listedURL[:idx] + requestedSha + listedURL[idx+len(listedSha):], 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
}
idx := strings.LastIndex(listedURL, commit)
return listedURL[:idx] + requested + listedURL[idx+len(commit):], true
}
// shaQueryCommit returns the commit an archive name passes in a sha query
// parameter. GitLab names the archive of every commit archive.zip and picks
// the commit that way.
func shaQueryCommit(filename string) (string, bool) {
_, query, ok := strings.Cut(filename, "?")
if !ok {
return "", false
}
values, err := url.ParseQuery(query)
if err != nil {
return "", false
}
commit := values.Get("sha")
return commit, composerCommitPattern.MatchString(commit)
}
// composerFields holds one version's fields as Composer sees them after
// expanding the minified format: raw values under their decoded names, in the
// order the names first appeared. A removed field keeps its slot with a nil
// value, so the order stays stable when it comes back.
type composerFields struct {
fields []composerField
index map[string]int
}
type composerField struct {
name string
key, value []byte
}
func newComposerFields() *composerFields {
return &composerFields{index: map[string]int{}}
}
func (f *composerFields) reset() {
f.fields = f.fields[:0]
clear(f.index)
}
// get returns the raw value of the named field, or nil when it is not set.
func (f *composerFields) get(name string) []byte {
if i, ok := f.index[name]; ok {
return f.fields[i].value
}
return nil
}
// string returns the named field when it is a string, or "".
func (f *composerFields) string(name string) string {
raw := f.get(name)
if len(raw) == 0 || raw[0] != '"' {
return ""
}
var s string
_ = json.Unmarshal(raw, &s)
return s
}
// set stores value under name, written with the quoted key. A nil value
// removes the field.
func (f *composerFields) set(name string, key, value []byte) {
if i, ok := f.index[name]; ok {
f.fields[i].key, f.fields[i].value = key, value
return
}
f.index[name] = len(f.fields)
f.fields = append(f.fields, composerField{name: name, key: key, value: value})
}
// apply overlays the members of a version entry. In minified metadata the
// value "__unset" removes the field instead.
func (f *composerFields) apply(entry []byte, minified bool) error {
return jsonscan.EachMember(entry, func(m jsonscan.Member) error {
key, value := m.Key(entry), m.Value(entry)
if minified && jsonscan.StringIs(value, composerUnset) {
value = nil
}
if bytes.IndexByte(key, '\\') < 0 {
if i, ok := f.index[string(key[1:len(key)-1])]; ok {
f.fields[i].key, f.fields[i].value = key, value
return nil
}
}
name, err := jsonscan.Key(key)
if err != nil {
return err
}
f.set(name, key, value)
return nil
})
}
// handleDownload serves a package file, fetching and caching from upstream if needed.
func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) {
vendor := r.PathValue("vendor")
pkg := r.PathValue("package")
version := r.PathValue("version")
filename := r.PathValue("filename")
packageName := vendor + "/" + pkg
h.proxy.Logger.Info("composer download request",
"package", packageName, "version", version, "filename", filename)
local, err := h.proxy.GetLocalArtifact(r.Context(), "composer", packageName, version, filename)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to read pushed package")
return
}
if local != nil {
ServeArtifactRequest(w, r, local)
return
}
if h.proxy.hostedOnly("composer", packageName) {
http.Error(w, "version not found", http.StatusNotFound)
return
}
// 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)
h.proxy.Logger.Debug("resolving download URL",
"package", packageName, "version", version,
"metadata_urls", metaURLs)
var dist composerDist
for _, metaURL := range metaURLs {
found, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("failed to fetch metadata", "error", err, "url", metaURL)
http.Error(w, "failed to fetch metadata", http.StatusBadGateway)
return
}
if found.url != "" {
dist = found
break
}
}
if dist.url == "" {
h.proxy.Logger.Debug("version not found in any metadata source",
"package", packageName, "version", version,
"tried_urls", metaURLs)
http.Error(w, "version not found", http.StatusNotFound)
return
}
// Lock files name archive URLs directly, so a version filtered out of the
// metadata still has to be withheld here.
if h.shouldFilterVersion(canonicalPackagePURL("composer", packageName), packageName, version, dist.published) {
http.Error(w, "version not found", http.StatusNotFound)
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, query included.
requested := path.Base(r.URL.EscapedPath())
if r.URL.RawQuery != "" {
requested += "?" + r.URL.RawQuery
}
listedURL := dist.url
downloadURL, ok := upstreamDistURL(listedURL, dist.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)
// Archives picked by ?sha= share one file name, so the commit goes into
// the name they are cached under. Proxy versions before that cached every
// commit under the shared name, which is dropped once a commit is fetched.
var sharedFilename string
if commit, ok := shaQueryCommit(requested); ok {
sharedFilename = h.routes.cacheFilename(packageName, filename)
ext := path.Ext(filename)
filename = strings.TrimSuffix(filename, ext) + "-" + commit + ext
}
cacheFilename := h.routes.cacheFilename(packageName, filename)
if commit := strings.TrimSuffix(filename, path.Ext(filename)); isDevVersion(version) && composerCommitPattern.MatchString(commit) {
h.discardOtherCommit(packageName, version, cacheFilename, commit)
}
result, err := h.proxy.GetOrFetchArtifactFromURLWithIntegrity(r.Context(), "composer", packageName, version, cacheFilename, downloadURL,
composerIntegrity(dist.shasum, version, downloadURL == listedURL))
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
if sharedFilename != "" && !result.Cached {
if err := h.proxy.ClearCachedArtifact("composer", packageName, version, sharedFilename); err != nil {
h.proxy.Logger.Warn("failed to discard composer archive cached under a shared name",
"package", packageName, "version", version, "filename", sharedFilename, "error", err)
}
}
ServeArtifactRequest(w, r, result)
}
// discardOtherCommit clears a cached branch archive named for commit that was
// fetched from another commit's URL. Proxy versions before the pinned-commit
// fix cached the branch head under whatever commit a lock file asked for. The
// record is only cleared while it still points at the object checked here.
func (h *ComposerHandler) discardOtherCommit(packageName, version, cacheFilename, commit string) {
_, versionPURL, err := packagePURLStrings("composer", packageName, version)
if err != nil {
return
}
record, err := h.proxy.DB.GetArtifact(versionPURL, cacheFilename)
if err != nil {
h.proxy.Logger.Warn("failed to read composer cache record",
"purl", versionPURL, "filename", cacheFilename, "error", err)
return
}
if record == nil || !record.StoragePath.Valid || strings.Contains(record.UpstreamURL, commit) {
return
}
h.proxy.Logger.Warn("cached composer archive is from another commit, discarding",
"purl", versionPURL, "filename", cacheFilename, "upstream_url", record.UpstreamURL)
if err := h.proxy.DB.DiscardArtifact(versionPURL, cacheFilename, record.StoragePath.String); err != nil {
h.proxy.Logger.Warn("failed to discard composer cache record",
"purl", versionPURL, "filename", cacheFilename, "error", err)
}
}
// 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-<branch>" (e.g. "dev-master") or
// "<alias>-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 {
repoURL, _ := h.sourceFor(vendor + "/" + pkg)
stable := fmt.Sprintf("%s/p2/%s/%s.json", repoURL, vendor, pkg)
dev := fmt.Sprintf("%s/p2/%s/%s~dev.json", repoURL, vendor, pkg)
if isDevVersion(version) {
return []string{dev, stable}
}
return []string{stable, dev}
}
// composerDist is what a version's metadata says about its archive: the dist
// URL, type and shasum, and the raw publish time the cooldown is checked
// against.
type composerDist struct {
url, distType, shasum string
published []byte
}
// findDownloadURLFromMetadata fetches a metadata document and returns the dist
// of the given version, with 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) (composerDist, 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 composerDist{}, err
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return composerDist{}, err
}
defer func() { _ = resp.Body.Close() }()
h.proxy.Logger.Debug("upstream metadata response",
"url", metaURL, "status", resp.StatusCode)
if resp.StatusCode != http.StatusOK {
return composerDist{}, nil
}
body, err := h.proxy.ReadMetadata(resp.Body)
if err != nil {
return composerDist{}, err
}
dist, err := composerDistURL(body, packageName, version)
if err != nil {
return composerDist{}, err
}
h.proxy.Logger.Debug("download URL lookup result",
"url", metaURL, "package", packageName, "version", version,
"download_url", dist.url)
return dist, nil
}
// composerDistURL returns the upstream dist of version from Composer
// metadata, with an empty URL 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) (composerDist, error) {
format, _, err := jsonscan.LookupString(body, "minified")
if err != nil {
return composerDist{}, err
}
versions, err := jsonscan.Lookup(body, "packages", packageName)
if err != nil || len(versions) == 0 || versions[0] != '[' {
return composerDist{}, err
}
minified := format == composerMinified
fields := newComposerFields()
var dist composerDist
err = jsonscan.EachElement(versions, func(entry []byte) error {
if minified && jsonscan.StringIs(entry, composerDevReset) {
fields.reset()
return nil
}
if entry[0] != '{' {
return nil
}
if !minified {
fields.reset()
}
if err := fields.apply(entry, minified); err != nil {
return err
}
if !jsonscan.StringIs(fields.get("version"), version) {
return nil
}
distRaw := fields.get("dist")
dist.url, _, _ = jsonscan.LookupString(distRaw, "url")
if dist.url != "" {
dist.distType, _, _ = jsonscan.LookupString(distRaw, "type")
dist.shasum, _, _ = jsonscan.LookupString(distRaw, "shasum")
dist.published = fields.get("time")
return jsonscan.ErrStop
}
return nil
})
if errors.Is(err, jsonscan.ErrStop) {
err = nil
}
return dist, err
}
// composerIntegrity returns the integrity to verify an archive of version
// against: the SHA-1 its dist lists as shasum. A dev branch is left
// unverified: its listed shasum describes only the current head, while every
// commit of the branch is cached under the same version, which holds a single
// recorded integrity, so a cache hit for an older pinned commit would be
// checked against the head's shasum. Composer checks dist.shasum itself
// anyway. A shasum also only describes the listed archive, and Packagist
// lists none for GitHub archives; an unusable one is ignored. Those archives
// are served unverified as before.
func composerIntegrity(shasum, version string, listed bool) string {
if !listed || isDevVersion(version) {
return ""
}
return sha1Checksum(shasum)
}
// proxyUpstream forwards a request to packagist.org without caching.
func (h *ComposerHandler) proxyUpstream(w http.ResponseWriter, r *http.Request) {
upstreamURL := h.upstreamURL + r.URL.Path
if r.URL.RawQuery != "" {
upstreamURL += "?" + r.URL.RawQuery
}
h.proxy.Logger.Debug("proxying to upstream", "url", upstreamURL)
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstreamURL, nil)
if err != nil {
http.Error(w, "failed to create request", http.StatusInternalServerError)
return
}
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
if writeUpstreamHeld(w, err) {
return
}
h.proxy.Logger.Error("upstream request failed", "error", err)
http.Error(w, "upstream request failed", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()
h.proxy.relayResponse(w, r, resp, nil)
}