internal/lfs/lfs.go
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}