cmd/gitbayd/backup_test.go

f8b976a97290a20d552056a999511f5d27d8e8ec
gitbay/cmd/gitbayd/backup_test.go history · blame · raw

842 lines · 24556 bytes

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