coverage~bigbes/sr-ht-spec64cae3afhooks/install.go

Coverage
83.3% 45/54 statements
Δ
+0.0
Blob
2d05ac9
1 package hooks
2
3 import (
4 "errors"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/go-git/go-git/v5"
10
11 "sourcecraft.dev/bigbes/sr-ht-spec/core"
12 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
13 )
14
15 const (
16 // hooksDirMode and the hook symlinks themselves are owned by the service
17 // user; every repository under the repos root is.
18 hooksDirMode = 0o755
19
20 // installSuffix names the temporary link Install renames into place, so a
21 // refresh is atomic and a push arriving mid-upgrade sees either the old
22 // hook or the new one, never a missing one.
23 installSuffix = ".specsrht-new"
24 )
25
26 // InstallOptions configures Install.
27 type InstallOptions struct {
28 // Binary is the absolute path of the specsrht binary every hook symlinks
29 // to. The daemon passes os.Executable().
30 Binary string
31 }
32
33 // InstallSpace installs the receive hooks into a space's repository.
34 //
35 // The path comes from gitx.DiskPath rather than from a second copy of the
36 // layout rule, for the same reason the server re-derives it there: one source
37 // of truth for where a space lives.
38 8 func InstallSpace(reposRoot string, ref core.SpaceRef, opts InstallOptions) error {
39 8 if err := core.ValidateOwner(ref.Owner); err != nil {
40 2 return fmt.Errorf("hooks: install into %s: %w", ref, err)
41 2 }
42 6 if err := core.ValidateSpaceName(ref.Name); err != nil {
43 2 return fmt.Errorf("hooks: install into %s: %w", ref, err)
44 2 }
45 4 return Install(gitx.DiskPath(reposRoot, ref), opts)
46 }
47
48 // Install writes — or refreshes — the receive hooks in a bare repository.
49 //
50 // Each hook is a symlink to the specsrht binary, which dispatches on the name
51 // git invoked it as. Nothing is generated, so there is no stale script to find
52 // after an upgrade: reinstalling is idempotent, and the daemon does it for
53 // every space at startup.
54 //
55 // It also sets receive.advertisePushOptions. Without it git refuses
56 // `--push-option=...` client-side with "the receiving end does not support
57 // push options", and the documented escape hatch would not exist.
58 //
59 // Any file already occupying a hook's name is replaced. A repository under the
60 // service's repos root has no hooks but ours, and silently leaving somebody
61 // else's `update` in place would mean pushes that are never validated — the
62 // exact failure this whole path exists to prevent.
63 15 func Install(repoDir string, opts InstallOptions) error {
64 15 if opts.Binary == "" {
65 1 return errors.New("hooks: no binary to install hooks from")
66 1 }
67 14 if !filepath.IsAbs(opts.Binary) {
68 1 return fmt.Errorf("hooks: %q is not an absolute path; git runs a hook with the "+
69 1 "repository as its working directory, so a relative target would not resolve", opts.Binary)
70 1 }
71 13 info, err := os.Stat(opts.Binary)
72 13 if err != nil {
73 1 return fmt.Errorf("hooks: %s: %w", opts.Binary, err)
74 1 }
75 12 if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
76 2 return fmt.Errorf("hooks: %s is not an executable file", opts.Binary)
77 2 }
78
79 10 if err := checkBareRepo(repoDir); err != nil {
80 3 return err
81 3 }
82
83 7 dir := filepath.Join(repoDir, "hooks")
84 7 if err := os.MkdirAll(dir, hooksDirMode); err != nil {
85 0 return fmt.Errorf("hooks: create %s: %w", dir, err)
86 0 }
87 21 for _, mode := range Modes() {
88 21 if err := linkHook(dir, string(mode), opts.Binary); err != nil {
89 0 return err
90 0 }
91 }
92 7 return advertisePushOptions(repoDir)
93 }
94
95 // checkBareRepo refuses to scatter symlinks into a directory that is not one of
96 // our bare repositories. A wrong path here would install hooks nothing runs and
97 // report success.
98 10 func checkBareRepo(repoDir string) error {
99 10 if repoDir == "" {
100 1 return errors.New("hooks: no repository directory")
101 1 }
102 9 if !filepath.IsAbs(repoDir) {
103 1 return fmt.Errorf("hooks: repository path %q is not absolute", repoDir)
104 1 }
105 22 for _, name := range []string{"HEAD", "objects", "refs"} {
106 22 if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil {
107 1 return fmt.Errorf("hooks: %s does not look like a bare repository (%s): %w",
108 1 repoDir, name, err)
109 1 }
110 }
111 7 return nil
112 }
113
114 // linkHook points one hook at the binary, atomically.
115 21 func linkHook(dir, name, binary string) error {
116 21 final := filepath.Join(dir, name)
117 21 tmp := final + installSuffix
118 21
119 21 if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
120 0 return fmt.Errorf("hooks: clear %s: %w", tmp, err)
121 0 }
122 21 if err := os.Symlink(binary, tmp); err != nil {
123 0 return fmt.Errorf("hooks: link %s -> %s: %w", tmp, binary, err)
124 0 }
125 21 if err := os.Rename(tmp, final); err != nil {
126 0 _ = os.Remove(tmp)
127 0 return fmt.Errorf("hooks: install %s: %w", final, err)
128 0 }
129 21 return nil
130 }
131
132 // advertisePushOptions turns on receive.advertisePushOptions.
133 //
134 // go-git writes the config file rather than this package shelling out to `git
135 // config`: library code must not depend on a git binary being on the daemon's
136 // PATH, and this runs on the daemon's side of the socket.
137 7 func advertisePushOptions(repoDir string) error {
138 7 repo, err := git.PlainOpen(repoDir)
139 7 if err != nil {
140 0 return fmt.Errorf("hooks: open %s: %w", repoDir, err)
141 0 }
142 7 cfg, err := repo.Config()
143 7 if err != nil {
144 0 return fmt.Errorf("hooks: read the config of %s: %w", repoDir, err)
145 0 }
146 7 section := cfg.Raw.Section("receive")
147 7 if section.Option("advertisePushOptions") == "true" {
148 2 return nil
149 2 }
150 5 section.SetOption("advertisePushOptions", "true")
151 5 if err := repo.SetConfig(cfg); err != nil {
152 0 return fmt.Errorf("hooks: enable push options on %s: %w", repoDir, err)
153 0 }
154 5 return nil
155 }