// Package lfs implements Git LFS server storage and authorization. // // The protocol surface lives in httpd (batch API + basic transfers) and // sshd (git-lfs-authenticate); this package owns the pieces both need: // content-addressed blob storage behind a small interface, and the // short-lived tokens that bridge SSH authentication to the HTTP endpoints. // // BlobStore is deliberately minimal so an S3-compatible backend is a // drop-in: implement the four methods against a bucket and the batch and // transfer handlers work unchanged (the server streams as a proxy). // Handing clients presigned URLs instead is a later optimization to the // batch handler, not a rewrite. package lfs import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "fmt" "io" "io/fs" "os" "path/filepath" "regexp" "strconv" "strings" "time" ) // OIDPat is a lowercase sha256 hex digest — the only object name LFS uses. var OIDPat = regexp.MustCompile(`^[a-f0-9]{64}$`) // BlobStore holds LFS objects by their sha256 content address. type BlobStore interface { // Put stores the reader's content as oid, verifying both size and // digest; a mismatch stores nothing. Put(oid string, r io.Reader, size int64) error Get(oid string) (io.ReadCloser, int64, error) Exists(oid string) (int64, bool) Delete(oid string) error } // LocalStore is the on-disk backend: ///, written via a // temp file and renamed only after the digest checks out. type LocalStore struct { Root string } func (s LocalStore) path(oid string) string { return filepath.Join(s.Root, oid[:2], oid[2:4], oid) } func (s LocalStore) Put(oid string, r io.Reader, size int64) error { if !OIDPat.MatchString(oid) { return fmt.Errorf("bad oid %q", oid) } dir := filepath.Dir(s.path(oid)) if err := os.MkdirAll(dir, 0o755); err != nil { return err } tmp, err := os.CreateTemp(dir, ".upload-*") if err != nil { return err } defer func() { tmp.Close() os.Remove(tmp.Name()) }() h := sha256.New() n, err := io.Copy(io.MultiWriter(tmp, h), io.LimitReader(r, size+1)) if err != nil { return err } if n != size { return fmt.Errorf("size mismatch: got %d bytes, expected %d", n, size) } if sum := hex.EncodeToString(h.Sum(nil)); sum != oid { return fmt.Errorf("content digest %s does not match oid", sum[:12]) } if err := tmp.Close(); err != nil { return err } return os.Rename(tmp.Name(), s.path(oid)) } func (s LocalStore) Get(oid string) (io.ReadCloser, int64, error) { if !OIDPat.MatchString(oid) { return nil, 0, fmt.Errorf("bad oid %q", oid) } f, err := os.Open(s.path(oid)) if err != nil { return nil, 0, err } fi, err := f.Stat() if err != nil { f.Close() return nil, 0, err } return f, fi.Size(), nil } func (s LocalStore) Exists(oid string) (int64, bool) { if !OIDPat.MatchString(oid) { return 0, false } fi, err := os.Stat(s.path(oid)) if err != nil { return 0, false } return fi.Size(), true } func (s LocalStore) Delete(oid string) error { if !OIDPat.MatchString(oid) { return fmt.Errorf("bad oid %q", oid) } return os.Remove(s.path(oid)) } // Tokens bridge SSH authentication to the HTTP endpoints: stateless, // HMAC-signed, scoped to one repo and one operation, short-lived. The // secret persists in the settings table so tokens survive restarts. const TokenTTL = time.Hour // Sign mints a token for op ("download" or "upload") on repoID. func Sign(secret []byte, repoID int64, op string, now time.Time) string { payload := fmt.Sprintf("%d:%s:%d", repoID, op, now.Add(TokenTTL).Unix()) mac := hmac.New(sha256.New, secret) mac.Write([]byte(payload)) return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) } // Verify checks a token and returns the repo and operation it authorizes. func Verify(secret []byte, token string, now time.Time) (repoID int64, op string, ok bool) { payloadB64, macB64, found := strings.Cut(token, ".") if !found { return 0, "", false } payload, err := base64.RawURLEncoding.DecodeString(payloadB64) if err != nil { return 0, "", false } gotMAC, err := base64.RawURLEncoding.DecodeString(macB64) if err != nil { return 0, "", false } mac := hmac.New(sha256.New, secret) mac.Write(payload) if !hmac.Equal(mac.Sum(nil), gotMAC) { return 0, "", false } parts := strings.Split(string(payload), ":") if len(parts) != 3 { return 0, "", false } id, err1 := strconv.ParseInt(parts[0], 10, 64) exp, err2 := strconv.ParseInt(parts[2], 10, 64) if err1 != nil || err2 != nil || now.Unix() > exp { return 0, "", false } if parts[1] != "download" && parts[1] != "upload" { return 0, "", false } return id, parts[1], true } // NewSecret returns 32 random bytes, hex-encoded for the settings table. func NewSecret() string { buf := make([]byte, 32) rand.Read(buf) return hex.EncodeToString(buf) } // Orphans lists objects in the store that no repository references and // that are older than minAge: an object uploaded ahead of the push that // will reference it is not an orphan yet. referenced holds the object ids // every repository's pointers name. func (s LocalStore) Orphans(referenced map[string]bool, minAge time.Duration) ([]Orphan, error) { cutoff := time.Now().Add(-minAge) var out []Orphan err := filepath.WalkDir(s.Root, func(path string, d fs.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } oid := d.Name() if !OIDPat.MatchString(oid) || referenced[oid] { return nil } info, err := d.Info() if err != nil || info.ModTime().After(cutoff) { return nil } out = append(out, Orphan{OID: oid, Size: info.Size()}) return nil }) return out, err } // Orphan is one unreferenced object. type Orphan struct { OID string Size int64 } // Size sums every object in the store. func (s LocalStore) Size() int64 { var total int64 filepath.WalkDir(s.Root, func(_ string, d fs.DirEntry, err error) error { if err == nil && !d.IsDir() { if fi, err := d.Info(); err == nil { total += fi.Size() } } return nil }) return total } // RootFor is the store root a configuration implies. func RootFor(lfsRoot, serverRoot string) string { if lfsRoot != "" { return lfsRoot } return filepath.Join(serverRoot, "lfs") }