88 "fmt"
99 "io"
1010 "net/http"
11+ "net/url"
1112 "path"
1213 "regexp"
1314 "strings"
@@ -372,7 +373,7 @@ func (h *ComposerHandler) proxyDistURL(packageName, version, upstreamURL, distTy
372373 return "" , false
373374 }
374375 return fmt .Sprintf ("%s/composer/files/%s/%s/%s/%s" ,
375- h .proxyURL , parts [0 ], parts [1 ], version , distFilename (upstreamURL , distType )), true
376+ h .proxyURL , parts [0 ], parts [1 ], url . PathEscape ( version ) , distFilename (upstreamURL , distType )), true
376377}
377378
378379// distFilename returns the file name this proxy serves an archive under: the
@@ -410,6 +411,16 @@ func upstreamDistURL(listedURL, distType, filename string, branch bool) (string,
410411 if filename == listed {
411412 return listedURL , true
412413 }
414+ if commit , ok := shaQueryCommit (listed ); ok {
415+ requested , ok := shaQueryCommit (filename )
416+ listedSha , requestedSha := "sha=" + commit , "sha=" + requested
417+ idx := strings .LastIndex (listed , listedSha )
418+ if ! branch || ! ok || idx < 0 || listed [:idx ]+ requestedSha + listed [idx + len (listedSha ):] != filename {
419+ return "" , false
420+ }
421+ idx = strings .LastIndex (listedURL , listedSha )
422+ return listedURL [:idx ] + requestedSha + listedURL [idx + len (listedSha ):], true
423+ }
413424 ext := path .Ext (listed )
414425 commit := strings .TrimSuffix (listed , ext )
415426 requested , ok := strings .CutSuffix (filename , ext )
@@ -420,6 +431,22 @@ func upstreamDistURL(listedURL, distType, filename string, branch bool) (string,
420431 return listedURL [:idx ] + requested + listedURL [idx + len (commit ):], true
421432}
422433
434+ // shaQueryCommit returns the commit an archive name passes in a sha query
435+ // parameter. GitLab names the archive of every commit archive.zip and picks
436+ // the commit that way.
437+ func shaQueryCommit (filename string ) (string , bool ) {
438+ _ , query , ok := strings .Cut (filename , "?" )
439+ if ! ok {
440+ return "" , false
441+ }
442+ values , err := url .ParseQuery (query )
443+ if err != nil {
444+ return "" , false
445+ }
446+ commit := values .Get ("sha" )
447+ return commit , composerCommitPattern .MatchString (commit )
448+ }
449+
423450// composerFields holds one version's fields as Composer sees them after
424451// expanding the minified format: raw values under their decoded names, in the
425452// order the names first appeared. A removed field keeps its slot with a nil
@@ -544,8 +571,7 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
544571
545572 // The archive is cached under the file name from the path, so fetch the
546573 // one that name stands for. Compare it as sent, the way rewriteDist handed
547- // it out. A query such as GitLab's ?sha= is compared but is not part of
548- // the cache key.
574+ // it out, query included.
549575 requested := path .Base (r .URL .EscapedPath ())
550576 if r .URL .RawQuery != "" {
551577 requested += "?" + r .URL .RawQuery
@@ -564,16 +590,60 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request)
564590 "package" , packageName , "version" , version ,
565591 "download_url" , downloadURL )
566592
593+ // Archives picked by ?sha= share one file name, so the commit goes into
594+ // the name they are cached under. Proxy versions before that cached every
595+ // commit under the shared name, which is dropped once a commit is fetched.
596+ var sharedFilename string
597+ if commit , ok := shaQueryCommit (requested ); ok {
598+ sharedFilename = h .routes .cacheFilename (packageName , filename )
599+ ext := path .Ext (filename )
600+ filename = strings .TrimSuffix (filename , ext ) + "-" + commit + ext
601+ }
567602 cacheFilename := h .routes .cacheFilename (packageName , filename )
603+ if commit := strings .TrimSuffix (filename , path .Ext (filename )); isDevVersion (version ) && composerCommitPattern .MatchString (commit ) {
604+ h .discardOtherCommit (packageName , version , cacheFilename , commit )
605+ }
568606 result , err := h .proxy .GetOrFetchArtifactFromURL (r .Context (), "composer" , packageName , version , cacheFilename , downloadURL )
569607 if err != nil {
570608 h .proxy .serveArtifactError (w , err , "failed to fetch package" )
571609 return
572610 }
611+ if sharedFilename != "" && ! result .Cached {
612+ if err := h .proxy .ClearCachedArtifact ("composer" , packageName , version , sharedFilename ); err != nil {
613+ h .proxy .Logger .Warn ("failed to discard composer archive cached under a shared name" ,
614+ "package" , packageName , "version" , version , "filename" , sharedFilename , "error" , err )
615+ }
616+ }
573617
574618 ServeArtifactRequest (w , r , result )
575619}
576620
621+ // discardOtherCommit clears a cached branch archive named for commit that was
622+ // fetched from another commit's URL. Proxy versions before the pinned-commit
623+ // fix cached the branch head under whatever commit a lock file asked for. The
624+ // record is only cleared while it still points at the object checked here.
625+ func (h * ComposerHandler ) discardOtherCommit (packageName , version , cacheFilename , commit string ) {
626+ _ , versionPURL , err := packagePURLStrings ("composer" , packageName , version )
627+ if err != nil {
628+ return
629+ }
630+ record , err := h .proxy .DB .GetArtifact (versionPURL , cacheFilename )
631+ if err != nil {
632+ h .proxy .Logger .Warn ("failed to read composer cache record" ,
633+ "purl" , versionPURL , "filename" , cacheFilename , "error" , err )
634+ return
635+ }
636+ if record == nil || ! record .StoragePath .Valid || strings .Contains (record .UpstreamURL , commit ) {
637+ return
638+ }
639+ h .proxy .Logger .Warn ("cached composer archive is from another commit, discarding" ,
640+ "purl" , versionPURL , "filename" , cacheFilename , "upstream_url" , record .UpstreamURL )
641+ if err := h .proxy .DB .DiscardArtifact (versionPURL , cacheFilename , record .StoragePath .String ); err != nil {
642+ h .proxy .Logger .Warn ("failed to discard composer cache record" ,
643+ "purl" , versionPURL , "filename" , cacheFilename , "error" , err )
644+ }
645+ }
646+
577647// isDevVersion reports whether a Composer version string refers to a
578648// development (unstable, branch) version rather than a tagged release.
579649// Composer formats these as either "dev-<branch>" (e.g. "dev-master") or
0 commit comments