cmd/gitbayd/backup_test.go
917 lines · 26963 bytes
23 symbols in this file
membersTestBackupDBOnlyOmitsRepositoriesTestBackupEncryptedToAgeRecipientleftoversTestBackupLeavesNoTemporariesTestBackupRefusesAgeNameWithoutRecipientsTestVerifyRejectsTruncatedArchiveTestArchivePathgitInTestVerifyChecksConnectivityTestFullBackupWaitsForRepositoryMovesTestBackupPreservesPackedRefDirsTestBackupArchivesRefsBeforeObjectsTestBackupSkipsVanishedObjectsTestGCRefusedDuringBackupTestBackupNeedsNoKeyFileTestBackupRemovesStaleTemporariesTestBackupRefusesOutputInsideRootTestVerifyIgnoresAlternatesTestBorrowsObjectsMemberTestVerifyIgnoresCommondirTestVerifyChecksReleaseAssetsAndLFSwriteFile
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}