cmd/gitbayd/backup_test.go

v1.41.0
gitbay/cmd/gitbayd/backup_test.go history · blame · raw

917 lines · 26963 bytes

  1package main
  2
  3import (
  4	"archive/tar"
  5	"compress/gzip"
  6	"crypto/sha256"
  7	"encoding/hex"
  8	"errors"
  9	"io"
 10	"io/fs"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"sort"
 15	"strconv"
 16	"strings"
 17	"testing"
 18	"time"
 19
 20	"filippo.io/age"
 21
 22	"gitbay.org/gitbay/internal/backuplock"
 23	"gitbay.org/gitbay/internal/config"
 24	"gitbay.org/gitbay/internal/gitutil"
 25)
 26
 27// members lists the archive's entries by name.
 28func members(t *testing.T, path string) []string {
 29	t.Helper()
 30	f, err := os.Open(path)
 31	if err != nil {
 32		t.Fatal(err)
 33	}
 34	defer f.Close()
 35	gz, err := gzip.NewReader(f)
 36	if err != nil {
 37		t.Fatal(err)
 38	}
 39	var names []string
 40	tr := tar.NewReader(gz)
 41	for {
 42		hdr, err := tr.Next()
 43		if err == io.EOF {
 44			break
 45		}
 46		if err != nil {
 47			t.Fatal(err)
 48		}
 49		names = append(names, hdr.Name)
 50	}
 51	sort.Strings(names)
 52	return names
 53}
 54
 55// --db-only is what makes an hourly schedule affordable, so it has to leave
 56// the repositories out and still carry a restorable database.
 57func TestBackupDBOnlyOmitsRepositories(t *testing.T) {
 58	cfg := testConfig(t)
 59	root := cfg.Server.Root
 60	s, err := openStore(cfg)
 61	if err != nil {
 62		t.Fatal(err)
 63	}
 64	s.Close()
 65
 66	repo := filepath.Join(root, "repos", "krz", "thing.git")
 67	if err := os.MkdirAll(repo, 0o750); err != nil {
 68		t.Fatal(err)
 69	}
 70	if err := os.WriteFile(filepath.Join(repo, "HEAD"), []byte("ref: refs/heads/main\n"), 0o640); err != nil {
 71		t.Fatal(err)
 72	}
 73
 74	full := filepath.Join(t.TempDir(), "full.tar.gz")
 75	if err := runBackup(cfg, full, false); err != nil {
 76		t.Fatalf("full backup: %v", err)
 77	}
 78	dbOnly := filepath.Join(t.TempDir(), "db.tar.gz")
 79	if err := runBackup(cfg, dbOnly, true); err != nil {
 80		t.Fatalf("db-only backup: %v", err)
 81	}
 82
 83	fullNames := members(t, full)
 84	if len(fullNames) < 2 {
 85		t.Fatalf("full backup carries only %v", fullNames)
 86	}
 87	var sawRepo bool
 88	for _, n := range fullNames {
 89		if n == "repos/krz/thing.git/HEAD" {
 90			sawRepo = true
 91		}
 92	}
 93	if !sawRepo {
 94		t.Errorf("full backup is missing the repository: %v", fullNames)
 95	}
 96
 97	if got := members(t, dbOnly); len(got) != 1 || got[0] != "gitbay.db" {
 98		t.Errorf("db-only backup carries %v, want [gitbay.db]", got)
 99	}
100
101	fi, err := os.Stat(dbOnly)
102	if err != nil {
103		t.Fatal(err)
104	}
105	if fi.Size() == 0 {
106		t.Error("db-only backup is empty")
107	}
108}
109
110func TestBackupEncryptedToAgeRecipient(t *testing.T) {
111	cfg := testConfig(t)
112	id, err := age.GenerateX25519Identity()
113	if err != nil {
114		t.Fatal(err)
115	}
116	cfg.Backup.AgeRecipients = []string{id.Recipient().String()}
117	s, err := openStore(cfg)
118	if err != nil {
119		t.Fatal(err)
120	}
121	s.Close()
122
123	out := filepath.Join(t.TempDir(), "b.tar.gz.age")
124	if err := runBackup(cfg, out, true); err != nil {
125		t.Fatal(err)
126	}
127	head := make([]byte, 22)
128	f, err := os.Open(out)
129	if err != nil {
130		t.Fatal(err)
131	}
132	_, err = io.ReadFull(f, head)
133	f.Close()
134	if err != nil {
135		t.Fatal(err)
136	}
137	if string(head) != "age-encryption.org/v1\n" {
138		t.Fatalf("archive is not age-encrypted: %q", head)
139	}
140
141	if err := verifyBackup(out, ""); err == nil || !strings.Contains(err.Error(), "--identity") {
142		t.Fatalf("verify without an identity: %v", err)
143	}
144	idFile := filepath.Join(t.TempDir(), "backup-identity.txt")
145	if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil {
146		t.Fatal(err)
147	}
148	if err := verifyBackup(out, idFile); err != nil {
149		t.Fatalf("verify with the identity: %v", err)
150	}
151	other, err := age.GenerateX25519Identity()
152	if err != nil {
153		t.Fatal(err)
154	}
155	otherFile := filepath.Join(t.TempDir(), "other.txt")
156	if err := os.WriteFile(otherFile, []byte(other.String()+"\n"), 0o600); err != nil {
157		t.Fatal(err)
158	}
159	var noMatch *age.NoIdentityMatchError
160	if err := verifyBackup(out, otherFile); !errors.As(err, &noMatch) {
161		t.Fatalf("verify with another identity: %v, want a no-identity-match error", err)
162	}
163}
164
165// leftovers lists what a backup run left in dir besides the archive.
166func leftovers(t *testing.T, dir string) []string {
167	t.Helper()
168	ents, err := os.ReadDir(dir)
169	if err != nil {
170		t.Fatal(err)
171	}
172	var names []string
173	for _, e := range ents {
174		if strings.HasPrefix(e.Name(), ".") {
175			names = append(names, e.Name())
176		}
177	}
178	return names
179}
180
181// The snapshot directory and the archive's temporary file are removed
182// whether the run succeeds or fails, and a failed run leaves no archive.
183func TestBackupLeavesNoTemporaries(t *testing.T) {
184	cfg := testConfig(t)
185	id, err := age.GenerateX25519Identity()
186	if err != nil {
187		t.Fatal(err)
188	}
189	cfg.Backup.AgeRecipients = []string{id.Recipient().String()}
190	s, err := openStore(cfg)
191	if err != nil {
192		t.Fatal(err)
193	}
194	s.Close()
195
196	dir := t.TempDir()
197	out := filepath.Join(dir, "ok.tar.gz.age")
198	if err := runBackup(cfg, out, false); err != nil {
199		t.Fatal(err)
200	}
201	if got := leftovers(t, dir); len(got) != 0 {
202		t.Errorf("after a successful run: %v", got)
203	}
204	fi, err := os.Stat(out)
205	if err != nil {
206		t.Fatal(err)
207	}
208	if fi.Mode().Perm() != 0o600 {
209		t.Errorf("archive mode %v, want 0600", fi.Mode().Perm())
210	}
211
212	// A file the walk cannot read fails the run after the snapshot and
213	// the temporary archive exist. Root reads a mode-0 file, so the case
214	// needs an unprivileged user.
215	if os.Geteuid() == 0 {
216		t.Log("running as root: skipping the mid-walk failure case")
217	} else {
218		unreadable := filepath.Join(cfg.Server.Root, "unreadable")
219		if err := os.WriteFile(unreadable, []byte("x"), 0o000); err != nil {
220			t.Fatal(err)
221		}
222		failed := filepath.Join(dir, "failed.tar.gz.age")
223		err := runBackup(cfg, failed, false)
224		os.Remove(unreadable)
225		if err == nil {
226			t.Fatal("backup with an unreadable file succeeded")
227		}
228		if _, err := os.Stat(failed); !os.IsNotExist(err) {
229			t.Errorf("failed run left an archive: %v", err)
230		}
231		if got := leftovers(t, dir); len(got) != 0 {
232			t.Errorf("after a failed run: %v", got)
233		}
234	}
235
236	bad := cfg
237	bad.Backup.AgeRecipients = []string{"age1x"}
238	if err := runBackup(bad, filepath.Join(dir, "bad.tar.gz.age"), true); err == nil {
239		t.Fatal("backup with a bad recipient succeeded")
240	}
241	if got := leftovers(t, dir); len(got) != 0 {
242		t.Errorf("after a bad recipient: %v", got)
243	}
244}
245
246func TestBackupRefusesAgeNameWithoutRecipients(t *testing.T) {
247	cfg := testConfig(t)
248	out := filepath.Join(t.TempDir(), "b.tar.gz.age")
249	err := runBackup(cfg, out, true)
250	if err == nil || !strings.Contains(err.Error(), "age_recipients") {
251		t.Fatalf("got %v, want a refusal naming age_recipients", err)
252	}
253}
254
255// A truncated archive fails verification even when the tar stream's end
256// markers survive: gzip's trailer and age's final chunk are checked.
257func TestVerifyRejectsTruncatedArchive(t *testing.T) {
258	cfg := testConfig(t)
259	s, err := openStore(cfg)
260	if err != nil {
261		t.Fatal(err)
262	}
263	s.Close()
264	dir := t.TempDir()
265	plain := filepath.Join(dir, "p.tar.gz")
266	if err := runBackup(cfg, plain, true); err != nil {
267		t.Fatal(err)
268	}
269	id, err := age.GenerateX25519Identity()
270	if err != nil {
271		t.Fatal(err)
272	}
273	enc := cfg
274	enc.Backup.AgeRecipients = []string{id.Recipient().String()}
275	sealed := filepath.Join(dir, "e.tar.gz.age")
276	if err := runBackup(enc, sealed, true); err != nil {
277		t.Fatal(err)
278	}
279	idFile := filepath.Join(dir, "id.txt")
280	if err := os.WriteFile(idFile, []byte(id.String()+"\n"), 0o600); err != nil {
281		t.Fatal(err)
282	}
283	if err := verifyBackup(plain, ""); err != nil {
284		t.Fatalf("intact plain archive: %v", err)
285	}
286	if err := verifyBackup(sealed, idFile); err != nil {
287		t.Fatalf("intact encrypted archive: %v", err)
288	}
289
290	for _, c := range []struct {
291		src      string
292		cut      int
293		identity string
294	}{
295		{plain, 1, ""},
296		{sealed, 1, idFile},
297		{sealed, 100, idFile},
298	} {
299		data, err := os.ReadFile(c.src)
300		if err != nil {
301			t.Fatal(err)
302		}
303		short := filepath.Join(dir, "short-"+filepath.Base(c.src))
304		if err := os.WriteFile(short, data[:len(data)-c.cut], 0o600); err != nil {
305			t.Fatal(err)
306		}
307		if err := verifyBackup(short, c.identity); err == nil {
308			t.Errorf("%s cut by %d bytes verified", filepath.Base(c.src), c.cut)
309		}
310	}
311}
312
313func TestArchivePath(t *testing.T) {
314	now := time.Date(2026, 9, 27, 9, 0, 0, 0, time.UTC)
315	plain := testConfig(t)
316	enc := plain
317	enc.Backup.AgeRecipients = []string{"age1x"}
318	for _, c := range []struct {
319		out  string
320		cfg  config.Config
321		want string
322	}{
323		{"", plain, "gitbay-backup-20260927-090000.tar.gz"},
324		{"", enc, "gitbay-backup-20260927-090000.tar.gz.age"},
325		{"/b/x.tar.gz", enc, "/b/x.tar.gz.age"},
326		{"/b/x.tar.gz.age", enc, "/b/x.tar.gz.age"},
327		{"/b/x.tar.gz", plain, "/b/x.tar.gz"},
328	} {
329		if got := archivePath(c.out, c.cfg, now); got != c.want {
330			t.Errorf("archivePath(%q) = %q, want %q", c.out, got, c.want)
331		}
332	}
333}
334
335func gitIn(t *testing.T, dir string, args ...string) string {
336	t.Helper()
337	cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
338	cmd.Env = append(os.Environ(),
339		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@e",
340		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@e")
341	out, err := cmd.CombinedOutput()
342	if err != nil {
343		t.Fatalf("git %v: %v\n%s", args, err, out)
344	}
345	return strings.TrimSpace(string(out))
346}
347
348// verify runs git's connectivity check on every repository the
349// database names: a repository missing an object fails it.
350func TestVerifyChecksConnectivity(t *testing.T) {
351	cfg := testConfig(t)
352	st, err := openStore(cfg)
353	if err != nil {
354		t.Fatal(err)
355	}
356	uid, err := st.CreateUser("krz", false)
357	if err != nil {
358		t.Fatal(err)
359	}
360	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
361		t.Fatal(err)
362	}
363	st.Close()
364
365	work := t.TempDir()
366	gitIn(t, work, "init", "-q", "-b", "main")
367	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
368		t.Fatal(err)
369	}
370	gitIn(t, work, "add", "a.txt")
371	gitIn(t, work, "commit", "-q", "-m", "one")
372	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
373	gitIn(t, work, "clone", "-q", "--bare", work, dir)
374
375	good := filepath.Join(t.TempDir(), "good.tar.gz")
376	if err := runBackup(cfg, good, false); err != nil {
377		t.Fatal(err)
378	}
379	if err := verifyBackup(good, ""); err != nil {
380		t.Fatalf("intact archive: %v", err)
381	}
382
383	blob := gitIn(t, dir, "rev-parse", "HEAD:a.txt")
384	if err := os.Remove(filepath.Join(dir, "objects", blob[:2], blob[2:])); err != nil {
385		t.Fatal(err)
386	}
387	bad := filepath.Join(t.TempDir(), "bad.tar.gz")
388	if err := runBackup(cfg, bad, false); err != nil {
389		t.Fatal(err)
390	}
391	err = verifyBackup(bad, "")
392	if err == nil || !strings.Contains(err.Error(), "krz/thing") || !strings.Contains(err.Error(), "connectivity") {
393		t.Fatalf("archive with a missing blob: %v", err)
394	}
395}
396
397// A full backup waits for a delete under way, and does not archive its
398// own lock file.
399func TestFullBackupWaitsForRepositoryMoves(t *testing.T) {
400	cfg := testConfig(t)
401	s, err := openStore(cfg)
402	if err != nil {
403		t.Fatal(err)
404	}
405	s.Close()
406	inFlight, err := backuplock.TryShared(cfg.Server.Root)
407	if err != nil {
408		t.Fatal(err)
409	}
410	out := filepath.Join(t.TempDir(), "b.tar.gz")
411	done := make(chan error, 1)
412	go func() { done <- runBackup(cfg, out, false) }()
413	select {
414	case err := <-done:
415		t.Fatalf("backup finished while a delete held the lock: %v", err)
416	case <-time.After(200 * time.Millisecond):
417	}
418	inFlight()
419	select {
420	case err := <-done:
421		if err != nil {
422			t.Fatal(err)
423		}
424	case <-time.After(10 * time.Second):
425		t.Fatal("backup never started after the delete finished")
426	}
427	for _, n := range members(t, out) {
428		if n == backuplock.Name {
429			t.Fatalf("archive carries %s", n)
430		}
431	}
432}
433
434// A repository with every ref packed keeps its empty refs/heads and
435// refs/tags directories through backup and extraction, the same as a real
436// restore would: git needs refs/ to recognize a bare repository at all,
437// even when every ref lives in packed-refs (#259).
438func TestBackupPreservesPackedRefDirs(t *testing.T) {
439	cfg := testConfig(t)
440	st, err := openStore(cfg)
441	if err != nil {
442		t.Fatal(err)
443	}
444	uid, err := st.CreateUser("krz", false)
445	if err != nil {
446		t.Fatal(err)
447	}
448	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
449		t.Fatal(err)
450	}
451	st.Close()
452
453	work := t.TempDir()
454	gitIn(t, work, "init", "-q", "-b", "main")
455	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
456		t.Fatal(err)
457	}
458	gitIn(t, work, "add", "a.txt")
459	gitIn(t, work, "commit", "-q", "-m", "one")
460	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
461	gitIn(t, work, "clone", "-q", "--bare", work, dir)
462	gitIn(t, dir, "pack-refs", "--all")
463	entries, err := os.ReadDir(filepath.Join(dir, "refs", "heads"))
464	if err != nil {
465		t.Fatal(err)
466	}
467	if len(entries) != 0 {
468		t.Fatalf("refs/heads not empty after pack-refs --all: %v", entries)
469	}
470
471	archive := filepath.Join(t.TempDir(), "b.tar.gz")
472	if err := runBackup(cfg, archive, false); err != nil {
473		t.Fatal(err)
474	}
475
476	// verify sees the archive exactly as a restore would: no workaround.
477	if err := verifyBackup(archive, ""); err != nil {
478		t.Fatalf("verify: %v", err)
479	}
480
481	restored := t.TempDir()
482	if out, err := exec.Command("tar", "-xzf", archive, "-C", restored).CombinedOutput(); err != nil {
483		t.Fatalf("extract: %v\n%s", err, out)
484	}
485	restoredRepo := filepath.Join(restored, "repos", "krz", "thing.git")
486	if got := gitIn(t, restoredRepo, "rev-parse", "--verify", "HEAD"); got == "" {
487		t.Fatal("rev-parse --verify HEAD returned nothing after restore")
488	}
489	gitIn(t, restoredRepo, "fsck", "--connectivity-only", "--no-progress", "--no-dangling")
490}
491
492// A commit pushed after a repository's refs are archived and before its
493// objects are leaves the archive with the earlier refs and every object
494// they reach, plus the new ones unreferenced (#259).
495func TestBackupArchivesRefsBeforeObjects(t *testing.T) {
496	cfg := testConfig(t)
497	st, err := openStore(cfg)
498	if err != nil {
499		t.Fatal(err)
500	}
501	uid, err := st.CreateUser("krz", false)
502	if err != nil {
503		t.Fatal(err)
504	}
505	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
506		t.Fatal(err)
507	}
508	st.Close()
509
510	work := t.TempDir()
511	gitIn(t, work, "init", "-q", "-b", "main")
512	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
513		t.Fatal(err)
514	}
515	gitIn(t, work, "add", "a.txt")
516	gitIn(t, work, "commit", "-q", "-m", "one")
517	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
518	gitIn(t, work, "clone", "-q", "--bare", work, dir)
519	first := gitIn(t, dir, "rev-parse", "refs/heads/main")
520
521	var second string
522	afterRefs = func(repo string) {
523		if repo != dir {
524			return
525		}
526		if err := os.WriteFile(filepath.Join(work, "b.txt"), []byte("b\n"), 0o644); err != nil {
527			t.Fatal(err)
528		}
529		gitIn(t, work, "add", "b.txt")
530		gitIn(t, work, "commit", "-q", "-m", "two")
531		gitIn(t, work, "push", "-q", dir, "main")
532		second = gitIn(t, dir, "rev-parse", "refs/heads/main")
533	}
534	t.Cleanup(func() { afterRefs = func(string) {} })
535
536	archive := filepath.Join(t.TempDir(), "b.tar.gz")
537	if err := runBackup(cfg, archive, false); err != nil {
538		t.Fatal(err)
539	}
540	if second == "" || second == first {
541		t.Fatal("the push between the refs and the objects did not happen")
542	}
543	if err := verifyBackup(archive, ""); err != nil {
544		t.Fatalf("verify: %v", err)
545	}
546	restored := t.TempDir()
547	if out, err := exec.Command("tar", "-xzf", archive, "-C", restored).CombinedOutput(); err != nil {
548		t.Fatalf("extract: %v\n%s", err, out)
549	}
550	repo := filepath.Join(restored, "repos", "krz", "thing.git")
551	if got := gitIn(t, repo, "rev-parse", "refs/heads/main"); got != first {
552		t.Errorf("archived main is %s, want %s from before the push", got, first)
553	}
554	gitIn(t, repo, "cat-file", "-e", second)
555}
556
557// A pack removed between the walk listing it and reading it is skipped,
558// and verify's fsck then reports what it held; a vanished file outside
559// objects/ still fails the backup.
560func TestBackupSkipsVanishedObjects(t *testing.T) {
561	cfg := testConfig(t)
562	st, err := openStore(cfg)
563	if err != nil {
564		t.Fatal(err)
565	}
566	uid, err := st.CreateUser("krz", false)
567	if err != nil {
568		t.Fatal(err)
569	}
570	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
571		t.Fatal(err)
572	}
573	st.Close()
574
575	work := t.TempDir()
576	gitIn(t, work, "init", "-q", "-b", "main")
577	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
578		t.Fatal(err)
579	}
580	gitIn(t, work, "add", "a.txt")
581	gitIn(t, work, "commit", "-q", "-m", "one")
582	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
583	gitIn(t, work, "clone", "-q", "--bare", work, dir)
584	gitIn(t, dir, "repack", "-q", "-a", "-d")
585
586	removed := ""
587	beforeAdd = func(path string) {
588		if removed == "" && strings.HasSuffix(path, ".pack") {
589			removed = path
590			os.Remove(path)
591		}
592	}
593	t.Cleanup(func() { beforeAdd = func(string) {} })
594	archive := filepath.Join(t.TempDir(), "b.tar.gz")
595	if err := runBackup(cfg, archive, false); err != nil {
596		t.Fatalf("backup with a vanished pack: %v", err)
597	}
598	if removed == "" {
599		t.Fatal("no pack was archived")
600	}
601	for _, n := range members(t, archive) {
602		if strings.HasSuffix(n, ".pack") {
603			t.Errorf("archive carries %s", n)
604		}
605	}
606	if err := verifyBackup(archive, ""); err == nil || !strings.Contains(err.Error(), "connectivity") {
607		t.Errorf("verify of an archive missing its pack: %v", err)
608	}
609
610	beforeAdd = func(path string) {
611		if strings.HasSuffix(path, filepath.Join("thing.git", "config")) {
612			os.Remove(path)
613		}
614	}
615	err = runBackup(cfg, filepath.Join(t.TempDir(), "c.tar.gz"), false)
616	if !errors.Is(err, fs.ErrNotExist) {
617		t.Fatalf("backup with a vanished config: %v, want not-exist", err)
618	}
619}
620
621// gc refuses while a full backup holds the lock.
622func TestGCRefusedDuringBackup(t *testing.T) {
623	cfg := testConfig(t)
624	release, err := backuplock.Hold(cfg.Server.Root)
625	if err != nil {
626		t.Fatal(err)
627	}
628	defer release()
629	if err := runGC(cfg, "", false, false); !errors.Is(err, backuplock.ErrBusy) {
630		t.Fatalf("gc during a backup: %v", err)
631	}
632}
633
634// A backup and its verify need no key file: sealed values are copied as
635// they are. A missing database is refused rather than created.
636func TestBackupNeedsNoKeyFile(t *testing.T) {
637	cfg := testConfig(t)
638	out := filepath.Join(t.TempDir(), "b.tar.gz")
639	if err := runBackup(cfg, out, false); !errors.Is(err, fs.ErrNotExist) {
640		t.Fatalf("backup without a database: %v", err)
641	}
642	if _, err := os.Stat(filepath.Join(cfg.Server.Root, "gitbay.db")); !os.IsNotExist(err) {
643		t.Fatalf("backup created a database: %v", err)
644	}
645	s, err := openStore(cfg)
646	if err != nil {
647		t.Fatal(err)
648	}
649	s.Close()
650	if err := os.Remove(cfg.Server.SecretKeyFile); err != nil {
651		t.Fatal(err)
652	}
653	if err := runBackup(cfg, out, false); err != nil {
654		t.Fatalf("backup without the key file: %v", err)
655	}
656	if err := verifyBackup(out, ""); err != nil {
657		t.Fatalf("verify without the key file: %v", err)
658	}
659}
660
661// A run removes what a killed run left beside the archive once it is a
662// day old, and leaves younger ones, which may belong to a run under way.
663func TestBackupRemovesStaleTemporaries(t *testing.T) {
664	cfg := testConfig(t)
665	s, err := openStore(cfg)
666	if err != nil {
667		t.Fatal(err)
668	}
669	s.Close()
670	dir := t.TempDir()
671	old := time.Now().Add(-25 * time.Hour)
672	mk := func(name string, isDir bool, mtime time.Time) {
673		p := filepath.Join(dir, name)
674		if isDir {
675			if err := os.Mkdir(p, 0o700); err != nil {
676				t.Fatal(err)
677			}
678		} else if err := os.WriteFile(p, []byte("x"), 0o600); err != nil {
679			t.Fatal(err)
680		}
681		if err := os.Chtimes(p, mtime, mtime); err != nil {
682			t.Fatal(err)
683		}
684	}
685	mk(".gitbay-snap-old", true, old)
686	mk(".b.tar.gz.tmp-123", false, old)
687	mk(".gitbay-snap-new", true, time.Now())
688	mk(".b.tar.gz.tmp-456", false, time.Now())
689	mk(".keep", false, old)
690	mk(".notes.tmp-draft", false, old)
691	if err := runBackup(cfg, filepath.Join(dir, "b.tar.gz"), true); err != nil {
692		t.Fatal(err)
693	}
694	got := leftovers(t, dir)
695	want := []string{".b.tar.gz.tmp-456", ".gitbay-snap-new", ".keep", ".notes.tmp-draft"}
696	sort.Strings(got)
697	if strings.Join(got, " ") != strings.Join(want, " ") {
698		t.Errorf("left %v, want %v", got, want)
699	}
700}
701
702// An archive written under server.root, directly or through a symlink,
703// would be in the next full backup, so it is refused.
704func TestBackupRefusesOutputInsideRoot(t *testing.T) {
705	cfg := testConfig(t)
706	s, err := openStore(cfg)
707	if err != nil {
708		t.Fatal(err)
709	}
710	s.Close()
711	link := filepath.Join(t.TempDir(), "link")
712	if err := os.Symlink(cfg.Server.Root, link); err != nil {
713		t.Fatal(err)
714	}
715	for _, out := range []string{
716		filepath.Join(cfg.Server.Root, "b.tar.gz"),
717		filepath.Join(cfg.Server.Root, "backups", "b.tar.gz"),
718		filepath.Join(link, "b.tar.gz"),
719	} {
720		if err := runBackup(cfg, out, true); err == nil || !strings.Contains(err.Error(), "inside server.root") {
721			t.Errorf("%s: %v", out, err)
722		}
723	}
724}
725
726// verify does not extract objects/info/alternates, so an archived
727// repository cannot borrow objects from paths outside the archive.
728func TestVerifyIgnoresAlternates(t *testing.T) {
729	cfg := testConfig(t)
730	st, err := openStore(cfg)
731	if err != nil {
732		t.Fatal(err)
733	}
734	uid, err := st.CreateUser("krz", false)
735	if err != nil {
736		t.Fatal(err)
737	}
738	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
739		t.Fatal(err)
740	}
741	st.Close()
742
743	work := t.TempDir()
744	gitIn(t, work, "init", "-q", "-b", "main")
745	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
746		t.Fatal(err)
747	}
748	gitIn(t, work, "add", "a.txt")
749	gitIn(t, work, "commit", "-q", "-m", "one")
750	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
751	gitIn(t, work, "clone", "-q", "--bare", "--shared", work, dir)
752	if _, err := os.Stat(filepath.Join(dir, "objects", "info", "alternates")); err != nil {
753		t.Fatal(err)
754	}
755	gitIn(t, dir, "fsck", "--connectivity-only", "--no-progress")
756
757	archive := filepath.Join(t.TempDir(), "b.tar.gz")
758	if err := runBackup(cfg, archive, false); err != nil {
759		t.Fatal(err)
760	}
761	if err := verifyBackup(archive, ""); err == nil || !strings.Contains(err.Error(), "connectivity") {
762		t.Fatalf("verify of a repository whose objects are only in an alternate: %v", err)
763	}
764}
765
766func TestBorrowsObjectsMember(t *testing.T) {
767	for name, want := range map[string]bool{
768		"repos/a/b.git/objects/info/alternates":      true,
769		"repos/a/b.git/objects/info/./alternates":    true,
770		"repos/a/b.git/objects/info/Alternates":      true,
771		"repos/a/b.git/objects/info/http-alternates": true,
772		"repos/a/b.git/objects/info/packs":           false,
773		"repos/a/b.git/refs/heads/alternates":        false,
774		"repos/a/b.git/commondir":                    true,
775		"repos/a/b.git/CommonDir":                    true,
776		"repos/a/b.git/refs/heads/commondir":         false,
777	} {
778		if got := borrowsObjects(name); got != want {
779			t.Errorf("borrowsObjects(%q) = %v, want %v", name, got, want)
780		}
781	}
782}
783
784// verify does not extract a commondir, so an archived repository with
785// none of its own objects cannot pass by pointing git at a repository on
786// the host.
787func TestVerifyIgnoresCommondir(t *testing.T) {
788	cfg := testConfig(t)
789	st, err := openStore(cfg)
790	if err != nil {
791		t.Fatal(err)
792	}
793	uid, err := st.CreateUser("krz", false)
794	if err != nil {
795		t.Fatal(err)
796	}
797	if _, err := st.CreateRepo("user", uid, "thing", "public"); err != nil {
798		t.Fatal(err)
799	}
800	st.Close()
801
802	work := t.TempDir()
803	gitIn(t, work, "init", "-q", "-b", "main")
804	if err := os.WriteFile(filepath.Join(work, "a.txt"), []byte("a\n"), 0o644); err != nil {
805		t.Fatal(err)
806	}
807	gitIn(t, work, "add", "a.txt")
808	gitIn(t, work, "commit", "-q", "-m", "one")
809	host := filepath.Join(t.TempDir(), "host.git")
810	gitIn(t, work, "clone", "-q", "--bare", work, host)
811	gitIn(t, host, "pack-refs", "--all")
812
813	// A repository whose refs are its own and whose objects directory is
814	// empty, borrowing everything else from host through commondir.
815	dir := filepath.Join(cfg.Server.Root, "repos", "krz", "thing.git")
816	for _, d := range []string{"objects", "refs"} {
817		if err := os.MkdirAll(filepath.Join(dir, d), 0o755); err != nil {
818			t.Fatal(err)
819		}
820	}
821	packed, err := os.ReadFile(filepath.Join(host, "packed-refs"))
822	if err != nil {
823		t.Fatal(err)
824	}
825	for name, body := range map[string]string{
826		"HEAD":        "ref: refs/heads/main\n",
827		"packed-refs": string(packed),
828		"commondir":   host + "\n",
829	} {
830		if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
831			t.Fatal(err)
832		}
833	}
834	if err := gitutil.FsckConnectivity(dir); err != nil {
835		t.Fatalf("with commondir on the host the repository should pass: %v", err)
836	}
837
838	archive := filepath.Join(t.TempDir(), "b.tar.gz")
839	if err := runBackup(cfg, archive, false); err != nil {
840		t.Fatal(err)
841	}
842	if err := verifyBackup(archive, ""); err == nil || !strings.Contains(err.Error(), "connectivity") {
843		t.Fatalf("verify of a repository whose objects are only in its commondir: %v", err)
844	}
845}
846
847// verify checks each release asset the database names against its
848// recorded digest, and each archived LFS object against its name.
849func TestVerifyChecksReleaseAssetsAndLFS(t *testing.T) {
850	cfg := testConfig(t)
851	root := cfg.Server.Root
852	st, err := openStore(cfg)
853	if err != nil {
854		t.Fatal(err)
855	}
856	uid, err := st.CreateUser("krz", false)
857	if err != nil {
858		t.Fatal(err)
859	}
860	rid, err := st.CreateRepo("user", uid, "thing", "public")
861	if err != nil {
862		t.Fatal(err)
863	}
864	relID, err := st.CreateRelease(rid, "v1", "v1", "", uid, "md")
865	if err != nil {
866		t.Fatal(err)
867	}
868	asset := []byte("binary\n")
869	sum := sha256.Sum256(asset)
870	if err := st.AddReleaseAsset(relID, "tool", int64(len(asset)), hex.EncodeToString(sum[:])); err != nil {
871		t.Fatal(err)
872	}
873	st.Close()
874	dir := filepath.Join(root, "repos", "krz", "thing.git")
875	gitIn(t, root, "init", "-q", "--bare", dir)
876	assetFile := filepath.Join(dir, "gitbay-releases", strconv.FormatInt(relID, 10), "tool")
877	writeFile(t, assetFile, asset)
878	obj := []byte("large\n")
879	oid := sha256.Sum256(obj)
880	o := hex.EncodeToString(oid[:])
881	writeFile(t, filepath.Join(root, "lfs", o[:2], o[2:4], o), obj)
882	// An upload in progress stages a temporary file beside the objects.
883	writeFile(t, filepath.Join(root, "lfs", o[:2], o[2:4], ".upload-123"), []byte("partial"))
884
885	good := filepath.Join(t.TempDir(), "good.tar.gz")
886	if err := runBackup(cfg, good, false); err != nil {
887		t.Fatal(err)
888	}
889	if err := verifyBackup(good, ""); err != nil {
890		t.Fatalf("intact archive: %v", err)
891	}
892
893	writeFile(t, assetFile, []byte("tampered\n"))
894	wrong := strings.Repeat("0", 64)
895	writeFile(t, filepath.Join(root, "lfs", "00", "00", wrong), obj)
896	bad := filepath.Join(t.TempDir(), "bad.tar.gz")
897	if err := runBackup(cfg, bad, false); err != nil {
898		t.Fatal(err)
899	}
900	err = verifyBackup(bad, "")
901	if err == nil || !strings.Contains(err.Error(), "krz/thing release") || !strings.Contains(err.Error(), wrong) {
902		t.Fatalf("archive with a bad asset and LFS object: %v", err)
903	}
904	if strings.Contains(err.Error(), o) || strings.Contains(err.Error(), ".upload-") {
905		t.Fatalf("intact LFS object or upload staging file reported: %v", err)
906	}
907}
908
909func writeFile(t *testing.T, p string, b []byte) {
910	t.Helper()
911	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
912		t.Fatal(err)
913	}
914	if err := os.WriteFile(p, b, 0o644); err != nil {
915		t.Fatal(err)
916	}
917}