cmd/gitbayd/backup_test.go
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}