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/git_lfs.go
Simon Chrzanowski fe4be3ccd6
Reuse batch download URLs and stored copies for LFS objects
- Keep the download URLs of a batch response in memory until shortly
  before they expire, so the GET that follows needs no second batch
  request. A kept URL that fails is replaced by a fresh one.
- Cut off a download that isn't the object's size.
- Copy an object whose content is already stored, such as for a fork,
  instead of downloading it, once the upstream has confirmed the
  repository has it. Artifacts are indexed by content hash for this.
2026-10-25 18:19:09 +00:00

559 lines
19 KiB
Go

package handler
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"time"
)
const (
// gitLFSPath separates a repository from the Git LFS API below it, as
// git-lfs derives the API URL from a remote: {remote}.git/info/lfs.
gitLFSPath = "/info/lfs/"
gitLFSMediaType = "application/vnd.git-lfs+json"
gitLFSTransfer = "basic"
gitLFSHashAlgo = "sha256"
// gitLFSObjectMediaType is what objects are served as.
gitLFSObjectMediaType = "application/octet-stream"
// gitLFSMaxBatchRequest bounds a client's batch request. git-lfs sends
// 100 objects per batch by default, a few kilobytes.
gitLFSMaxBatchRequest = 1 << 20
// gitLFSMaxBatchResponse bounds an upstream batch response, which holds
// an href and headers per object.
gitLFSMaxBatchResponse = 16 << 20
gitLFSOIDLength = 64
)
// lfsBatchRequest is a Git LFS batch API request.
type lfsBatchRequest struct {
Operation string `json:"operation"`
Transfers []string `json:"transfers,omitempty"`
Ref json.RawMessage `json:"ref,omitempty"`
Objects []lfsObject `json:"objects"`
HashAlgo string `json:"hash_algo,omitempty"`
}
type lfsObject struct {
OID string `json:"oid"`
Size int64 `json:"size"`
}
// lfsBatchResponse is a Git LFS batch API response.
type lfsBatchResponse struct {
Transfer string `json:"transfer,omitempty"`
Objects []lfsObjectResponse `json:"objects"`
HashAlgo string `json:"hash_algo,omitempty"`
}
type lfsObjectResponse struct {
OID string `json:"oid"`
Size int64 `json:"size"`
Authenticated bool `json:"authenticated,omitempty"`
Actions map[string]lfsAction `json:"actions,omitempty"`
Error *lfsObjectError `json:"error,omitempty"`
}
type lfsAction struct {
Href string `json:"href"`
Header map[string]string `json:"header,omitempty"`
ExpiresIn int64 `json:"expires_in,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
type lfsObjectError struct {
Code int `json:"code"`
Message string `json:"message"`
}
// lfsUpstreamError is a batch request the upstream answered with an error
// status.
type lfsUpstreamError struct {
status int
}
func (e *lfsUpstreamError) Error() string {
return fmt.Sprintf("LFS batch API answered %d", e.status)
}
// WithBaseURL sets the URL clients reach the proxy at, which LFS batch
// responses send them back to for each object.
func (h *GitHandler) WithBaseURL(baseURL string) *GitHandler {
h.baseURL = strings.TrimSuffix(baseURL, "/")
return h
}
// serveLFS answers a request to the Git LFS API of a repository: batch
// downloads, and the objects the batch responses point at. Objects are
// stored in the artifact cache, checked against their sha256 object ID.
// Uploads and locking are refused.
func (h *GitHandler) serveLFS(w http.ResponseWriter, r *http.Request, repoPath, endpoint string) {
repo, status, msg := h.repository(repoPath)
if status != 0 {
lfsError(w, status, msg)
return
}
if endpoint == "objects/batch" {
if r.Method != http.MethodPost {
lfsError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
h.serveLFSBatch(w, r, repo)
return
}
oid, sizeText, ok := strings.Cut(strings.TrimPrefix(endpoint, "objects/"), "/")
size, err := strconv.ParseInt(sizeText, 10, 64)
if !strings.HasPrefix(endpoint, "objects/") || !ok || err != nil || size < 0 || !validLFSOID(oid) {
lfsError(w, http.StatusNotFound, "not found")
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
lfsError(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
h.serveLFSObject(w, r, repo, lfsObject{OID: oid, Size: size})
}
// serveLFSBatch forwards a download batch upstream and points every object
// the upstream offers at this proxy. The upstream answers for the
// repository, so a client only gets objects it could get from there.
func (h *GitHandler) serveLFSBatch(w http.ResponseWriter, r *http.Request, repo gitRepository) {
var req lfsBatchRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, gitLFSMaxBatchRequest)).Decode(&req); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
lfsError(w, http.StatusRequestEntityTooLarge, "batch request too large")
return
}
lfsError(w, http.StatusBadRequest, "invalid batch request")
return
}
switch {
case req.Operation != "download":
lfsError(w, http.StatusForbidden, "pushing is not supported")
return
case req.HashAlgo != "" && req.HashAlgo != gitLFSHashAlgo:
lfsError(w, http.StatusConflict, "only sha256 objects are supported")
return
case len(req.Transfers) > 0 && !slices.Contains(req.Transfers, gitLFSTransfer):
lfsError(w, http.StatusUnprocessableEntity, "only the basic transfer is supported")
return
}
requested := make(map[string]int64, len(req.Objects))
for _, object := range req.Objects {
if !validLFSOID(object.OID) || object.Size < 0 {
lfsError(w, http.StatusUnprocessableEntity, "invalid object")
return
}
requested[object.OID] = object.Size
}
upstream, err := h.lfsBatch(r.Context(), repo, req.Ref, req.Objects)
if err != nil {
if resp, ok := h.lfsBatchFromCache(r, repo, req.Objects, err); ok {
writeLFSBatch(w, resp)
return
}
h.serveLFSUpstreamError(w, r, repo, err)
return
}
resp := newLFSBatchResponse()
for _, object := range upstream.Objects {
// Only objects the client asked for, at the size it gave, so a
// response can't point the client at anything else.
size, ok := requested[object.OID]
if !ok || size != object.Size {
continue
}
out := lfsObjectResponse{OID: object.OID, Size: object.Size, Error: object.Error}
if download, ok := object.Actions["download"]; ok && object.Error == nil && download.Href != "" {
out.Authenticated = true
out.Actions = map[string]lfsAction{"download": {Href: h.lfsObjectURL(repo, object.OID, object.Size)}}
// Kept so the client's GET for the object needs no second
// batch request.
h.lfsDownloads.put(repo.key, lfsObject{OID: object.OID, Size: object.Size}, download, time.Now())
}
resp.Objects = append(resp.Objects, out)
}
writeLFSBatch(w, resp)
}
// lfsBatchFromCache answers a batch the upstream couldn't, because it was
// unreachable or failing, from the objects cached for repo. This is what a
// GET of each object would serve anyway. A refusal by the upstream is not
// overridden, and a batch none of whose objects are cached is left to fail.
func (h *GitHandler) lfsBatchFromCache(r *http.Request, repo gitRepository, objects []lfsObject, err error) (lfsBatchResponse, bool) {
var refused *lfsUpstreamError
if r.Context().Err() != nil || (errors.As(err, &refused) && refused.status < http.StatusInternalServerError) {
return lfsBatchResponse{}, false
}
resp := newLFSBatchResponse()
seen := make(map[string]bool, len(objects))
cached := 0
for _, object := range objects {
if seen[object.OID] {
continue
}
seen[object.OID] = true
out := lfsObjectResponse{OID: object.OID, Size: object.Size}
if ok, _ := h.proxy.HasCachedArtifact(r.Context(), gitEcosystem, repo.lfsName(), object.OID, object.OID); ok {
out.Authenticated = true
out.Actions = map[string]lfsAction{"download": {Href: h.lfsObjectURL(repo, object.OID, object.Size)}}
cached++
} else {
out.Error = &lfsObjectError{Code: http.StatusBadGateway, Message: "upstream unavailable and object not cached"}
}
resp.Objects = append(resp.Objects, out)
}
if cached == 0 {
return lfsBatchResponse{}, false
}
h.proxy.Logger.Warn("git: LFS batch request failed, answering from the cache",
"repository", repo.key, "cached", cached, "objects", len(resp.Objects), "error", err)
return resp, true
}
func newLFSBatchResponse() lfsBatchResponse {
return lfsBatchResponse{Transfer: gitLFSTransfer, Objects: []lfsObjectResponse{}, HashAlgo: gitLFSHashAlgo}
}
func writeLFSBatch(w http.ResponseWriter, resp lfsBatchResponse) {
w.Header().Set("Content-Type", gitLFSMediaType)
_ = json.NewEncoder(w).Encode(resp)
}
// serveLFSObject serves an object from the artifact cache. On a miss it
// takes the download URL from the client's batch, or asks the upstream batch
// API for a fresh one, and caches the object from there, or from a copy
// already stored under another repository, once its content matches its
// object ID.
func (h *GitHandler) serveLFSObject(w http.ResponseWriter, r *http.Request, repo gitRepository, object lfsObject) {
cached, err := h.proxy.GetCachedArtifact(r.Context(), gitEcosystem, repo.lfsName(), object.OID, object.OID)
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to read cached LFS object")
return
}
if cached == nil {
var ok bool
if cached, ok = h.fetchLFSObject(w, r, repo, object); !ok {
return
}
}
serveGenericArtifact(w, r, cached)
}
// fetchLFSObject caches an object the cache doesn't have. When it can't, or
// the request is a HEAD, it answers the request and returns false.
func (h *GitHandler) fetchLFSObject(w http.ResponseWriter, r *http.Request, repo gitRepository, object lfsObject) (*CacheResult, bool) {
download, kept := h.lfsDownloads.get(repo.key, object, time.Now())
if !kept {
var ok bool
if download, ok = h.lfsFreshDownload(w, r, repo, object); !ok {
return nil, false
}
}
if r.Method == http.MethodHead {
// The upstream has the object, and a HEAD needs no more than that:
// fetching it is left to a GET.
w.Header().Set("Content-Type", gitLFSObjectMediaType)
w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10))
return nil, false
}
h.lfsDownloads.drop(repo.key, object.OID)
cached, err := h.cacheLFSObject(r.Context(), repo, object, download)
if err != nil && kept && retryLFSDownload(r.Context(), err) {
// The kept URL may have expired early or been revoked.
var ok bool
if download, ok = h.lfsFreshDownload(w, r, repo, object); !ok {
return nil, false
}
cached, err = h.cacheLFSObject(r.Context(), repo, object, download)
}
if err != nil {
h.proxy.serveArtifactError(w, err, "failed to fetch LFS object")
return nil, false
}
return cached, true
}
// lfsFreshDownload asks the upstream batch API for object's download. When
// there is none it answers the request and returns false.
func (h *GitHandler) lfsFreshDownload(w http.ResponseWriter, r *http.Request, repo gitRepository, object lfsObject) (lfsAction, bool) {
upstream, err := h.lfsBatch(r.Context(), repo, nil, []lfsObject{object})
if err != nil {
h.serveLFSUpstreamError(w, r, repo, err)
return lfsAction{}, false
}
download, status, msg := lfsDownload(upstream, object)
if status != 0 {
lfsError(w, status, msg)
return lfsAction{}, false
}
return download, true
}
// cacheLFSObject caches object, which the upstream offers as download. A
// copy already stored under another name, such as by a fork of the
// repository, saves the download.
func (h *GitHandler) cacheLFSObject(ctx context.Context, repo gitRepository, object lfsObject, download lfsAction) (*CacheResult, error) {
header := make(http.Header, len(download.Header))
for key, value := range download.Header {
header.Set(key, value)
}
// Download URLs are often signed, granting access to the object until
// they expire, so only the URL without its query is logged and
// recorded. The object must have the size the client asked for.
ctx, recorded := withSignedDownload(ctx, download.Href, object.Size)
if !h.proxy.StreamArtifacts {
stored, err := h.proxy.DB.FindStoredContent(object.OID)
if err != nil {
h.proxy.Logger.Warn("git: looking up a stored copy of an LFS object failed", "oid", object.OID, "error", err)
}
if stored != nil && stored.Size == object.Size {
cached, err := h.proxy.cacheStoredCopy(ctx, gitEcosystem, repo.lfsName(), object.OID, object.OID,
recorded, object.OID, stored)
if err == nil {
return cached, nil
}
h.proxy.Logger.Warn("git: copying a stored LFS object failed, downloading it", "oid", object.OID, "error", err)
}
}
return h.proxy.getOrFetchArtifactFromURL(ctx, gitEcosystem, repo.lfsName(), object.OID, object.OID,
recorded, header, object.OID, "")
}
// retryLFSDownload reports whether a download from a kept URL failed in a way
// a fresh URL might not.
func retryLFSDownload(ctx context.Context, err error) bool {
return ctx.Err() == nil &&
!errors.Is(err, ErrArtifactDigestMismatch) &&
!errors.Is(err, errDownloadSize) &&
!errors.Is(err, ErrVersionDenied) &&
!errors.Is(err, ErrArtifactBlocked)
}
// lfsDownload returns the download action the upstream gave for object, or
// the status and message to answer with when it gave none.
func lfsDownload(resp *lfsBatchResponse, object lfsObject) (lfsAction, int, string) {
for _, got := range resp.Objects {
if got.OID != object.OID {
continue
}
if got.Error != nil {
if got.Error.Code == http.StatusNotFound || got.Error.Code == http.StatusGone {
return lfsAction{}, http.StatusNotFound, "object not found"
}
return lfsAction{}, http.StatusBadGateway, "upstream refused the object"
}
download, ok := got.Actions["download"]
if !ok || !validLFSHref(download.Href) {
return lfsAction{}, http.StatusBadGateway, "upstream gave no download for the object"
}
return download, 0, ""
}
return lfsAction{}, http.StatusNotFound, "object not found"
}
// validLFSHref reports whether href is an absolute HTTP(S) URL.
func validLFSHref(href string) bool {
u, err := url.Parse(href)
return err == nil && (u.Scheme == "https" || u.Scheme == "http") && u.Host != ""
}
// lfsBatch asks the upstream's LFS batch API for download actions for
// objects. upstream.auth applies to it as to the repository.
func (h *GitHandler) lfsBatch(ctx context.Context, repo gitRepository, ref json.RawMessage, objects []lfsObject) (*lfsBatchResponse, error) {
body, err := json.Marshal(lfsBatchRequest{
Operation: "download",
Transfers: []string{gitLFSTransfer},
Ref: ref,
Objects: objects,
HashAlgo: gitLFSHashAlgo,
})
if err != nil {
return nil, err
}
batchURL := strings.TrimSuffix(repo.url, ".git") + ".git" + gitLFSPath + "objects/batch"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, batchURL, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Accept", gitLFSMediaType)
req.Header.Set("Content-Type", gitLFSMediaType)
resp, err := h.proxy.HTTPClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, &lfsUpstreamError{status: resp.StatusCode}
}
var batch lfsBatchResponse
if err := json.NewDecoder(io.LimitReader(resp.Body, gitLFSMaxBatchResponse)).Decode(&batch); err != nil {
return nil, fmt.Errorf("reading LFS batch response: %w", err)
}
if batch.Transfer != "" && batch.Transfer != gitLFSTransfer {
return nil, fmt.Errorf("LFS batch response uses the %q transfer", batch.Transfer)
}
return &batch, nil
}
// serveLFSUpstreamError answers a request whose upstream batch request
// failed.
func (h *GitHandler) serveLFSUpstreamError(w http.ResponseWriter, r *http.Request, repo gitRepository, err error) {
if r.Context().Err() != nil {
return
}
h.proxy.Logger.Warn("git: LFS batch request failed", "repository", repo.key, "error", err)
var upstream *lfsUpstreamError
switch {
case errors.As(err, &upstream) && upstream.status == http.StatusNotFound:
lfsError(w, http.StatusNotFound, "repository not found")
case errors.As(err, &upstream) && upstream.status < http.StatusInternalServerError:
lfsError(w, http.StatusBadGateway, "upstream refused the request")
default:
lfsError(w, http.StatusBadGateway, "upstream unavailable")
}
}
// lfsName names repo's LFS objects in the artifact cache. Objects are cached
// per repository: having one cached for another repository says nothing
// about whether this one has it.
func (repo gitRepository) lfsName() string {
return repo.name + "/" + repo.repo
}
// lfsObjectURL is where this proxy serves an object of repo.
func (h *GitHandler) lfsObjectURL(repo gitRepository, oid string, size int64) string {
return fmt.Sprintf("%s/git/%s/%s.git%sobjects/%s/%d",
h.baseURL, url.PathEscape(repo.name), repo.escapedRepo, gitLFSPath, oid, size)
}
// validLFSOID reports whether oid is a sha256 object ID in lowercase hex.
func validLFSOID(oid string) bool {
if len(oid) != gitLFSOIDLength {
return false
}
for _, c := range oid {
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
return false
}
}
return true
}
// lfsError answers with a Git LFS error document, which git-lfs shows.
func lfsError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", gitLFSMediaType)
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"message": msg})
}
const (
// gitLFSKeptDownloads bounds how many download URLs from batch responses
// are kept for the GETs that follow them.
gitLFSKeptDownloads = 4096
// gitLFSKeptTTL is how long a download URL is kept when the upstream
// gives no expiry, and gitLFSKeptMaxTTL how long at most.
gitLFSKeptTTL = 5 * time.Minute
gitLFSKeptMaxTTL = time.Hour
// gitLFSExpiryMargin is how long before its expiry a URL is no longer
// used, so a download started with it can finish.
gitLFSExpiryMargin = 30 * time.Second
)
// lfsDownloads keeps the download actions of recent batch responses, so the
// GET for an object a client just got in a batch needs no second batch
// request. They stay in memory, never reach a client and expire with the
// URLs.
type lfsDownloads struct {
mu sync.Mutex
entries map[lfsDownloadKey]lfsKeptDownload
}
type lfsDownloadKey struct {
repo, oid string
}
type lfsKeptDownload struct {
size int64
action lfsAction
expires time.Time
}
// put keeps action for object of repo until shortly before it expires.
func (d *lfsDownloads) put(repo string, object lfsObject, action lfsAction, now time.Time) {
if !validLFSHref(action.Href) {
return
}
expires := now.Add(gitLFSKeptTTL)
if action.ExpiresIn > 0 || action.ExpiresAt != "" {
expires = now.Add(gitLFSKeptMaxTTL)
}
if action.ExpiresIn > 0 {
expires = minTime(expires, now.Add(time.Duration(action.ExpiresIn)*time.Second))
}
if at, err := time.Parse(time.RFC3339, action.ExpiresAt); err == nil {
expires = minTime(expires, at)
}
expires = expires.Add(-gitLFSExpiryMargin)
if !expires.After(now) {
return
}
d.mu.Lock()
defer d.mu.Unlock()
if d.entries == nil {
d.entries = make(map[lfsDownloadKey]lfsKeptDownload)
}
if len(d.entries) >= gitLFSKeptDownloads {
for key, kept := range d.entries {
if !kept.expires.After(now) {
delete(d.entries, key)
}
}
}
if len(d.entries) >= gitLFSKeptDownloads {
// Still full: make room by dropping any one.
for key := range d.entries {
delete(d.entries, key)
break
}
}
d.entries[lfsDownloadKey{repo, object.OID}] = lfsKeptDownload{size: object.Size, action: action, expires: expires}
}
// get returns the action kept for object of repo, if it hasn't expired.
func (d *lfsDownloads) get(repo string, object lfsObject, now time.Time) (lfsAction, bool) {
d.mu.Lock()
defer d.mu.Unlock()
kept, ok := d.entries[lfsDownloadKey{repo, object.OID}]
if !ok || kept.size != object.Size || !kept.expires.After(now) {
return lfsAction{}, false
}
return kept.action, true
}
// drop forgets the action kept for oid of repo, once a GET has used it.
func (d *lfsDownloads) drop(repo, oid string) {
d.mu.Lock()
defer d.mu.Unlock()
delete(d.entries, lfsDownloadKey{repo, oid})
}
func minTime(a, b time.Time) time.Time {
if b.Before(a) {
return b
}
return a
}