cmd/gitbayd/backup.go

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

751 lines · 24374 bytes

  1package main
  2
  3import (
  4	"archive/tar"
  5	"bufio"
  6	"compress/gzip"
  7	"crypto/sha256"
  8	"encoding/hex"
  9	"errors"
 10	"fmt"
 11	"io"
 12	"io/fs"
 13	"os"
 14	"path"
 15	"path/filepath"
 16	"regexp"
 17	"strconv"
 18	"strings"
 19	"time"
 20
 21	"filippo.io/age"
 22	"github.com/spf13/cobra"
 23
 24	"gitbay.org/gitbay/internal/backuplock"
 25	"gitbay.org/gitbay/internal/config"
 26	"gitbay.org/gitbay/internal/gitutil"
 27	"gitbay.org/gitbay/internal/lfs"
 28	"gitbay.org/gitbay/internal/store"
 29)
 30
 31// backupCmd produces one tar.gz holding a consistent database snapshot plus
 32// every repository and the SSH host keys. Restore by extracting the archive
 33// into a fresh server.root.
 34//
 35// Ordering: the database is snapshotted BEFORE the repositories are read,
 36// and each repository's refs before its objects. A push that lands
 37// mid-backup then shows up only as unreferenced git objects in the archive
 38// (harmless) or not at all; the reverse order could leave database rows or
 39// refs pointing at objects the archive never captured.
 40func backupCmd() *cobra.Command {
 41	var out, verify, identity string
 42	var dbOnly bool
 43	cmd := &cobra.Command{
 44		Use:   "backup",
 45		Short: "write a consistent backup archive (database snapshot first, then repositories)",
 46		Long: `Writes a tar.gz of the server root: a consistent SQLite snapshot,
 47all repositories, and the SSH host keys. Transient state (hook socket,
 48regenerated hook scripts, askpass helper, WAL files) is excluded.
 49
 50--db-only writes the database snapshot alone. It is seconds and megabytes
 51rather than minutes and gigabytes, which is what makes a frequent schedule
 52affordable, and the database is the copy of issues, merge requests and
 53comments that exists nowhere else. Repositories are not in such an archive,
 54so it supplements a full backup and does not replace one.
 55
 56Restore: extract into an empty directory, point server.root at it,
 57restore server.secret_key_file from its own backup (mode 0600, owned by
 58the daemon user), start gitbayd. No archive carries the key file, and
 59without it gitbayd refuses to start. Host keys are preserved, so clients
 60keep their known_hosts entries.
 61
 62With [backup] age_recipients set, the archive is encrypted to those age
 63public keys and its name ends in .age. --verify then needs --identity
 64<file> holding a matching private key, which is kept off the host.`,
 65		RunE: func(cmd *cobra.Command, args []string) error {
 66			if verify != "" {
 67				return verifyBackup(verify, identity)
 68			}
 69			cfg, err := config.Load(configPath)
 70			if err != nil {
 71				return err
 72			}
 73			return runBackup(cfg, archivePath(out, cfg, time.Now()), dbOnly)
 74		},
 75	}
 76	cmd.Flags().StringVar(&out, "out", "", "output archive path (default gitbay-backup-<utc timestamp>.tar.gz; .age is appended when [backup] age_recipients is set)")
 77	cmd.Flags().BoolVar(&dbOnly, "db-only", false, "archive the database snapshot alone, without repositories")
 78	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")
 79	cmd.Flags().StringVar(&identity, "identity", "", "with --verify: an age identity file that opens an encrypted archive")
 80	return cmd
 81}
 82
 83// archivePath is where the archive goes: out, or a timestamped name,
 84// ending in .age when the archive is encrypted.
 85func archivePath(out string, cfg config.Config, now time.Time) string {
 86	if out == "" {
 87		out = fmt.Sprintf("gitbay-backup-%s.tar.gz", now.UTC().Format("20060102-150405"))
 88	}
 89	if len(cfg.Backup.AgeRecipients) > 0 && !strings.HasSuffix(out, ".age") {
 90		out += ".age"
 91	}
 92	return out
 93}
 94
 95func runBackup(cfg config.Config, out string, dbOnly bool) error {
 96	var rs []age.Recipient
 97	if len(cfg.Backup.AgeRecipients) > 0 {
 98		var err error
 99		if rs, err = cfg.Backup.Recipients(); err != nil {
100			return err
101		}
102	} else if strings.HasSuffix(out, ".age") {
103		return fmt.Errorf("%s ends in .age but [backup] age_recipients is not set, so the archive would not be encrypted", out)
104	}
105
106	dir := filepath.Dir(out)
107	// An archive under the root would be in the next full backup's walk.
108	if config.Within(cfg.Server.Root, dir) {
109		return fmt.Errorf("%s is inside server.root %s; write the archive elsewhere", out, cfg.Server.Root)
110	}
111	removeStale(dir, time.Now().Add(-staleAge))
112
113	// Deletes, renames and transfers wait until the walk finishes, so
114	// every repository the snapshot names is still on disk when the walk
115	// reaches it (#259). A database-only archive reads no repository.
116	if !dbOnly {
117		release, err := backuplock.Hold(cfg.Server.Root)
118		if err != nil {
119			return fmt.Errorf("backup lock: %w", err)
120		}
121		defer release()
122	}
123
124	// VACUUM INTO copies sealed values as they are, so the backup needs
125	// no key file, and it migrates nothing. store.Open would create a
126	// missing database, so its absence is checked first.
127	dbFile := filepath.Join(cfg.Server.Root, "gitbay.db")
128	if _, err := os.Stat(dbFile); err != nil {
129		return fmt.Errorf("database: %w", err)
130	}
131	st, err := store.Open(dbFile)
132	if err != nil {
133		return err
134	}
135	defer st.Close()
136
137	// 1. Consistent database snapshot, before any repository is read. It
138	// goes in a fresh 0700 directory beside the archive.
139	snapDir, err := os.MkdirTemp(dir, ".gitbay-snap-")
140	if err != nil {
141		return err
142	}
143	defer os.RemoveAll(snapDir)
144	snap := filepath.Join(snapDir, "gitbay.db")
145	if err := snapshotDB(st, snap); err != nil {
146		return fmt.Errorf("database snapshot: %w", err)
147	}
148
149	// The archive is written to a temporary name beside out and renamed
150	// once complete, so a failed run leaves no partial archive behind.
151	f, err := os.CreateTemp(dir, "."+filepath.Base(out)+".tmp-")
152	if err != nil {
153		return err
154	}
155	done := false
156	defer func() {
157		if !done {
158			f.Close()
159			os.Remove(f.Name())
160		}
161	}()
162	var sink io.Writer = f
163	var enc io.WriteCloser
164	if len(rs) > 0 {
165		if enc, err = age.Encrypt(f, rs...); err != nil {
166			return err
167		}
168		sink = enc
169	}
170	gz := gzip.NewWriter(sink)
171	tw := tar.NewWriter(gz)
172
173	if err := addFile(tw, snap, "gitbay.db"); err != nil {
174		return err
175	}
176
177	// 2. Everything under the root except transient or regenerated state.
178	// Skipped entirely for --db-only.
179	skip := map[string]bool{
180		"gitbay.db": true, "gitbay.db-wal": true, "gitbay.db-shm": true,
181		"hook.sock": true, "askpass.sh": true, "hooks": true,
182		backuplock.Name: true,
183	}
184	repoCount := 0
185	root := cfg.Server.Root
186	if !dbOnly {
187		err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
188			rel, err := filepath.Rel(root, path)
189			if err != nil {
190				return err
191			}
192			if walkErr != nil {
193				if vanished(walkErr, rel) {
194					return nil
195				}
196				return walkErr
197			}
198			if rel == "." {
199				return nil
200			}
201			if top, _, _ := strings.Cut(rel, string(filepath.Separator)); skip[top] {
202				if d.IsDir() {
203					return filepath.SkipDir
204				}
205				return nil
206			}
207			if !d.Type().IsRegular() && !d.IsDir() {
208				return nil // sockets, symlinks
209			}
210			// A repository's refs were archived on entering it.
211			if strings.HasSuffix(filepath.Dir(rel), ".git") && refNames[d.Name()] {
212				if d.IsDir() {
213					return filepath.SkipDir
214				}
215				return nil
216			}
217			if d.IsDir() {
218				// A directory entry, even for one that holds no file (a
219				// bare repository's refs/heads and refs/tags once every
220				// ref is packed), so extraction recreates it: git's own
221				// repository discovery needs refs/ to exist.
222				if err := addDir(tw, path, filepath.ToSlash(rel)); err != nil {
223					if vanished(err, rel) {
224						return filepath.SkipDir
225					}
226					return err
227				}
228				if strings.HasSuffix(rel, ".git") {
229					repoCount++
230					if err := addRefs(tw, path, filepath.ToSlash(rel)); err != nil {
231						return err
232					}
233					afterRefs(path)
234				}
235				return nil
236			}
237			beforeAdd(path)
238			if err := addFile(tw, path, filepath.ToSlash(rel)); !vanished(err, rel) {
239				return err
240			}
241			return nil
242		})
243		if err != nil {
244			return err
245		}
246	}
247	if err := tw.Close(); err != nil {
248		return err
249	}
250	if err := gz.Close(); err != nil {
251		return err
252	}
253	if enc != nil {
254		if err := enc.Close(); err != nil {
255			return err
256		}
257	}
258	if err := f.Sync(); err != nil {
259		return err
260	}
261	if err := f.Close(); err != nil {
262		return err
263	}
264	if err := os.Rename(f.Name(), out); err != nil {
265		return err
266	}
267	done = true
268	if err := syncDir(dir); err != nil {
269		return err
270	}
271
272	info, _ := os.Stat(out)
273	if dbOnly {
274		fmt.Printf("wrote %s (database only, %.1f MB)\n", out, float64(info.Size())/1e6)
275		return nil
276	}
277	fmt.Printf("wrote %s (%d repositories, %.1f MB)\n", out, repoCount, float64(info.Size())/1e6)
278	return nil
279}
280
281// staleAge is how old a snapshot directory or temporary archive must be
282// before a later run removes it. A run that is still writing one is
283// younger than this.
284const staleAge = 24 * time.Hour
285
286// tmpArchive is a temporary archive's name: os.CreateTemp's pattern
287// "."+base+".tmp-" followed by the digits it appends.
288var tmpArchive = regexp.MustCompile(`^\..+\.tmp-[0-9]+$`)
289
290// removeStale removes what a killed run left in dir: snapshot
291// directories and temporary archives last modified before cutoff.
292func removeStale(dir string, cutoff time.Time) {
293	ents, err := os.ReadDir(dir)
294	if err != nil {
295		return
296	}
297	for _, e := range ents {
298		name := e.Name()
299		snap := e.IsDir() && strings.HasPrefix(name, ".gitbay-snap-")
300		tmp := e.Type().IsRegular() && tmpArchive.MatchString(name)
301		if !snap && !tmp {
302			continue
303		}
304		info, err := e.Info()
305		if err != nil || !info.ModTime().Before(cutoff) {
306			continue
307		}
308		p := filepath.Join(dir, name)
309		if err := os.RemoveAll(p); err != nil {
310			fmt.Fprintf(os.Stderr, "removing stale %s: %v\n", p, err)
311			continue
312		}
313		fmt.Fprintf(os.Stderr, "removed stale %s\n", p)
314	}
315}
316
317// refNames are what a repository's refs are read from. WalkDir would
318// reach objects/ before packed-refs and refs/, so a push landing mid-walk
319// could leave an archived ref naming objects the archive lacks. addRefs
320// archives these first on entering the repository; objects are only ever
321// added, so the walk that follows finds every object those refs reach.
322var refNames = map[string]bool{"HEAD": true, "packed-refs": true, "refs": true}
323
324// afterRefs runs between a repository's refs and the rest of it. Tests
325// use it to write into the repository at that point.
326var afterRefs = func(repo string) {}
327
328// addRefs archives HEAD, refs/ and packed-refs of the repository at
329// path, whichever exist. refs/ is read before packed-refs, the order git
330// reads them in: pack-refs writes packed-refs before deleting the loose
331// refs it packed, so a ref moving between the two is caught in one.
332func addRefs(tw *tar.Writer, path, name string) error {
333	if err := addRegular(tw, path, name, "HEAD"); err != nil {
334		return err
335	}
336	refs := filepath.Join(path, "refs")
337	if _, err := os.Lstat(refs); err == nil {
338		if err := addTree(tw, path, name, refs); err != nil {
339			return err
340		}
341	} else if !errors.Is(err, fs.ErrNotExist) {
342		return err
343	}
344	return addRegular(tw, path, name, "packed-refs")
345}
346
347// addRegular archives the regular file f in the repository at path, if
348// it exists.
349func addRegular(tw *tar.Writer, path, name, f string) error {
350	fi, err := os.Lstat(filepath.Join(path, f))
351	if errors.Is(err, fs.ErrNotExist) || err == nil && !fi.Mode().IsRegular() {
352		return nil
353	}
354	if err != nil {
355		return err
356	}
357	return addFile(tw, filepath.Join(path, f), name+"/"+f)
358}
359
360// addTree archives the directory refs inside the repository at path.
361func addTree(tw *tar.Writer, path, name, refs string) error {
362	return filepath.WalkDir(refs, func(p string, d fs.DirEntry, err error) error {
363		if err != nil {
364			return err
365		}
366		rel, err := filepath.Rel(path, p)
367		if err != nil {
368			return err
369		}
370		member := name + "/" + filepath.ToSlash(rel)
371		switch {
372		case d.IsDir():
373			return addDir(tw, p, member)
374		case d.Type().IsRegular():
375			return addFile(tw, p, member)
376		}
377		return nil
378	})
379}
380
381// beforeAdd runs before each file the walk archives outside refs. Tests
382// use it to remove a file between listing and reading.
383var beforeAdd = func(path string) {}
384
385// vanished reports a file or directory under a repository's objects/
386// that went between the walk listing it and reading it: a pack or loose
387// object a concurrent gc or receive.autogc removed. The walk skips it.
388// Refs archived earlier reach only objects that are still reachable, and
389// a repack writes those into a new pack before removing the old one; if
390// one is lost regardless, verify's fsck reports it.
391func vanished(err error, rel string) bool {
392	if !errors.Is(err, fs.ErrNotExist) {
393		return false
394	}
395	parts := strings.Split(filepath.ToSlash(rel), "/")
396	for i := 0; i+2 < len(parts); i++ {
397		if strings.HasSuffix(parts[i], ".git") && parts[i+1] == "objects" {
398			return true
399		}
400	}
401	return false
402}
403
404// syncDir makes a rename in dir durable.
405func syncDir(dir string) error {
406	d, err := os.Open(dir)
407	if err != nil {
408		return err
409	}
410	defer d.Close()
411	return d.Sync()
412}
413
414// snapshotDB writes a consistent copy of the live database. VACUUM INTO
415// takes a read snapshot, so concurrent daemon writes are safe under WAL.
416func snapshotDB(st *store.Store, dest string) error {
417	quoted := strings.ReplaceAll(dest, "'", "''")
418	_, err := st.DB.Exec(fmt.Sprintf("VACUUM INTO '%s'", quoted))
419	return err
420}
421
422// addFile opens before writing the header, so a file removed after the
423// walk listed it fails before the archive has a member for it.
424func addFile(tw *tar.Writer, path, name string) error {
425	src, err := os.Open(path)
426	if err != nil {
427		return err
428	}
429	defer src.Close()
430	info, err := src.Stat()
431	if err != nil {
432		return err
433	}
434	hdr, err := tar.FileInfoHeader(info, "")
435	if err != nil {
436		return err
437	}
438	hdr.Name = name
439	if err := tw.WriteHeader(hdr); err != nil {
440		return err
441	}
442	_, err = io.CopyN(tw, src, hdr.Size)
443	return err
444}
445
446// addDir writes a directory entry, so an empty directory survives
447// extraction. The mode never exceeds 0755, whatever the source directory
448// carries.
449func addDir(tw *tar.Writer, path, name string) error {
450	info, err := os.Stat(path)
451	if err != nil {
452		return err
453	}
454	hdr, err := tar.FileInfoHeader(info, "")
455	if err != nil {
456		return err
457	}
458	hdr.Name = name + "/"
459	hdr.Mode = hdr.Mode&^0o777 | hdr.Mode&0o755
460	return tw.WriteHeader(hdr)
461}
462
463// verifyBackup reads an archive back, decrypting it with identity when it
464// is encrypted: the database snapshot must pass SQLite's integrity check,
465// every repository it names must be in the archive, and each of those
466// must pass git fsck --connectivity-only; release assets must match the
467// database and LFS objects their names. A database-only archive is
468// checked for integrity alone and says so. Repositories are extracted to
469// a temporary directory for the check, so it needs free space for them.
470func verifyBackup(path, identity string) error {
471	tmp, err := os.MkdirTemp("", "gitbay-verify-")
472	if err != nil {
473		return err
474	}
475	defer os.RemoveAll(tmp)
476	return checkArchive(path, identity, tmp, false)
477}
478
479// checkArchive extracts the archive at path into dest and runs verify's
480// checks on it. With full unset it extracts only the database and the
481// repositories; with full set, every member, so dest is a restored
482// server.root. Alternates and commondir are left out either way.
483func checkArchive(path, identity, dest string, full bool) error {
484	f, err := os.Open(path)
485	if err != nil {
486		return err
487	}
488	defer f.Close()
489	plain, err := archiveReader(f, path, identity)
490	if err != nil {
491		return err
492	}
493	gz, err := gzip.NewReader(plain)
494	if err != nil {
495		return fmt.Errorf("%s: not a gzip archive: %w", path, err)
496	}
497	tr := tar.NewReader(gz)
498	dbPath := ""
499	inArchive := map[string]bool{}
500	members := 0
501	lfsObjects := 0
502	var badLFS []string
503	for {
504		h, err := tr.Next()
505		if err == io.EOF {
506			break
507		}
508		if err != nil {
509			return fmt.Errorf("%s: archive damaged after %d members: %w", path, members, err)
510		}
511		members++
512		switch {
513		case h.Name == "gitbay.db":
514			dbPath = filepath.Join(dest, "gitbay.db")
515			if err := extractTo(tr, dbPath); err != nil {
516				return fmt.Errorf("%s: extracting the database: %w", path, err)
517			}
518		case strings.HasPrefix(h.Name, "lfs/") && h.Typeflag == tar.TypeReg && lfs.OIDPat.MatchString(filepath.Base(h.Name)):
519			// Objects are named by their sha256, so each is checked as it
520			// streams past and none is extracted. Other names, such as an
521			// upload's .upload-* staging file, are not objects.
522			lfsObjects++
523			if !filepath.IsLocal(h.Name) {
524				return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name)
525			}
526			sum := sha256.New()
527			if full {
528				err = extractTo(io.TeeReader(tr, sum), filepath.Join(dest, filepath.FromSlash(h.Name)))
529			} else {
530				_, err = io.Copy(sum, tr)
531			}
532			if err != nil {
533				return fmt.Errorf("%s: reading %s: %w", path, h.Name, err)
534			}
535			if hex.EncodeToString(sum.Sum(nil)) != filepath.Base(h.Name) {
536				badLFS = append(badLFS, h.Name)
537			}
538		case full || strings.HasPrefix(h.Name, "repos/"):
539			trimmed := strings.TrimSuffix(h.Name, "/")
540			// repos/<owner>/<name>.git/HEAD marks one repository present.
541			parts := strings.Split(trimmed, "/")
542			if len(parts) == 4 && parts[0] == "repos" && parts[3] == "HEAD" && strings.HasSuffix(parts[2], ".git") {
543				inArchive[parts[1]+"/"+strings.TrimSuffix(parts[2], ".git")] = true
544			}
545			if !filepath.IsLocal(trimmed) {
546				return fmt.Errorf("%s: member %q leaves the archive root", path, h.Name)
547			}
548			if borrowsObjects(trimmed) {
549				continue
550			}
551			dest := filepath.Join(dest, filepath.FromSlash(trimmed))
552			switch h.Typeflag {
553			case tar.TypeDir:
554				// The archive's directory modes do not matter to fsck, and
555				// a hostile one would stop RemoveAll cleaning up.
556				if err := os.MkdirAll(dest, 0o700); err != nil {
557					return fmt.Errorf("%s: creating %s: %w", path, h.Name, err)
558				}
559			case tar.TypeReg:
560				if err := extractTo(tr, dest); err != nil {
561					return fmt.Errorf("%s: extracting %s: %w", path, h.Name, err)
562				}
563			}
564		}
565	}
566	// Read to the end so gzip checks its trailer and age its final chunk.
567	if _, err := io.Copy(io.Discard, gz); err != nil {
568		return fmt.Errorf("%s: archive truncated or damaged: %w", path, err)
569	}
570	if err := gz.Close(); err != nil {
571		return fmt.Errorf("%s: archive truncated or damaged: %w", path, err)
572	}
573	if dbPath == "" {
574		return fmt.Errorf("%s: no gitbay.db in the archive", path)
575	}
576	st, err := store.Open(dbPath)
577	if err != nil {
578		return fmt.Errorf("%s: database does not open: %w", path, err)
579	}
580	defer st.Close()
581	var integrity string
582	if err := st.DB.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil {
583		return fmt.Errorf("%s: integrity check: %w", path, err)
584	}
585	if integrity != "ok" {
586		return fmt.Errorf("%s: database integrity: %s", path, integrity)
587	}
588	repos, err := st.ListAllRepos()
589	if err != nil {
590		return err
591	}
592	if len(inArchive) == 0 {
593		fmt.Printf("%s: database only; integrity ok, %d repositories in the database, none in the archive\n", path, len(repos))
594		return nil
595	}
596	var missing []string
597	for _, r := range repos {
598		if !inArchive[r.Path()] {
599			missing = append(missing, r.Path())
600		}
601	}
602	extra := len(inArchive) - (len(repos) - len(missing))
603	fmt.Printf("%s: integrity ok, %d repositories in the database, %d in the archive\n", path, len(repos), len(inArchive))
604	if len(missing) > 0 {
605		return fmt.Errorf("%s: %d repositories the database names are not in the archive: %s", path, len(missing), strings.Join(missing, ", "))
606	}
607	if extra > 0 {
608		fmt.Printf("%d repositories in the archive that the database does not name (created after the snapshot)\n", extra)
609	}
610	var failed []error
611	var broken []string
612	for _, r := range repos {
613		dir := filepath.Join(dest, "repos", r.OwnerName, r.Name+".git")
614		if err := gitutil.FsckConnectivity(dir); err != nil {
615			fmt.Fprintf(os.Stderr, "%s: %v\n", r.Path(), err)
616			broken = append(broken, r.Path())
617		}
618	}
619	if len(broken) > 0 {
620		failed = append(failed, fmt.Errorf("%s: %d repositories fail the connectivity check: %s", path, len(broken), strings.Join(broken, ", ")))
621	} else {
622		fmt.Printf("connectivity ok on %d repositories\n", len(repos))
623	}
624	assets, badAssets, err := checkReleaseAssets(st, repos, dest)
625	if err != nil {
626		return err
627	}
628	if len(badAssets) > 0 {
629		failed = append(failed, fmt.Errorf("%s: %d release assets missing or not matching their digest: %s", path, len(badAssets), strings.Join(badAssets, ", ")))
630	} else {
631		fmt.Printf("release assets ok: %d\n", assets)
632	}
633	// LFS objects are named by pointer files in git history, not by the
634	// database, so this checks the archived objects' digests and not that
635	// every pointer has its object. With [lfs] root outside server.root
636	// the archive carries none.
637	if len(badLFS) > 0 {
638		failed = append(failed, fmt.Errorf("%s: %d LFS objects do not match their digest: %s", path, len(badLFS), strings.Join(badLFS, ", ")))
639	} else {
640		fmt.Printf("LFS objects ok: %d\n", lfsObjects)
641	}
642	return errors.Join(failed...)
643}
644
645// checkReleaseAssets checks that every release asset the database names
646// is under root, extracted, with its recorded size and sha256. It
647// returns how many the database names and those that fail.
648func checkReleaseAssets(st *store.Store, repos []store.Repo, root string) (int, []string, error) {
649	byID := map[int64]store.Repo{}
650	for _, r := range repos {
651		byID[r.ID] = r
652	}
653	rows, err := st.DB.Query(`SELECT rl.repo_id, a.release_id, a.name, a.size, a.sha256
654		FROM release_assets a JOIN releases rl ON rl.id = a.release_id
655		ORDER BY rl.repo_id, a.release_id, a.name`)
656	if err != nil {
657		return 0, nil, err
658	}
659	defer rows.Close()
660	n := 0
661	var bad []string
662	for rows.Next() {
663		var repoID, relID, size int64
664		var name, want string
665		if err := rows.Scan(&repoID, &relID, &name, &size, &want); err != nil {
666			return 0, nil, err
667		}
668		n++
669		r, ok := byID[repoID]
670		owner := r.Path()
671		if !ok {
672			owner = fmt.Sprintf("repository %d", repoID)
673		}
674		label := fmt.Sprintf("%s release %d %s", owner, relID, name)
675		f, err := os.Open(filepath.Join(root, "repos", r.OwnerName, r.Name+".git", "gitbay-releases", strconv.FormatInt(relID, 10), name))
676		if err != nil {
677			bad = append(bad, label)
678			continue
679		}
680		sum := sha256.New()
681		got, err := io.Copy(sum, f)
682		f.Close()
683		if err != nil || got != size || hex.EncodeToString(sum.Sum(nil)) != want {
684			bad = append(bad, label)
685		}
686	}
687	return n, bad, rows.Err()
688}
689
690// borrowsObjects reports an archive member that would point git at
691// objects or refs outside the extracted repository: alternates, or a
692// commondir directly in a *.git directory. gitbay writes none, and one in
693// a hostile archive would have fsck read another repository on the host,
694// so verify leaves them out. The comparison ignores case, as a
695// case-insensitive filesystem would.
696func borrowsObjects(name string) bool {
697	name = strings.ToLower(filepath.ToSlash(filepath.Clean(name)))
698	if dir, base := path.Split(name); base == "commondir" && strings.HasSuffix(strings.TrimSuffix(dir, "/"), ".git") {
699		return true
700	}
701	return strings.HasSuffix(name, "/objects/info/alternates") ||
702		strings.HasSuffix(name, "/objects/info/http-alternates")
703}
704
705// extractTo writes one archive member to dest, owner-only.
706func extractTo(r io.Reader, dest string) error {
707	if err := os.MkdirAll(filepath.Dir(dest), 0o700); err != nil {
708		return err
709	}
710	w, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
711	if err != nil {
712		return err
713	}
714	if _, err := io.Copy(w, r); err != nil {
715		w.Close()
716		return err
717	}
718	return w.Close()
719}
720
721const ageHeader = "age-encryption.org/v1\n"
722
723// archiveReader returns the archive's gzip stream, decrypting it first
724// when it is an age file.
725func archiveReader(f io.Reader, path, identity string) (io.Reader, error) {
726	br := bufio.NewReader(f)
727	head, _ := br.Peek(len(ageHeader))
728	if string(head) != ageHeader {
729		if identity != "" {
730			fmt.Fprintf(os.Stderr, "%s is not encrypted; --identity was not used\n", path)
731		}
732		return br, nil
733	}
734	if identity == "" {
735		return nil, fmt.Errorf("%s is encrypted; pass --identity <file> with the private key for one of its recipients", path)
736	}
737	idf, err := os.Open(identity)
738	if err != nil {
739		return nil, err
740	}
741	defer idf.Close()
742	ids, err := age.ParseIdentities(idf)
743	if err != nil {
744		return nil, fmt.Errorf("%s: %w", identity, err)
745	}
746	r, err := age.Decrypt(br, ids...)
747	if err != nil {
748		return nil, fmt.Errorf("%s: decrypting: %w", path, err)
749	}
750	return r, nil
751}