~bigbes/sr-ht-spec

ref: 5bb0bb134d608263da3197df9fb4f1d8a3fe42db sr-ht-spec/hooks/install.go -rw-r--r-- 5.2 KiB
5bb0bb13 — Eugene Blikh graph: serve /query on the anonymous router with a bearer credential 2 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
package hooks

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/go-git/go-git/v5"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
)

const (
	// hooksDirMode and the hook symlinks themselves are owned by the service
	// user; every repository under the repos root is.
	hooksDirMode = 0o755

	// installSuffix names the temporary link Install renames into place, so a
	// refresh is atomic and a push arriving mid-upgrade sees either the old
	// hook or the new one, never a missing one.
	installSuffix = ".specsrht-new"
)

// InstallOptions configures Install.
type InstallOptions struct {
	// Binary is the absolute path of the specsrht binary every hook symlinks
	// to. The daemon passes os.Executable().
	Binary string
}

// InstallSpace installs the receive hooks into a space's repository.
//
// The path comes from gitx.DiskPath rather than from a second copy of the
// layout rule, for the same reason the server re-derives it there: one source
// of truth for where a space lives.
func InstallSpace(reposRoot string, ref core.SpaceRef, opts InstallOptions) error {
	if err := core.ValidateOwner(ref.Owner); err != nil {
		return fmt.Errorf("hooks: install into %s: %w", ref, err)
	}
	if err := core.ValidateSpaceName(ref.Name); err != nil {
		return fmt.Errorf("hooks: install into %s: %w", ref, err)
	}
	return Install(gitx.DiskPath(reposRoot, ref), opts)
}

// Install writes — or refreshes — the receive hooks in a bare repository.
//
// Each hook is a symlink to the specsrht binary, which dispatches on the name
// git invoked it as. Nothing is generated, so there is no stale script to find
// after an upgrade: reinstalling is idempotent, and the daemon does it for
// every space at startup.
//
// It also sets receive.advertisePushOptions. Without it git refuses
// `--push-option=...` client-side with "the receiving end does not support
// push options", and the documented escape hatch would not exist.
//
// Any file already occupying a hook's name is replaced. A repository under the
// service's repos root has no hooks but ours, and silently leaving somebody
// else's `update` in place would mean pushes that are never validated — the
// exact failure this whole path exists to prevent.
func Install(repoDir string, opts InstallOptions) error {
	if opts.Binary == "" {
		return errors.New("hooks: no binary to install hooks from")
	}
	if !filepath.IsAbs(opts.Binary) {
		return fmt.Errorf("hooks: %q is not an absolute path; git runs a hook with the "+
			"repository as its working directory, so a relative target would not resolve", opts.Binary)
	}
	info, err := os.Stat(opts.Binary)
	if err != nil {
		return fmt.Errorf("hooks: %s: %w", opts.Binary, err)
	}
	if info.IsDir() || info.Mode().Perm()&0o111 == 0 {
		return fmt.Errorf("hooks: %s is not an executable file", opts.Binary)
	}

	if err := checkBareRepo(repoDir); err != nil {
		return err
	}

	dir := filepath.Join(repoDir, "hooks")
	if err := os.MkdirAll(dir, hooksDirMode); err != nil {
		return fmt.Errorf("hooks: create %s: %w", dir, err)
	}
	for _, mode := range Modes() {
		if err := linkHook(dir, string(mode), opts.Binary); err != nil {
			return err
		}
	}
	return advertisePushOptions(repoDir)
}

// checkBareRepo refuses to scatter symlinks into a directory that is not one of
// our bare repositories. A wrong path here would install hooks nothing runs and
// report success.
func checkBareRepo(repoDir string) error {
	if repoDir == "" {
		return errors.New("hooks: no repository directory")
	}
	if !filepath.IsAbs(repoDir) {
		return fmt.Errorf("hooks: repository path %q is not absolute", repoDir)
	}
	for _, name := range []string{"HEAD", "objects", "refs"} {
		if _, err := os.Stat(filepath.Join(repoDir, name)); err != nil {
			return fmt.Errorf("hooks: %s does not look like a bare repository (%s): %w",
				repoDir, name, err)
		}
	}
	return nil
}

// linkHook points one hook at the binary, atomically.
func linkHook(dir, name, binary string) error {
	final := filepath.Join(dir, name)
	tmp := final + installSuffix

	if err := os.Remove(tmp); err != nil && !errors.Is(err, os.ErrNotExist) {
		return fmt.Errorf("hooks: clear %s: %w", tmp, err)
	}
	if err := os.Symlink(binary, tmp); err != nil {
		return fmt.Errorf("hooks: link %s -> %s: %w", tmp, binary, err)
	}
	if err := os.Rename(tmp, final); err != nil {
		_ = os.Remove(tmp)
		return fmt.Errorf("hooks: install %s: %w", final, err)
	}
	return nil
}

// advertisePushOptions turns on receive.advertisePushOptions.
//
// go-git writes the config file rather than this package shelling out to `git
// config`: library code must not depend on a git binary being on the daemon's
// PATH, and this runs on the daemon's side of the socket.
func advertisePushOptions(repoDir string) error {
	repo, err := git.PlainOpen(repoDir)
	if err != nil {
		return fmt.Errorf("hooks: open %s: %w", repoDir, err)
	}
	cfg, err := repo.Config()
	if err != nil {
		return fmt.Errorf("hooks: read the config of %s: %w", repoDir, err)
	}
	section := cfg.Raw.Section("receive")
	if section.Option("advertisePushOptions") == "true" {
		return nil
	}
	section.SetOption("advertisePushOptions", "true")
	if err := repo.SetConfig(cfg); err != nil {
		return fmt.Errorf("hooks: enable push options on %s: %w", repoDir, err)
	}
	return nil
}