internal/httpd/packlimit_test.go

bd5cf5d7d1f34fa780660fd7562b9ffd9746ee27
gitbay/internal/httpd/packlimit_test.go history · blame · raw

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}