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