// 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, and // bound to the SSH key that obtained them, which must still be live // when the token is used (#285). 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, bound // to keyID: the SSH key, user or deploy, that asked for it, or 0 for an // anonymous download of a public repository. fingerprint is that key's // fingerprint, "" for key 0. SQLite reuses the id of a deleted key, so // the token carries a hash of the fingerprint as well and a new key // given the old id does not inherit the old key's tokens (#303). func Sign(secret []byte, repoID, keyID int64, fingerprint, op string, now time.Time) string { payload := fmt.Sprintf("%d:%d:%s:%s:%d", repoID, keyID, KeyPin(fingerprint), 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)) } // KeyPin is the fingerprint's form in a token: the first 16 hex // characters of its SHA-256, or "" for no key. func KeyPin(fingerprint string) string { if fingerprint == "" { return "" } sum := sha256.Sum256([]byte(fingerprint)) return hex.EncodeToString(sum[:8]) } // Grant is what a verified token authorizes. type Grant struct { RepoID int64 KeyID int64 // 0: an anonymous download of a public repository KeyPin string // KeyPin of the key's fingerprint; "" when KeyID is 0 Op string } // Verify checks a token's MAC, shape and expiry. A token from before // tokens named their key, or before they carried its fingerprint, does // not verify. func Verify(secret []byte, token string, now time.Time) (Grant, bool) { payloadB64, macB64, found := strings.Cut(token, ".") if !found { return Grant{}, false } payload, err := base64.RawURLEncoding.DecodeString(payloadB64) if err != nil { return Grant{}, false } gotMAC, err := base64.RawURLEncoding.DecodeString(macB64) if err != nil { return Grant{}, false } mac := hmac.New(sha256.New, secret) mac.Write(payload) if !hmac.Equal(mac.Sum(nil), gotMAC) { return Grant{}, false } parts := strings.Split(string(payload), ":") if len(parts) != 5 { return Grant{}, false } repoID, err1 := strconv.ParseInt(parts[0], 10, 64) keyID, err2 := strconv.ParseInt(parts[1], 10, 64) exp, err3 := strconv.ParseInt(parts[4], 10, 64) if err1 != nil || err2 != nil || err3 != nil || keyID < 0 || now.Unix() > exp { return Grant{}, false } if (keyID == 0) != (parts[2] == "") { return Grant{}, false } if parts[3] != "download" && parts[3] != "upload" { return Grant{}, false } return Grant{RepoID: repoID, KeyID: keyID, KeyPin: parts[2], Op: parts[3]}, 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") }