internal/httpd/packlimit_test.go
348 lines · 11071 bytes
1package httpd
2
3import (
4 "context"
5 "crypto/rand"
6 "fmt"
7 "net"
8 "net/http"
9 "net/http/httptest"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strconv"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18
19 "gitbay.org/gitbay/internal/config"
20 "gitbay.org/gitbay/internal/control"
21 "gitbay.org/gitbay/internal/gitutil"
22 "gitbay.org/gitbay/internal/packlimit"
23 "gitbay.org/gitbay/internal/store"
24)
25
26func busyServer(t *testing.T) *Server {
27 t.Helper()
28 st, err := store.Open(filepath.Join(t.TempDir(), "gitbay.db"))
29 if err != nil {
30 t.Fatal(err)
31 }
32 t.Cleanup(func() { st.Close() })
33 if err := st.MigrateUp(); err != nil {
34 t.Fatal(err)
35 }
36 uid, err := st.CreateUser("alice", false)
37 if err != nil {
38 t.Fatal(err)
39 }
40 if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
41 t.Fatal(err)
42 }
43 packs := packlimit.New(1, 0, 0, time.Second)
44 hold, err := packs.Acquire(nil, "ip:elsewhere")
45 if err != nil {
46 t.Fatal(err)
47 }
48 t.Cleanup(hold)
49 var cfg config.Config
50 cfg.Server.Root = t.TempDir()
51 return &Server{cfg: cfg, st: st, packs: packs, stopping: make(chan struct{})}
52}
53
54func post(s *Server, body string) *httptest.ResponseRecorder {
55 r := httptest.NewRequest("POST", "/alice/app/git-upload-pack", strings.NewReader(body))
56 r.SetPathValue("owner", "alice")
57 r.SetPathValue("repo", "app")
58 w := httptest.NewRecorder()
59 s.uploadPack(w, r)
60 return w
61}
62
63func TestUploadPackBusyIs503(t *testing.T) {
64 w := post(busyServer(t), "0000")
65 if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
66 t.Fatalf("status %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
67 }
68}
69
70// A protocol v2 ref listing generates no pack and is never queued.
71func TestLsRefsBypassesTheLimit(t *testing.T) {
72 w := post(busyServer(t), "0014command=ls-refs\n0000")
73 if w.Code == http.StatusServiceUnavailable {
74 t.Fatal("ls-refs was held to the pack limit")
75 }
76}
77
78// A request with a valid bearer token counts against the account, the
79// key SSH uses; anything else against the client address.
80func TestPackPrincipal(t *testing.T) {
81 s := busyServer(t)
82 alice, err := s.st.UserByUsername("alice")
83 if err != nil {
84 t.Fatal(err)
85 }
86 if err := s.st.CreateAPIToken(alice.ID, "t", store.HashToken("secret"), "read", nil, 0); err != nil {
87 t.Fatal(err)
88 }
89 r := httptest.NewRequest("POST", "/alice/app/git-upload-pack", nil)
90 r.RemoteAddr = "192.0.2.7:4000"
91 if got := s.packPrincipal(r); got != "ip:192.0.2.7" {
92 t.Fatalf("anonymous: %q", got)
93 }
94 r.Header.Set("Authorization", "Bearer wrong")
95 if got := s.packPrincipal(r); got != "ip:192.0.2.7" {
96 t.Fatalf("bad token: %q", got)
97 }
98 r6 := httptest.NewRequest("POST", "/alice/app/git-upload-pack", nil)
99 r6.RemoteAddr = "[2001:db8:1:2:3:4:5:6]:4000"
100 if got := s.packPrincipal(r6); got != "ip:2001:db8:1:2::/64" {
101 t.Fatalf("anonymous IPv6: %q", got)
102 }
103 want := "user:" + strconv.FormatInt(alice.ID, 10)
104 r.Header.Set("Authorization", "Bearer secret")
105 if got := s.packPrincipal(r); got != want {
106 t.Fatalf("token: %q, want %q", got, want)
107 }
108 if err := s.st.CreateWebSession(store.HashToken("sess"), alice.ID, time.Hour); err != nil {
109 t.Fatal(err)
110 }
111 r = httptest.NewRequest("POST", "/alice/app/git-upload-pack", nil)
112 r.RemoteAddr = "192.0.2.7:4000"
113 r.AddCookie(&http.Cookie{Name: sessionCookie, Value: "sess"})
114 if got := s.packPrincipal(r); got != want {
115 t.Fatalf("session: %q, want %q", got, want)
116 }
117}
118
119// stuckClient is a connection whose client sent the start of a request
120// body and then stopped sending. Reads block until a read deadline is
121// set; the response is discarded.
122type stuckClient struct {
123 header http.Header
124 head string // the part of the body that was sent
125 cut chan struct{}
126 once sync.Once
127}
128
129func (c *stuckClient) Header() http.Header { return c.header }
130func (c *stuckClient) WriteHeader(int) {}
131func (c *stuckClient) Write(b []byte) (int, error) { return len(b), nil }
132func (c *stuckClient) Read(b []byte) (int, error) {
133 if c.head != "" {
134 n := copy(b, c.head)
135 c.head = c.head[n:]
136 return n, nil
137 }
138 <-c.cut
139 return 0, os.ErrDeadlineExceeded
140}
141func (c *stuckClient) Close() error { return nil }
142func (c *stuckClient) SetReadDeadline(time.Time) error {
143 c.once.Do(func() { close(c.cut) })
144 return nil
145}
146
147// limitedServer is a server with a pack limit and an empty alice/app.
148func limitedServer(t *testing.T) *Server {
149 t.Helper()
150 s := busyServer(t)
151 s.packs = packlimit.New(1, 0, 0, time.Second)
152 if err := gitutil.InitBare(control.RepoDir(s.cfg.Server.Root, "alice", "app"), "main", t.TempDir()); err != nil {
153 t.Fatal(err)
154 }
155 return s
156}
157
158// seed commits files of the given sizes, random and so incompressible,
159// to alice/app's main and returns the commit.
160func seed(t *testing.T, s *Server, sizes ...int) string {
161 t.Helper()
162 work := t.TempDir()
163 git := func(args ...string) string {
164 t.Helper()
165 cmd := exec.Command("git", append([]string{"-C", work, "-c", "user.name=t", "-c", "user.email=t@t"}, args...)...)
166 out, err := cmd.CombinedOutput()
167 if err != nil {
168 t.Fatalf("git %v: %v\n%s", args, err, out)
169 }
170 return strings.TrimSpace(string(out))
171 }
172 git("init", "-q", "-b", "main")
173 for i, n := range sizes {
174 b := make([]byte, n)
175 rand.Read(b)
176 if err := os.WriteFile(filepath.Join(work, strconv.Itoa(i)), b, 0o644); err != nil {
177 t.Fatal(err)
178 }
179 }
180 git("add", ".")
181 git("commit", "-q", "-m", "seed")
182 git("push", "-q", control.RepoDir(s.cfg.Server.Root, "alice", "app"), "main")
183 return git("rev-parse", "HEAD")
184}
185
186// fetchBody is a protocol v0 request for sha's whole history.
187func fetchBody(sha string) string {
188 pkt := func(s string) string { return fmt.Sprintf("%04x%s", len(s)+4, s) }
189 return pkt("want "+sha+" side-band-64k ofs-delta\n") + "0000" + pkt("done\n")
190}
191
192// ended requires the handler to finish within limit and its slot to be
193// free.
194func ended(t *testing.T, s *Server, finished <-chan struct{}, limit time.Duration) {
195 t.Helper()
196 select {
197 case <-finished:
198 case <-time.After(limit):
199 t.Fatal("fetch still running")
200 }
201 hold, err := s.packs.Acquire(nil, "ip:elsewhere")
202 if err != nil {
203 t.Fatalf("slot not released after the kill: %v", err)
204 }
205 hold()
206}
207
208func stallAfter(t *testing.T, d time.Duration) {
209 old := packlimit.StallDeadline
210 packlimit.StallDeadline = d
211 t.Cleanup(func() { packlimit.StallDeadline = old })
212}
213
214// A client that stops sending its body is cut after StallDeadline.
215func TestFetchKilledWhenClientStopsSending(t *testing.T) {
216 stallAfter(t, 200*time.Millisecond)
217 s := limitedServer(t)
218 // Half a pkt-line: git waits for the rest.
219 c := &stuckClient{header: http.Header{}, head: "0032want 0123456789abcdef", cut: make(chan struct{})}
220 r := httptest.NewRequest("POST", "/alice/app/git-upload-pack", c)
221 r.SetPathValue("owner", "alice")
222 r.SetPathValue("repo", "app")
223 finished := make(chan struct{})
224 go func() {
225 s.uploadPack(c, r)
226 close(finished)
227 }()
228 ended(t, s, finished, 5*time.Second)
229}
230
231// A client that leaves ends git at once, even while git is busy with
232// neither its input nor its output: here a pack-objects hook that
233// sleeps, with the whole request read and the response discarded.
234func TestFetchKilledWhenClientLeaves(t *testing.T) {
235 s := limitedServer(t)
236 sha := seed(t, s, 10)
237 hook := filepath.Join(t.TempDir(), "hook")
238 if err := os.WriteFile(hook, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil {
239 t.Fatal(err)
240 }
241 t.Setenv("GIT_CONFIG_COUNT", "1")
242 t.Setenv("GIT_CONFIG_KEY_0", "uploadpack.packObjectsHook")
243 t.Setenv("GIT_CONFIG_VALUE_0", hook)
244 ctx, cancel := context.WithCancel(context.Background())
245 defer cancel()
246 r := httptest.NewRequestWithContext(ctx, "POST", "/alice/app/git-upload-pack", strings.NewReader(fetchBody(sha)))
247 r.SetPathValue("owner", "alice")
248 r.SetPathValue("repo", "app")
249 finished := make(chan struct{})
250 go func() {
251 s.uploadPack(httptest.NewRecorder(), r)
252 close(finished)
253 }()
254 time.Sleep(500 * time.Millisecond)
255 cancel()
256 // Unkilled, git runs until the hook's sleep ends.
257 ended(t, s, finished, 5*time.Second)
258}
259
260// A client that stops reading the response is cut after StallDeadline:
261// the write blocked on its full socket fails at the deadline set then.
262func TestFetchKilledWhenClientStopsReading(t *testing.T) {
263 stallAfter(t, 500*time.Millisecond)
264 s := limitedServer(t)
265 sizes := make([]int, 16)
266 for i := range sizes {
267 sizes[i] = 2 << 20
268 }
269 sha := seed(t, s, sizes...)
270 finished := make(chan struct{})
271 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
272 r.SetPathValue("owner", "alice")
273 r.SetPathValue("repo", "app")
274 s.uploadPack(w, r)
275 close(finished)
276 }))
277 defer srv.Close()
278 conn, err := net.Dial("tcp", srv.Listener.Addr().String())
279 if err != nil {
280 t.Fatal(err)
281 }
282 defer conn.Close()
283 conn.(*net.TCPConn).SetReadBuffer(4096)
284 body := fetchBody(sha)
285 fmt.Fprintf(conn, "POST /alice/app/git-upload-pack HTTP/1.1\r\nHost: x\r\nContent-Length: %d\r\n\r\n%s", len(body), body)
286 // The 32MB pack cannot fit in the socket buffers; nothing is read.
287 ended(t, s, finished, 20*time.Second)
288}
289
290func getArchive(s *Server, w http.ResponseWriter, r *http.Request) {
291 r.SetPathValue("owner", "alice")
292 r.SetPathValue("repo", "app")
293 r.SetPathValue("file", "main.tar.gz")
294 s.archive(w, r)
295}
296
297// A web archive takes a pack slot: busy is 503, and the slot is free
298// again once the archive is written.
299func TestArchiveTakesASlot(t *testing.T) {
300 s := limitedServer(t)
301 seed(t, s, 10)
302 hold, err := s.packs.Acquire(nil, "ip:elsewhere")
303 if err != nil {
304 t.Fatal(err)
305 }
306 w := httptest.NewRecorder()
307 getArchive(s, w, httptest.NewRequest("GET", "/alice/app/archive/main.tar.gz", nil))
308 if w.Code != http.StatusServiceUnavailable || w.Header().Get("Retry-After") == "" {
309 t.Fatalf("busy: status %d, Retry-After %q", w.Code, w.Header().Get("Retry-After"))
310 }
311 hold()
312 w = httptest.NewRecorder()
313 getArchive(s, w, httptest.NewRequest("GET", "/alice/app/archive/main.tar.gz", nil))
314 if w.Code != http.StatusOK || w.Header().Get("Content-Type") != "application/gzip" || w.Body.Len() == 0 {
315 t.Fatalf("free: status %d, type %q, %d bytes", w.Code, w.Header().Get("Content-Type"), w.Body.Len())
316 }
317 hold, err = s.packs.Acquire(nil, "ip:elsewhere")
318 if err != nil {
319 t.Fatalf("slot not released after the archive: %v", err)
320 }
321 hold()
322}
323
324// An archive whose client stops reading is cut after StallDeadline.
325func TestArchiveKilledWhenClientStopsReading(t *testing.T) {
326 stallAfter(t, 500*time.Millisecond)
327 s := limitedServer(t)
328 sizes := make([]int, 16)
329 for i := range sizes {
330 sizes[i] = 2 << 20
331 }
332 seed(t, s, sizes...)
333 finished := make(chan struct{})
334 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
335 getArchive(s, w, r)
336 close(finished)
337 }))
338 defer srv.Close()
339 conn, err := net.Dial("tcp", srv.Listener.Addr().String())
340 if err != nil {
341 t.Fatal(err)
342 }
343 defer conn.Close()
344 conn.(*net.TCPConn).SetReadBuffer(4096)
345 fmt.Fprintf(conn, "GET /alice/app/archive/main.tar.gz HTTP/1.1\r\nHost: x\r\n\r\n")
346 // The 32MB archive cannot fit in the socket buffers; nothing is read.
347 ended(t, s, finished, 20*time.Second)
348}