11package server
22
33import (
4+ "bytes"
45 "encoding/json"
56 "fmt"
67 "io"
@@ -17,17 +18,75 @@ import (
1718
1819const contentTypePlainText = "text/plain; charset=utf-8"
1920
20- // getStripPrefix returns the path prefix to strip for a given ecosystem.
21- // npm packages wrap content in a "package/" directory.
22- func getStripPrefix (ecosystem string ) string {
23- switch ecosystem {
24- case "npm" :
25- return "package/"
26- default :
21+ // archiveFilename returns a filename suitable for archive format detection.
22+ // Some ecosystems (e.g. composer) store artifacts with bare hash filenames
23+ // that have no extension. This adds .zip when the original has no extension
24+ // and the content is likely a zip archive.
25+ func archiveFilename (filename string ) string {
26+ if path .Ext (filename ) == "" {
27+ return filename + ".zip"
28+ }
29+ return filename
30+ }
31+
32+ // detectSingleRootDir returns the single top-level directory name if all files
33+ // in the archive live under one common directory (e.g. GitHub zipballs use
34+ // "repo-hash/"). Returns "" if there's no single root or the archive is flat.
35+ func detectSingleRootDir (reader archives.Reader ) string {
36+ files , err := reader .List ()
37+ if err != nil || len (files ) == 0 {
38+ return ""
39+ }
40+
41+ var root string
42+ for _ , f := range files {
43+ parts := strings .SplitN (f .Path , "/" , 2 ) //nolint:mnd // split into dir + rest
44+ if len (parts ) == 0 {
45+ continue
46+ }
47+ dir := parts [0 ]
48+ if root == "" {
49+ root = dir
50+ } else if dir != root {
51+ return ""
52+ }
53+ }
54+
55+ if root == "" {
2756 return ""
2857 }
58+ return root + "/"
59+ }
60+
61+ // openArchive opens a cached artifact as an archive reader, auto-detecting
62+ // and stripping a single top-level directory prefix (like GitHub zipballs).
63+ // For npm, the hardcoded "package/" prefix takes precedence.
64+ func openArchive (filename string , content io.Reader , ecosystem string ) (archives.Reader , error ) { //nolint:ireturn // wraps multiple archive implementations
65+ fname := archiveFilename (filename )
66+
67+ // npm always uses package/ prefix
68+ if ecosystem == "npm" {
69+ return archives .OpenWithPrefix (fname , content , "package/" )
70+ }
71+
72+ // Read content into memory so we can scan then wrap with prefix
73+ data , err := io .ReadAll (content )
74+ if err != nil {
75+ return nil , fmt .Errorf ("reading artifact: %w" , err )
76+ }
77+
78+ // Open once to detect root prefix
79+ probe , err := archives .Open (fname , bytes .NewReader (data ))
80+ if err != nil {
81+ return nil , err
82+ }
83+ prefix := detectSingleRootDir (probe )
84+ _ = probe .Close ()
85+
86+ return archives .OpenWithPrefix (fname , bytes .NewReader (data ), prefix )
2987}
3088
89+
3190// BrowseListResponse contains the file listing for a directory in an archives.
3291type BrowseListResponse struct {
3392 Path string `json:"path"`
@@ -174,9 +233,8 @@ func (s *Server) browseList(w http.ResponseWriter, r *http.Request, ecosystem, n
174233 }
175234 defer func () { _ = artifactReader .Close () }()
176235
177- // Open archive with appropriate prefix stripping
178- stripPrefix := getStripPrefix (ecosystem )
179- archiveReader , err := archives .OpenWithPrefix (cachedArtifact .Filename , artifactReader , stripPrefix )
236+ // Open archive with auto-detected prefix stripping
237+ archiveReader , err := openArchive (cachedArtifact .Filename , artifactReader , ecosystem )
180238 if err != nil {
181239 s .logger .Error ("failed to open archive" , "error" , err , "filename" , cachedArtifact .Filename )
182240 http .Error (w , "failed to open archive" , http .StatusInternalServerError )
@@ -269,9 +327,8 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
269327 }
270328 defer func () { _ = artifactReader .Close () }()
271329
272- // Open archive with appropriate prefix stripping
273- stripPrefix := getStripPrefix (ecosystem )
274- archiveReader , err := archives .OpenWithPrefix (cachedArtifact .Filename , artifactReader , stripPrefix )
330+ // Open archive with auto-detected prefix stripping
331+ archiveReader , err := openArchive (cachedArtifact .Filename , artifactReader , ecosystem )
275332 if err != nil {
276333 s .logger .Error ("failed to open archive" , "error" , err , "filename" , cachedArtifact .Filename )
277334 http .Error (w , "failed to open archive" , http .StatusInternalServerError )
@@ -484,17 +541,15 @@ func (s *Server) compareDiff(w http.ResponseWriter, r *http.Request, ecosystem,
484541 }
485542 defer func () { _ = toReader .Close () }()
486543
487- stripPrefix := getStripPrefix (ecosystem )
488-
489- fromArchive , err := archives .OpenWithPrefix (fromArtifact .Filename , fromReader , stripPrefix )
544+ fromArchive , err := openArchive (fromArtifact .Filename , fromReader , ecosystem )
490545 if err != nil {
491546 s .logger .Error ("failed to open from archive" , "error" , err )
492547 http .Error (w , "failed to open from archive" , http .StatusInternalServerError )
493548 return
494549 }
495550 defer func () { _ = fromArchive .Close () }()
496551
497- toArchive , err := archives . OpenWithPrefix (toArtifact .Filename , toReader , stripPrefix )
552+ toArchive , err := openArchive (toArtifact .Filename , toReader , ecosystem )
498553 if err != nil {
499554 s .logger .Error ("failed to open to archive" , "error" , err )
500555 http .Error (w , "failed to open to archive" , http .StatusInternalServerError )
0 commit comments