Commit c09a124633

c09a124633ecf74c51cf77f97fa5b967f00bfea1

parent: 5518fc0861

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 03:27 UTC

backup: verify checks release assets and LFS object digests

Ref #259

Layout: unified · split

.gitbay/wiki/Admin.org +12 −5
@@ -491,11 +491,18 @@ each one it removes.
491491
492492=--verify= reads an archive back: the snapshot must pass SQLite's
493493integrity check, every repository the snapshot names must be in the
494archive, and each must pass =git fsck --connectivity-only=. It
495extracts the repositories to a temporary directory for that, so it
496needs free space the size of the repositories. A database-only archive
497is checked for integrity and says so. Exit is non-zero on damage, a
498missing repository or a missing object.
494archive, and each must pass =git fsck --connectivity-only=. Every
495release asset the snapshot names must be in its repository with its
496recorded size and sha256, and every LFS object in the archive must
497hash to its name. LFS objects are named by pointer files in git
498history rather than by the database, so a pointer whose object was
499never uploaded is not caught, and with =[lfs] root= outside
500=server.root= the archive holds no LFS objects at all. It extracts the
501repositories to a temporary directory for the checks, so it needs free
502space the size of the repositories. A database-only archive is checked
503for integrity and says so. Exit is non-zero on damage, a missing
504repository, a missing object, or a missing or altered asset or LFS
505object.
499506
500507A full backup holds =<root>/backup.lock= from its database snapshot to
501508its last repository. While it runs, =repo delete=, =repo rename=,
cmd/gitbayd/backup.go +83 −5
@@ -4,6 +4,8 @@ import (
44 "archive/tar"
55 "bufio"
66 "compress/gzip"
7 "crypto/sha256"
8 "encoding/hex"
79 "errors"
810 "fmt"
911 "io"
@@ -12,6 +14,7 @@ import (
1214 "path"
1315 "path/filepath"
1416 "regexp"
17 "strconv"
1518 "strings"
1619 "time"
1720
@@ -71,7 +74,7 @@ public keys and its name ends in .age. --verify then needs --identity
7174 }
7275 cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)")
7376 cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
74 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, and git connectivity of each")
77 cmd.Flags().StringVar(&verify, "verify", "", "check an archive instead of writing one: database integrity, its repositories against the archive's, git connectivity of each, release assets and LFS object digests")
7578 cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive")
7679 return cmd
7780}
@@ -459,7 +462,8 @@ func addDir(tw *tar.Writer, path, name string) error {
459462// verifyBackup reads an archive back, decrypting it with identity when it
460463// is encrypted: the database snapshot must pass SQLite's integrity check,
461464// every repository it names must be in the archive, and each of those
462// must pass git fsck --connectivity-only. A database-only archive is
465// must pass git fsck --connectivity-only; release assets must match the
466// database and LFS objects their names. A database-only archive is
463467// checked for integrity alone and says so. Repositories are extracted to
464468// a temporary directory for the check, so it needs free space for them.
465469func verifyBackup(path, identity string) error {
@@ -485,6 +489,8 @@ func verifyBackup(path, identity string) error {
485489 dbPath := ""
486490 inArchive := map[string]bool{}
487491 members := 0
492 lfsObjects := 0
493 var badLFS []string
488494 for {
489495 h, err := tr.Next()
490496 if err == io.EOF {
@@ -500,6 +506,17 @@ func verifyBackup(path, identity string) error {
500506 if err := extractTo(tr, dbPath); err != nil {
501507 return fmt.Errorf("%s: extracting the database: %w", path, err)
502508 }
509 case strings.HasPrefix(h.Name, "lfs/") && h.Typeflag == tar.TypeReg:
510 // Objects are named by their sha256, so each is checked as it
511 // streams past and none is extracted.
512 lfsObjects++
513 sum := sha256.New()
514 if _, err := io.Copy(sum, tr); err != nil {
515 return fmt.Errorf("%s: reading %s: %w", path, h.Name, err)
516 }
517 if hex.EncodeToString(sum.Sum(nil)) != filepath.Base(h.Name) {
518 badLFS = append(badLFS, h.Name)
519 }
503520 case strings.HasPrefix(h.Name, "repos/"):
504521 trimmed := strings.TrimSuffix(h.Name, "/")
505522 // repos/<owner>/<name>.git/HEAD marks one repository present.
@@ -572,6 +589,7 @@ func verifyBackup(path, identity string) error {
572589 if extra > 0 {
573590 fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra)
574591 }
592 var failed []error
575593 var broken []string
576594 for _, r := range repos {
577595 dir := filepath.Join(tmp, "repos", r.OwnerName, r.Name+".git")
@@ -581,10 +599,70 @@ func verifyBackup(path, identity string) error {
581599 }
582600 }
583601 if len(broken) > 0 {
584 return fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", "))
602 failed = append(failed, fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", ")))
603 } else {
604 fmt.Printf("connectivity ok on %d repositories\n", len(repos))
585605 }
586 fmt.Printf("connectivity ok on %d repositories\n", len(repos))
587 return nil
606 assets, badAssets, err := checkReleaseAssets(st, repos, tmp)
607 if err != nil {
608 return err
609 }
610 if len(badAssets) > 0 {
611 failed = append(failed, fmt.Errorf("%s: %d release assets missing or not matching their digest: %s", path, len(badAssets), strings.Join(badAssets, ", ")))
612 } else {
613 fmt.Printf("release assets ok: %d\n", assets)
614 }
615 // LFS objects are named by pointer files in git history, not by the
616 // database, so this checks the archived objects' digests and not that
617 // every pointer has its object. With [lfs] root outside server.root
618 // the archive carries none.
619 if len(badLFS) > 0 {
620 failed = append(failed, fmt.Errorf("%s: %d LFS objects do not match their digest: %s", path, len(badLFS), strings.Join(badLFS, ", ")))
621 } else {
622 fmt.Printf("LFS objects ok: %d\n", lfsObjects)
623 }
624 return errors.Join(failed...)
625}
626
627// checkReleaseAssets checks that every release asset the database names
628// is under root, extracted, with its recorded size and sha256. It
629// returns how many the database names and those that fail.
630func checkReleaseAssets(st *store.Store, repos []store.Repo, root string) (int, []string, error) {
631 byID := map[int64]store.Repo{}
632 for _, r := range repos {
633 byID[r.ID] = r
634 }
635 rows, err := st.DB.Query(`SELECT rl.repo_id, a.release_id, a.name, a.size, a.sha256
636 FROM release_assets a JOIN releases rl ON rl.id = a.release_id
637 ORDER BY rl.repo_id, a.release_id, a.name`)
638 if err != nil {
639 return 0, nil, err
640 }
641 defer rows.Close()
642 n := 0
643 var bad []string
644 for rows.Next() {
645 var repoID, relID, size int64
646 var name, want string
647 if err := rows.Scan(&repoID, &relID, &name, &size, &want); err != nil {
648 return 0, nil, err
649 }
650 n++
651 r := byID[repoID]
652 label := fmt.Sprintf("%s release %d %s", r.Path(), relID, name)
653 f, err := os.Open(filepath.Join(root, "repos", r.OwnerName, r.Name+".git", "gitbay-releases", strconv.FormatInt(relID, 10), name))
654 if err != nil {
655 bad = append(bad, label)
656 continue
657 }
658 sum := sha256.New()
659 got, err := io.Copy(sum, f)
660 f.Close()
661 if err != nil || got != size || hex.EncodeToString(sum.Sum(nil)) != want {
662 bad = append(bad, label)
663 }
664 }
665 return n, bad, rows.Err()
588666}
589667
590668// borrowsObjects reports an archive member that would point git at
cmd/gitbayd/backup_test.go +73
@@ -3,6 +3,8 @@ package main
33import (
44 "archive/tar"
55 "compress/gzip"
6 "crypto/sha256"
7 "encoding/hex"
68 "errors"
79 "io"
810 "io/fs"
@@ -10,6 +12,7 @@ import (
1012 "os/exec"
1113 "path/filepath"
1214 "sort"
15 "strconv"
1316 "strings"
1417 "testing"
1518 "time"
@@ -840,3 +843,73 @@ func TestVerifyIgnoresCommondir(t *testing.T) {
840843 t.Fatalf("verify of a repository whose objects are only in its commondir: %v", err)
841844 }
842845}
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
883 good := filepath.Join(t.TempDir(), "good.tar.gz")
884 if err := runBackup(cfg, good, false); err != nil {
885 t.Fatal(err)
886 }
887 if err := verifyBackup(good, ""); err != nil {
888 t.Fatalf("intact archive: %v", err)
889 }
890
891 writeFile(t, assetFile, []byte("tampered\n"))
892 wrong := strings.Repeat("0", 64)
893 writeFile(t, filepath.Join(root, "lfs", "00", "00", wrong), obj)
894 bad := filepath.Join(t.TempDir(), "bad.tar.gz")
895 if err := runBackup(cfg, bad, false); err != nil {
896 t.Fatal(err)
897 }
898 err = verifyBackup(bad, "")
899 if err == nil || !strings.Contains(err.Error(), "krz/thing release") || !strings.Contains(err.Error(), wrong) {
900 t.Fatalf("archive with a bad asset and LFS object: %v", err)
901 }
902 if strings.Contains(err.Error(), o) {
903 t.Fatalf("intact LFS object reported: %v", err)
904 }
905}
906
907func writeFile(t *testing.T, p string, b []byte) {
908 t.Helper()
909 if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
910 t.Fatal(err)
911 }
912 if err := os.WriteFile(p, b, 0o644); err != nil {
913 t.Fatal(err)
914 }
915}