git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / internal / server / clone_push_test.go /
internal/server/clone_push_test.go · 10.8 KiB · raw
view: SourceMarkdown ☑ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
package server

import (
	"bytes"
	"crypto/rand"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	"gitweb/internal/store"
)

// gitEnv returns a hermetic environment for invoking git in tests: no prompts,
// no system/global config, no credential helpers, and an isolated HOME.
func gitEnv(t *testing.T) []string {
	return append(os.Environ(),
		"HOME="+t.TempDir(),
		"GIT_TERMINAL_PROMPT=0",
		"GIT_CONFIG_NOSYSTEM=1",
		"GIT_ASKPASS=true",
		"GIT_AUTHOR_NAME=T", "GIT_AUTHOR_EMAIL=t@e.com",
		"GIT_COMMITTER_NAME=T", "GIT_COMMITTER_EMAIL=t@e.com",
	)
}

func runGit(t *testing.T, env []string, dir string, args ...string) (string, error) {
	t.Helper()
	c := exec.Command("git", args...)
	c.Dir = dir
	c.Env = env
	out, err := c.CombinedOutput()
	return string(out), err
}

func mustGit(t *testing.T, env []string, dir string, args ...string) string {
	t.Helper()
	out, err := runGit(t, env, dir, args...)
	if err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
	return out
}

// makeBareRepo builds a bare repo at ns/<repoPath> with one commit, and writes
// an Access file with the given content beside it.
func makeBareRepo(t *testing.T, env []string, ns, repoPath, access string) {
	t.Helper()
	work := t.TempDir()
	mustGit(t, env, work, "init", "-q", "-b", "main")
	if err := os.WriteFile(filepath.Join(work, "README.md"), []byte("# hello\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	mustGit(t, env, work, "add", "-A")
	mustGit(t, env, work, "commit", "-qm", "initial")

	dst := filepath.Join(ns, filepath.FromSlash(repoPath))
	if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
		t.Fatal(err)
	}
	mustGit(t, env, work, "clone", "-q", "--bare", work, dst)
	if err := os.WriteFile(filepath.Join(dst, "Access"), []byte(access), 0o644); err != nil {
		t.Fatal(err)
	}
}

// writeTokens writes a gitweb tokens file granting the given plaintext tokens to
// user (by storing their SHA-256 hashes).
func writeTokens(t *testing.T, ns, user string, tokens ...string) {
	t.Helper()
	dir := filepath.Join(ns, user, "gitweb")
	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	var b strings.Builder
	for _, tok := range tokens {
		h := sha256.Sum256([]byte(tok))
		fmt.Fprintf(&b, "%s token\n", hex.EncodeToString(h[:]))
	}
	if err := os.WriteFile(filepath.Join(dir, "tokens"), []byte(b.String()), 0o644); err != nil {
		t.Fatal(err)
	}
}

// cloneURL builds an http clone URL, optionally embedding Basic-auth creds.
func cloneURL(base, repoPath, user, token string) string {
	u, _ := url.Parse(base)
	if user != "" {
		u.User = url.UserPassword(user, token)
	}
	u.Path = "/" + repoPath
	return u.String()
}

func TestCloneAndPush(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git not available")
	}
	env := gitEnv(t)
	ns := t.TempDir()

	const owner = "u@d.com"
	const writer = "w@d.com"
	const reader = "r@d.com"

	makeBareRepo(t, env, ns, owner+"/public.git", "read,list: all\n*: "+owner+"\n")
	makeBareRepo(t, env, ns, owner+"/private.git",
		"*: "+owner+"\nread,list,write,create,delete: "+writer+"\nread,list: "+reader+"\n")
	writeTokens(t, ns, writer, "writer-token")
	writeTokens(t, ns, reader, "reader-token")

	web := os.DirFS("../../web")
	cacheDir := t.TempDir()
	srv, err := New(Config{Store: store.NewLocalStore(ns), Web: http.FS(web), CacheDir: cacheDir})
	if err != nil {
		t.Fatal(err)
	}
	ts := httptest.NewServer(srv.Handler())
	defer ts.Close()

	t.Run("anon clone public", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "pub")
		if out, err := runGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, owner+"/public.git", "", ""), dst); err != nil {
			t.Fatalf("clone public failed: %v\n%s", err, out)
		}
		if _, err := os.Stat(filepath.Join(dst, "README.md")); err != nil {
			t.Fatalf("cloned repo missing README: %v", err)
		}
	})

	t.Run("anon clone private denied", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "priv")
		if out, err := runGit(t, env, ".", "clone", "-q", cloneURL(ts.URL, owner+"/private.git", "", ""), dst); err == nil {
			t.Fatalf("anonymous clone of private repo succeeded, want failure\n%s", out)
		}
	})

	t.Run("authed clone private", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "priv-ok")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token")
		if out, err := runGit(t, env, ".", "clone", "-q", u, dst); err != nil {
			t.Fatalf("authed clone failed: %v\n%s", err, out)
		}
	})

	t.Run("wrong token denied", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "priv-bad")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "nope")
		if out, err := runGit(t, env, ".", "clone", "-q", u, dst); err == nil {
			t.Fatalf("clone with wrong token succeeded, want failure\n%s", out)
		}
	})

	t.Run("push as writer", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "push")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		if err := os.WriteFile(filepath.Join(dst, "new.txt"), []byte("added\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "add new.txt")
		if out, err := runGit(t, env, dst, "push", "-q", "origin", "main"); err != nil {
			t.Fatalf("push failed: %v\n%s", err, out)
		}
		// A fresh clone must see the pushed commit.
		fresh := filepath.Join(t.TempDir(), "fresh")
		mustGit(t, env, ".", "clone", "-q", u, fresh)
		if _, err := os.Stat(filepath.Join(fresh, "new.txt")); err != nil {
			t.Fatalf("pushed file not visible after re-clone: %v", err)
		}
	})

	t.Run("incremental push (thin pack)", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "thin")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		// A file large enough that git deltas the second version against the
		// first. On the second push that base blob is not in the pack (the
		// server already has it), so git sends a thin pack with a REF_DELTA —
		// the case that used to fail with "reference delta not found".
		lines := strings.Repeat("a line of text that compresses and deltas well\n", 200)
		if err := os.WriteFile(filepath.Join(dst, "big.txt"), []byte(lines), 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "add big.txt")
		mustGit(t, env, dst, "push", "-q", "origin", "main")

		if err := os.WriteFile(filepath.Join(dst, "big.txt"), []byte(lines+"one changed line\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "append to big.txt")
		if out, err := runGit(t, env, dst, "push", "origin", "main"); err != nil {
			t.Fatalf("incremental push failed: %v\n%s", err, out)
		}

		fresh := filepath.Join(t.TempDir(), "fresh-thin")
		mustGit(t, env, ".", "clone", "-q", u, fresh)
		got, err := os.ReadFile(filepath.Join(fresh, "big.txt"))
		if err != nil {
			t.Fatal(err)
		}
		if string(got) != lines+"one changed line\n" {
			t.Error("re-clone does not have the incrementally pushed content")
		}
	})

	t.Run("push branch create and delete", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "branch")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		mustGit(t, env, dst, "push", "-q", "origin", "main:refs/heads/scratch")
		// Deleting a ref sends no packfile at all; the server must not choke
		// on the empty pack stream.
		if out, err := runGit(t, env, dst, "push", "origin", ":refs/heads/scratch"); err != nil {
			t.Fatalf("delete push failed: %v\n%s", err, out)
		}
		refs := mustGit(t, env, dst, "ls-remote", "origin")
		if strings.Contains(refs, "scratch") {
			t.Errorf("scratch branch still on remote:\n%s", refs)
		}
	})

	// Over http.postBuffer, git first sends a probe POST whose body is just a
	// flush-pkt ("0000") and only streams the real (chunked) pack after the
	// probe gets a 200. Answering the probe with 400 used to fail every push
	// over ~1 MiB (docs/FR-large-push-400.md).
	t.Run("large push over http.postBuffer", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "large")
		u := cloneURL(ts.URL, owner+"/private.git", writer, "writer-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		payload := make([]byte, 3<<20) // incompressible, so the wire size stays > 1 MiB
		if _, err := rand.Read(payload); err != nil {
			t.Fatal(err)
		}
		if err := os.WriteFile(filepath.Join(dst, "payload.bin"), payload, 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "add 3 MiB payload")
		// Pin git's default postBuffer (1 MiB) so the test doesn't depend on
		// the environment.
		if out, err := runGit(t, env, dst, "-c", "http.postBuffer=1048576", "push", "origin", "main"); err != nil {
			t.Fatalf("large push failed: %v\n%s", err, out)
		}
		fresh := filepath.Join(t.TempDir(), "fresh-large")
		mustGit(t, env, ".", "clone", "-q", u, fresh)
		got, err := os.ReadFile(filepath.Join(fresh, "payload.bin"))
		if err != nil || !bytes.Equal(got, payload) {
			t.Fatalf("re-clone lacks the large payload (err=%v, %d bytes)", err, len(got))
		}
	})

	t.Run("large push as reader denied", func(t *testing.T) {
		// The probe must still be authorized: a reader gets refused at the
		// probe, before any pack is sent.
		dst := filepath.Join(t.TempDir(), "large-ro")
		u := cloneURL(ts.URL, owner+"/private.git", reader, "reader-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		payload := make([]byte, 2<<20)
		rand.Read(payload)
		if err := os.WriteFile(filepath.Join(dst, "ro.bin"), payload, 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "reader large")
		if out, err := runGit(t, env, dst, "-c", "http.postBuffer=1048576", "push", "origin", "main"); err == nil {
			t.Fatalf("reader large push succeeded, want failure\n%s", out)
		}
	})

	t.Run("push copies cleaned up", func(t *testing.T) {
		// Every push materializes a private push-* dir under the cache root and
		// must remove it when done.
		children, err := os.ReadDir(cacheDir)
		if err != nil {
			t.Fatal(err)
		}
		for _, ch := range children {
			if strings.HasPrefix(ch.Name(), "push-") {
				t.Errorf("leftover push dir %s in cache root", ch.Name())
			}
		}
	})

	t.Run("push as reader denied", func(t *testing.T) {
		dst := filepath.Join(t.TempDir(), "push-ro")
		u := cloneURL(ts.URL, owner+"/private.git", reader, "reader-token")
		mustGit(t, env, ".", "clone", "-q", u, dst)
		if err := os.WriteFile(filepath.Join(dst, "sneaky.txt"), []byte("x\n"), 0o644); err != nil {
			t.Fatal(err)
		}
		mustGit(t, env, dst, "add", "-A")
		mustGit(t, env, dst, "commit", "-qm", "sneak")
		if out, err := runGit(t, env, dst, "push", "-q", "origin", "main"); err == nil {
			t.Fatalf("reader push succeeded, want 403 failure\n%s", out)
		}
	})
}