internal/lfs/lfs.go

e4fa4034379ae93e3cf6f1084ed577a7e02b530c
gitbay/internal/lfs/lfs.go history · blame · raw

175 lines · 4916 bytes

  1// Package lfs implements Git LFS server storage and authorization.
  2//
  3// The protocol surface lives in httpd (batch API + basic transfers) and
  4// sshd (git-lfs-authenticate); this package owns the pieces both need:
  5// content-addressed blob storage behind a small interface, and the
  6// short-lived tokens that bridge SSH authentication to the HTTP endpoints.
  7//
  8// BlobStore is deliberately minimal so an S3-compatible backend is a
  9// drop-in: implement the four methods against a bucket and the batch and
 10// transfer handlers work unchanged (the server streams as a proxy).
 11// Handing clients presigned URLs instead is a later optimization to the
 12// batch handler, not a rewrite.
 13package lfs
 14
 15import (
 16	"crypto/hmac"
 17	"crypto/rand"
 18	"crypto/sha256"
 19	"encoding/base64"
 20	"encoding/hex"
 21	"fmt"
 22	"io"
 23	"os"
 24	"path/filepath"
 25	"regexp"
 26	"strconv"
 27	"strings"
 28	"time"
 29)
 30
 31// OIDPat is a lowercase sha256 hex digest — the only object name LFS uses.
 32var OIDPat = regexp.MustCompile(`^[a-f0-9]{64}$`)
 33
 34// BlobStore holds LFS objects by their sha256 content address.
 35type BlobStore interface {
 36	// Put stores the reader's content as oid, verifying both size and
 37	// digest; a mismatch stores nothing.
 38	Put(oid string, r io.Reader, size int64) error
 39	Get(oid string) (io.ReadCloser, int64, error)
 40	Exists(oid string) (int64, bool)
 41	Delete(oid string) error
 42}
 43
 44// LocalStore is the on-disk backend: <root>/<aa>/<bb>/<oid>, written via a
 45// temp file and renamed only after the digest checks out.
 46type LocalStore struct {
 47	Root string
 48}
 49
 50func (s LocalStore) path(oid string) string {
 51	return filepath.Join(s.Root, oid[:2], oid[2:4], oid)
 52}
 53
 54func (s LocalStore) Put(oid string, r io.Reader, size int64) error {
 55	if !OIDPat.MatchString(oid) {
 56		return fmt.Errorf("bad oid %q", oid)
 57	}
 58	dir := filepath.Dir(s.path(oid))
 59	if err := os.MkdirAll(dir, 0o755); err != nil {
 60		return err
 61	}
 62	tmp, err := os.CreateTemp(dir, ".upload-*")
 63	if err != nil {
 64		return err
 65	}
 66	defer func() {
 67		tmp.Close()
 68		os.Remove(tmp.Name())
 69	}()
 70	h := sha256.New()
 71	n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, size+1))
 72	if err != nil {
 73		return err
 74	}
 75	if n != size {
 76		return fmt.Errorf("size mismatch: got %d bytes, expected %d", n, size)
 77	}
 78	if sum := hex.EncodeToString(h.Sum(nil)); sum != oid {
 79		return fmt.Errorf("content digest %s does not match oid", sum[:12])
 80	}
 81	if err := tmp.Close(); err != nil {
 82		return err
 83	}
 84	return os.Rename(tmp.Name(), s.path(oid))
 85}
 86
 87func (s LocalStore) Get(oid string) (io.ReadCloser, int64, error) {
 88	if !OIDPat.MatchString(oid) {
 89		return nil, 0, fmt.Errorf("bad oid %q", oid)
 90	}
 91	f, err := os.Open(s.path(oid))
 92	if err != nil {
 93		return nil, 0, err
 94	}
 95	fi, err := f.Stat()
 96	if err != nil {
 97		f.Close()
 98		return nil, 0, err
 99	}
100	return f, fi.Size(), nil
101}
102
103func (s LocalStore) Exists(oid string) (int64, bool) {
104	if !OIDPat.MatchString(oid) {
105		return 0, false
106	}
107	fi, err := os.Stat(s.path(oid))
108	if err != nil {
109		return 0, false
110	}
111	return fi.Size(), true
112}
113
114func (s LocalStore) Delete(oid string) error {
115	if !OIDPat.MatchString(oid) {
116		return fmt.Errorf("bad oid %q", oid)
117	}
118	return os.Remove(s.path(oid))
119}
120
121// Tokens bridge SSH authentication to the HTTP endpoints: stateless,
122// HMAC-signed, scoped to one repo and one operation, short-lived. The
123// secret persists in the settings table so tokens survive restarts.
124
125const TokenTTL = time.Hour
126
127// Sign mints a token for op ("download" or "upload") on repoID.
128func Sign(secret []byte, repoID int64, op string, now time.Time) string {
129	payload := fmt.Sprintf("%d:%s:%d", repoID, op, now.Add(TokenTTL).Unix())
130	mac := hmac.New(sha256.New, secret)
131	mac.Write([]byte(payload))
132	return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." +
133		base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
134}
135
136// Verify checks a token and returns the repo and operation it authorizes.
137func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) {
138	payloadB64, macB64, found := strings.Cut(token, ".")
139	if !found {
140		return 0, "", false
141	}
142	payload, err := base64.RawURLEncoding.DecodeString(payloadB64)
143	if err != nil {
144		return 0, "", false
145	}
146	gotMAC, err := base64.RawURLEncoding.DecodeString(macB64)
147	if err != nil {
148		return 0, "", false
149	}
150	mac := hmac.New(sha256.New, secret)
151	mac.Write(payload)
152	if !hmac.Equal(mac.Sum(nil), gotMAC) {
153		return 0, "", false
154	}
155	parts := strings.Split(string(payload), ":")
156	if len(parts) != 3 {
157		return 0, "", false
158	}
159	id, err1 := strconv.ParseInt(parts[0], 10, 64)
160	exp, err2 := strconv.ParseInt(parts[2], 10, 64)
161	if err1 != nil || err2 != nil || now.Unix() > exp {
162		return 0, "", false
163	}
164	if parts[1] != "download" && parts[1] != "upload" {
165		return 0, "", false
166	}
167	return id, parts[1], true
168}
169
170// NewSecret returns 32 random bytes, hex-encoded for the settings table.
171func NewSecret() string {
172	buf := make([]byte, 32)
173	rand.Read(buf)
174	return hex.EncodeToString(buf)
175}