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
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ go 1.25.6
require (
github.com/BurntSushi/toml v1.6.0
github.com/CycloneDX/cyclonedx-go v0.11.0
github.com/git-pkgs/archives v0.3.1
github.com/git-pkgs/archives v0.4.0
github.com/git-pkgs/cooldown v0.1.1
github.com/git-pkgs/enrichment v0.6.4
github.com/git-pkgs/magic v0.1.0
github.com/git-pkgs/purl v0.1.15
github.com/git-pkgs/registries v0.6.4
github.com/git-pkgs/spdx v0.1.4
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,14 @@ github.com/fzipp/gocyclo v0.6.0 h1:lsblElZG7d3ALtGMx9fmxeTKZaLLpU8mET09yN4BBLo=
github.com/fzipp/gocyclo v0.6.0/go.mod h1:rXPyn8fnlpa0R2csP/31uerbiVBugk5whMdlyaLkLoA=
github.com/ghostiam/protogetter v0.3.20 h1:oW7OPFit2FxZOpmMRPP9FffU4uUpfeE/rEdE1f+MzD0=
github.com/ghostiam/protogetter v0.3.20/go.mod h1:FjIu5Yfs6FT391m+Fjp3fbAYJ6rkL/J6ySpZBfnODuI=
github.com/git-pkgs/archives v0.3.1 h1:GKUuw++0YXAAElxweVHiR4AaSShKKYoVQmyxlF5blG4=
github.com/git-pkgs/archives v0.3.1/go.mod h1:408oQv3FxLCtePa33zp3sg3njXnwH74vnHZFxkRqoPo=
github.com/git-pkgs/archives v0.4.0 h1:KNmmIsLiSH27lUdT27EfUkQXFaLgXV5KezE81iyOIgo=
github.com/git-pkgs/archives v0.4.0/go.mod h1:tfio0OIuPKEBKHs/UCL5XBUvYmKpnvtnba2iDlfSd6g=
github.com/git-pkgs/cooldown v0.1.1 h1:9OqqzCB8gANz/y44SmqGD0Jp8Qtu81D1sCbKl6Ehg7w=
github.com/git-pkgs/cooldown v0.1.1/go.mod h1:v7APuK/UouTiu8mWQZbdDmj7DfxxkGUeuhjaRB5gv9E=
github.com/git-pkgs/enrichment v0.6.4 h1:mGrfenttwmcUfPXRkWpB0wBJiiGj55ltniUh66Pq4bU=
github.com/git-pkgs/enrichment v0.6.4/go.mod h1:zz1vPUak/w8Jhajll0KDRN2MjKaEYeCzQTxumWnVhqY=
github.com/git-pkgs/magic v0.1.0 h1:xLrqq7CMXB9g5bJnmJyKw17Rvlh0GFiEmO6e5RFsoeY=
github.com/git-pkgs/magic v0.1.0/go.mod h1:3ndidt+yvFaI1M0aEkkzkOlFnLPkeVQASIUojazcxCI=
github.com/git-pkgs/packageurl-go v0.3.1 h1:WM3RBABQZLaRBxgKyYughc3cVBE8KyQxbSC6Jt5ak7M=
github.com/git-pkgs/packageurl-go v0.3.1/go.mod h1:rcIxiG37BlQLB6FZfgdj9Fm7yjhRQd3l+5o7J0QPAk4=
github.com/git-pkgs/pom v0.1.5 h1:TGT8Az2OMxGWsXnSagtUMGzZm7Oax8HrSCteA+mi0qY=
Expand Down
128 changes: 77 additions & 51 deletions internal/server/browse.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package server

import (
"bufio"
"encoding/json"
"fmt"
"io"
Expand All @@ -10,29 +11,22 @@ import (

"github.com/git-pkgs/archives"
"github.com/git-pkgs/archives/diff"
"github.com/git-pkgs/magic"
"github.com/git-pkgs/proxy/internal/database"
"github.com/git-pkgs/purl"
"github.com/go-chi/chi/v5"
)

const contentTypePlainText = "text/plain; charset=utf-8"
const (
contentTypePlainText = "text/plain; charset=utf-8"
browseSniffSize = 512
)

// maxBrowseArchiveSize caps how much data openArchive will buffer for
// prefix detection. Artifacts larger than this are rejected to prevent
// memory exhaustion from a single request.
const maxBrowseArchiveSize = 512 << 20 // 512 MB

// archiveFilename returns a filename suitable for archive format detection.
// Some ecosystems (e.g. composer) store artifacts with bare hash filenames
// that have no extension. This adds .zip when the original has no extension
// and the content is likely a zip archive.
func archiveFilename(filename string) string {
if path.Ext(filename) == "" {
return filename + ".zip"
}
return filename
}

// detectSingleRootDir returns the single top-level directory name if all files
// in the archive live under one common directory (e.g. GitHub zipballs use
// "repo-hash/"). Returns "" if there's no single root or the archive is flat.
Expand Down Expand Up @@ -66,8 +60,6 @@ func detectSingleRootDir(reader archives.Reader) string {
// and stripping a single top-level directory prefix (like GitHub zipballs).
// For npm, the hardcoded "package/" prefix takes precedence.
func openArchive(filename string, content io.Reader, ecosystem string) (archives.Reader, error) { //nolint:ireturn // wraps multiple archive implementations
fname := archiveFilename(filename)

limited := io.LimitReader(content, maxBrowseArchiveSize+1)
data, err := io.ReadAll(limited)
if err != nil {
Expand All @@ -78,17 +70,17 @@ func openArchive(filename string, content io.Reader, ecosystem string) (archives
}

if ecosystem == "npm" {
return archives.OpenBytesWithPrefix(fname, data, "package/")
return archives.OpenBytesWithPrefix(filename, data, "package/")
}

probe, err := archives.OpenBytes(fname, data)
probe, err := archives.OpenBytes(filename, data)
if err != nil {
return nil, err
}
prefix := detectSingleRootDir(probe)
_ = probe.Close()

return archives.OpenBytesWithPrefix(fname, data, prefix)
return archives.OpenBytesWithPrefix(filename, data, prefix)
}

// BrowseListResponse contains the file listing for a directory in an archives.
Expand Down Expand Up @@ -361,7 +353,14 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
}
defer func() { _ = fileReader.Close() }()

contentType := detectContentType(filePath)
contentType, knownPath := detectContentTypeFromPath(filePath)
var content io.Reader = fileReader
if !knownPath {
bufferedFile := bufio.NewReaderSize(fileReader, browseSniffSize)
prefix, _ := bufferedFile.Peek(browseSniffSize)
contentType = detectContentType(filePath, prefix)
content = bufferedFile
}
Comment on lines +356 to +363
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Security-Policy", "sandbox")
w.Header().Set("X-Content-Type-Options", "nosniff")
Expand All @@ -370,85 +369,112 @@ func (s *Server) browseFile(w http.ResponseWriter, r *http.Request, ecosystem, n
w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", filename))

// Stream the file
_, _ = io.Copy(w, fileReader)
_, _ = io.Copy(w, content)
}

// detectContentType returns an appropriate content type. Known filenames and
// extensions take precedence; content detection handles the remaining files.
func detectContentType(filename string, prefix []byte) string {
if contentType, ok := detectContentTypeFromPath(filename); ok {
return contentType
}
return detectContentTypeFromPrefix(prefix)
}

// detectContentType returns an appropriate content type based on file extension.
func detectContentType(filename string) string {
func detectContentTypeFromPath(filename string) (string, bool) {
ext := strings.ToLower(path.Ext(filename))

switch ext {
// Text formats
case ".txt", ".md", ".markdown":
return contentTypePlainText
return contentTypePlainText, true
case ".html", ".htm", ".xhtml":
return contentTypePlainText
return contentTypePlainText, true
case ".css":
return "text/css; charset=utf-8"
return "text/css; charset=utf-8", true
case ".js", ".mjs":
return "application/javascript; charset=utf-8"
return "application/javascript; charset=utf-8", true
case ".json":
return "application/json; charset=utf-8"
return "application/json; charset=utf-8", true
case ".xml":
return "application/xml; charset=utf-8"
return "application/xml; charset=utf-8", true
case ".yaml", ".yml":
return "text/yaml; charset=utf-8"
return "text/yaml; charset=utf-8", true
case ".toml":
return "text/toml; charset=utf-8"
return "text/toml; charset=utf-8", true

// Programming languages
case ".go":
return "text/x-go; charset=utf-8"
return "text/x-go; charset=utf-8", true
case ".rs":
return "text/x-rust; charset=utf-8"
return "text/x-rust; charset=utf-8", true
case ".py":
return "text/x-python; charset=utf-8"
return "text/x-python; charset=utf-8", true
case ".rb":
return "text/x-ruby; charset=utf-8"
return "text/x-ruby; charset=utf-8", true
case ".java":
return "text/x-java; charset=utf-8"
return "text/x-java; charset=utf-8", true
case ".c", ".h":
return "text/x-c; charset=utf-8"
return "text/x-c; charset=utf-8", true
case ".cpp", ".cc", ".cxx", ".hpp":
return "text/x-c++; charset=utf-8"
return "text/x-c++; charset=utf-8", true
case ".ts":
return "text/typescript; charset=utf-8"
return "text/typescript; charset=utf-8", true
case ".tsx":
return "text/tsx; charset=utf-8"
return "text/tsx; charset=utf-8", true
case ".jsx":
return "text/jsx; charset=utf-8"
return "text/jsx; charset=utf-8", true
case ".php":
return "text/x-php; charset=utf-8"
return "text/x-php; charset=utf-8", true

// Config files
case ".conf", ".config", ".ini":
return contentTypePlainText
return contentTypePlainText, true
case ".sh", ".bash":
return "text/x-shellscript; charset=utf-8"
return "text/x-shellscript; charset=utf-8", true
case ".dockerfile":
return "text/x-dockerfile; charset=utf-8"
return "text/x-dockerfile; charset=utf-8", true

// Images
case ".png":
return "image/png"
return "image/png", true
case ".jpg", ".jpeg":
return "image/jpeg"
return "image/jpeg", true
case ".gif":
return "image/gif"
return "image/gif", true
case ".svg":
return contentTypePlainText
return contentTypePlainText, true
case ".ico":
return "image/x-icon"
return "image/x-icon", true

// Archives
case ".zip", ".tar", ".gz", ".bz2", ".xz":
return "application/octet-stream"
return "application/octet-stream", true

default:
// Try to detect if it looks like text
if isLikelyText(filename) {
return contentTypePlainText
return contentTypePlainText, true
}
return "", false
}
}

func detectContentTypeFromPrefix(prefix []byte) string {
result := magic.DetectPrefix(prefix)
if result.Kind == magic.KindText {
return contentTypePlainText
}

switch result.Format {
case "png":
return "image/png"
case "jpeg":
return "image/jpeg"
case "gif":
return "image/gif"
case "pdf":
return "application/pdf"
default:
return "application/octet-stream"
}
}
Expand Down
25 changes: 25 additions & 0 deletions internal/server/browse_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,28 @@ func BenchmarkOpenArchive(b *testing.B) {
})
}
}

func BenchmarkDetectContentType(b *testing.B) {
cases := []struct {
name string
filename string
prefix []byte
}{
{"known-path", "README.md", nil},
{"text-prefix", "artifact", bytes.Repeat([]byte("a"), browseSniffSize)},
{"png-prefix", "artifact", append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, browseSniffSize-8)...)},
}

for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
b.ReportAllocs()
var contentType string
for b.Loop() {
contentType = detectContentType(tc.filename, tc.prefix)
}
if contentType == "" {
b.Fatal("empty content type")
}
})
}
}
Loading