Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-10 16:19:16 -05:00
pkg-proxy/internal/handler/debian.go

243 lines
8.2 KiB
Go
Raw Permalink Normal View History

package handler
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"github.com/git-pkgs/proxy/internal/retention"
"github.com/git-pkgs/purl"
)
const (
debianUpstream = "http://deb.debian.org/debian"
debMatchCount = 4 // full match + name + version + arch
)
// Packages are stored as pkg:deb/{name}, without a vendor and whichever
// configured archive they came from. Package rules may name a vendor the way
// PURLs usually do, pkg:deb/debian/curl or pkg:deb/ubuntu/curl; it is dropped,
// so the rule covers the package from every archive.
func init() {
retention.Default.Register(retention.Spec{Key: "deb", CanonicalPackage: debRetentionKey})
}
// debRetentionKey maps a package rule to the stored package PURL. A name with
// a slash, or a namespace with more than one segment, can never match a stored
// package and is refused.
func debRetentionKey(p *purl.PURL) (string, bool) {
if p == nil || p.Type != "deb" || strings.Contains(p.Namespace, "/") || strings.Contains(p.Name, "/") {
return "", false
}
canonical := purl.MakePURLString("deb", p.Name, "")
return canonical, canonical != ""
}
// DebianHandler handles APT/Debian repository protocol requests.
// It proxies requests to upstream Debian/Ubuntu repositories and caches .deb packages.
//
// The main archive is served at /debian/. Additional archives are mounted at
// /debian/{repository}/ and the remaining path mirrors the upstream layout.
type DebianHandler struct {
proxy *Proxy
upstreamURL string
proxyURL string
repositories map[string]string
}
// NewDebianHandler creates a new Debian/APT protocol handler.
// When repositories is empty, only the main archive is reachable.
func NewDebianHandler(
proxy *Proxy,
proxyURL string,
upstreamURL string,
repositories map[string]string,
) *DebianHandler {
if upstreamURL == "" {
upstreamURL = debianUpstream
}
h := &DebianHandler{
proxy: proxy,
upstreamURL: strings.TrimSuffix(upstreamURL, "/"),
proxyURL: strings.TrimSuffix(proxyURL, "/"),
repositories: make(map[string]string, len(repositories)),
}
for name, repositoryURL := range repositories {
h.repositories[name] = strings.TrimSuffix(repositoryURL, "/")
}
return h
}
// Routes returns the HTTP handler for Debian requests.
// Mount this at /debian on your router.
func (h *DebianHandler) Routes() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/")
if containsPathTraversal(path) {
http.Error(w, "invalid path", http.StatusBadRequest)
return
}
// Unlike the APK handler, an unconfigured first segment is not a 404:
// the main archive is unnamed and serves paths of its own at the root
// (README, indices/, project/), so an unknown name stays a
// main-archive path.
upstreamURL, repository, rest := h.upstreamURL, "", path
if !strings.HasPrefix(path, "pool/") && !strings.HasPrefix(path, "dists/") {
if name, tail, ok := strings.Cut(path, "/"); ok && tail != "" {
if named, found := h.repositories[name]; found {
upstreamURL, repository, rest = named, name, tail
} else if strings.HasPrefix(tail, "dists/") || strings.HasPrefix(tail, "pool/") {
// The main archive has no {name}/dists/ or {name}/pool/ of
// its own, so this is a misspelled repository rather than a
// main-archive path. Answering here keeps a typo from
// surfacing as the upstream's opaque HTML 404.
h.repositoryNotFound(w, name)
return
}
}
}
// Route based on path type
switch {
case strings.HasPrefix(rest, "pool/"):
// Package downloads - cache these
h.handlePackageDownload(w, r, repository, upstreamURL, rest)
case strings.HasPrefix(rest, "dists/"):
// Repository metadata - served through the metadata cache
h.handleMetadata(w, r, repository, upstreamURL, rest)
default:
// Other files (like README, etc.) - proxy directly
h.proxyFile(w, r, upstreamURL, rest)
}
})
}
// repositoryNotFound answers a request addressed to a repository that is not
// configured, naming the ones that are so a misspelling is self-evident.
func (h *DebianHandler) repositoryNotFound(w http.ResponseWriter, name string) {
if len(h.repositories) == 0 {
http.Error(w,
fmt.Sprintf("unknown debian repository %q: none are configured", name),
http.StatusNotFound)
return
}
configured := make([]string, 0, len(h.repositories))
for repository := range h.repositories {
configured = append(configured, repository)
}
sort.Strings(configured)
http.Error(w,
fmt.Sprintf("unknown debian repository %q: configured repositories are %s",
name, strings.Join(configured, ", ")),
http.StatusNotFound)
}
// handlePackageDownload fetches and caches .deb packages from the pool.
// Pool path format: pool/{component}/{prefix}/{name}/{filename}
// Example: pool/main/n/nginx/nginx_1.18.0-6_amd64.deb
func (h *DebianHandler) handlePackageDownload(
w http.ResponseWriter,
r *http.Request,
repository, upstreamURL, path string,
) {
// Parse the path to extract package info
name, version, arch := h.parsePoolPath(path)
if name == "" {
// Can't parse, just proxy directly
h.proxyFile(w, r, upstreamURL, path)
return
}
filename := path[strings.LastIndex(path, "/")+1:]
downloadURL := fmt.Sprintf("%s/%s", upstreamURL, path)
cacheFilename := h.artifactCacheFilename(repository, filename)
h.proxy.Logger.Info("debian package download",
"repository", repository,
"name", name, "version", version, "arch", arch, "filename", filename)
result, err := h.proxy.GetOrFetchArtifactFromURL(
r.Context(), "deb", name, version, cacheFilename, downloadURL)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch package")
return
}
w.Header().Set(headerContentType, "application/vnd.debian.binary-package")
Support HTTP byte-range requests for local file cached artifact downloads (#404) * feat(storage): expose efficient seek for local files Open file-backed artifacts with os.Open so local readers support efficient seeking. Leave cloud storage readers unchanged. Keep the legacy sidecar mapping test focused on fileblob's reader: Blob.Open now reads local files directly and no longer consults the sidecar. Continue checking sidecar cleanup and post-store reads. * feat(handler): preserve efficient seek through integrity checks Keep the cache result's single Reader field and preserve io.Seeker when the underlying storage reader supports efficient seeking. Sequential reads continue through whole-object integrity verification. A successful seek switches subsequent reads to the source for range responses, where full-object verification is not possible. Test both modes and verify the source is closed once. * feat(handler): add request-aware byte range responses Add a request-aware artifact serving helper for bounded, open-ended, and suffix byte ranges, including If-Range handling, 206 responses, and 416 responses for valid unsatisfiable ranges. Ignore malformed and multi-range requests by serving the full artifact. Advertise byte-range support only for seekable readers, preserving HEAD, redirect, and non-seekable behavior. Clarify that net/http recovers ErrAbortHandler per request and keeps the server running. * feat(handler): enable byte ranges across artifact downloads Forward each artifact request to the shared range-aware response helper, including cached generic release assets and OCI blobs. This lets the handler honor ranges only when its reader supports efficient seeking, without changing direct-storage redirect behavior. Add warm-cache endpoint tests that assert partial response headers and bytes without an upstream refetch. Document supported range behavior, unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
ServeArtifactRequest(w, r, result)
}
// handleMetadata serves repository metadata files through the metadata cache,
// so they keep resolving while the upstream archive is unreachable.
func (h *DebianHandler) handleMetadata(
w http.ResponseWriter,
r *http.Request,
repository, upstreamURL, path string,
) {
cacheKey := h.metadataCacheKeyFor(repository, upstreamURL, path)
h.proxy.ProxyCached(w, r, fmt.Sprintf("%s/%s", upstreamURL, path), "debian", cacheKey, "*/*")
}
// artifactCacheFilename scopes a package by repository, since the same
// filename can hold different bytes in different archives. The main archive
// keeps the unscoped filename its existing cache entries were stored under.
func (h *DebianHandler) artifactCacheFilename(repository, filename string) string {
if repository == "" {
return filename
}
return repository + "/" + filename
}
// metadataCacheKeyFor returns the metadata cache key for a request. The main
// archive keeps its separator-based key so existing cache entries stay valid;
// a named repository hashes its identity as APKHandler.metadataCacheKey does.
func (h *DebianHandler) metadataCacheKeyFor(repository, upstreamURL, path string) string {
if repository == "" {
return strings.ReplaceAll(path, "/", "_")
}
identity := repository + "\x00" + upstreamURL + "\x00" + path
digest := sha256.Sum256([]byte(identity))
return hex.EncodeToString(digest[:])
}
// proxyFile proxies any file directly without caching.
func (h *DebianHandler) proxyFile(w http.ResponseWriter, r *http.Request, upstreamURL, path string) {
h.proxy.ProxyFile(w, r, fmt.Sprintf("%s/%s", upstreamURL, path))
}
// debPackagePattern matches .deb filenames to extract name, version, and arch.
// Format: {name}_{version}_{arch}.deb
var debPackagePattern = regexp.MustCompile(`^(.+)_([^_]+)_([^_]+)\.deb$`)
// parsePoolPath extracts package info from a pool path.
// Example: pool/main/n/nginx/nginx_1.18.0-6_amd64.deb
func (h *DebianHandler) parsePoolPath(path string) (name, version, arch string) {
// Get the filename
idx := strings.LastIndex(path, "/")
if idx < 0 {
return "", "", ""
}
filename := path[idx+1:]
// Parse the filename
matches := debPackagePattern.FindStringSubmatch(filename)
if len(matches) != debMatchCount {
return "", "", ""
}
return matches[1], matches[2], matches[3]
}