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
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
|
package hook
import (
"bytes"
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitlab.com/gitlab-org/gitaly/v16/internal/command"
"gitlab.com/gitlab-org/gitaly/v16/internal/git"
"gitlab.com/gitlab-org/gitaly/v16/internal/helper/env"
"gitlab.com/gitlab-org/gitaly/v16/proto/go/gitalypb"
"golang.org/x/sys/unix"
)
// customHooksExecutor executes all custom hooks for a given repository and hook name
type customHooksExecutor func(ctx context.Context, args, env []string, stdin io.Reader, stdout, stderr io.Writer) error
// CustomHookError is returned in case custom hooks return an error.
type CustomHookError error
// newCustomHooksExecutor creates a new hooks executor for custom hooks. Hooks
// are looked up and executed in the following order:
//
// 1. <repository>.git/custom_hooks/<hook_name> - per project hook
// 2. <repository>.git/custom_hooks/<hook_name>.d/* - per project hooks
// 3. <custom_hooks_dir>/hooks/<hook_name>.d/* - global hooks
//
// Any files which are either not executable or have a trailing `~` are ignored.
func (m *GitLabHookManager) newCustomHooksExecutor(repo *gitalypb.Repository, hookName string) (customHooksExecutor, error) {
repoPath, err := m.locator.GetRepoPath(repo)
if err != nil {
return nil, err
}
var hookFiles []string
projectCustomHookFile := filepath.Join(repoPath, "custom_hooks", hookName)
if isValidHook(projectCustomHookFile) {
hookFiles = append(hookFiles, projectCustomHookFile)
}
projectCustomHookDir := filepath.Join(repoPath, "custom_hooks", fmt.Sprintf("%s.d", hookName))
files, err := findHooks(projectCustomHookDir)
if err != nil {
return nil, err
}
hookFiles = append(hookFiles, files...)
globalCustomHooksDir := filepath.Join(m.cfg.Hooks.CustomHooksDir, fmt.Sprintf("%s.d", hookName))
files, err = findHooks(globalCustomHooksDir)
if err != nil {
return nil, err
}
hookFiles = append(hookFiles, files...)
return func(ctx context.Context, args, env []string, stdin io.Reader, stdout, stderr io.Writer) error {
var stdinBytes []byte
if stdin != nil {
stdinBytes, err = io.ReadAll(stdin)
if err != nil {
return err
}
}
for _, hookFile := range hookFiles {
c, err := command.New(ctx, append([]string{hookFile}, args...),
command.WithDir(repoPath),
command.WithStdin(bytes.NewReader(stdinBytes)),
command.WithStdout(stdout),
command.WithStderr(stderr),
command.WithEnvironment(env),
command.WithCommandName("gitaly-hooks", hookName),
)
if err != nil {
return err
}
if err = c.Wait(); err != nil {
// Custom hook errors need to be handled specially when we update
// refs via updateref.UpdaterWithHooks: their stdout and stderr must
// not be modified, but instead used as-is as the hooks' error
// message given that they may contain output that should be shown
// to the user.
return CustomHookError(fmt.Errorf("error executing %q: %w", hookFile, err))
}
}
return nil
}, nil
}
// findHooks finds valid hooks in the given directory. A hook is considered
// valid if `isValidHook()` would return `true`. Matching hooks are sorted by
// filename.
func findHooks(dir string) ([]string, error) {
fis, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var hookFiles []string
for _, fi := range fis {
hookPath := filepath.Join(dir, fi.Name())
if isValidHook(hookPath) {
hookFiles = append(hookFiles, hookPath)
}
}
return hookFiles, nil
}
// isValidHook checks whether a given path refers to a valid hook. A path is
// not a valid hook path if any of the following conditions is true:
//
// - The path ends with a tilde.
// - The path is or points at a directory.
// - The path is not executable by the current user.
func isValidHook(path string) bool {
if strings.HasSuffix(path, "~") {
return false
}
fi, err := os.Stat(path)
if err != nil {
return false
}
if fi.IsDir() {
return false
}
if unix.Access(path, unix.X_OK) != nil {
return false
}
return true
}
func (m *GitLabHookManager) customHooksEnv(ctx context.Context, payload git.HooksPayload, pushOptions []string, envs []string) ([]string, error) {
repoPath, err := m.locator.GetPath(payload.Repo)
if err != nil {
return nil, err
}
customEnvs := append(command.AllowedEnvironment(envs), pushOptionsEnv(pushOptions)...)
objectDirectory := env.ExtractValue(envs, "GIT_OBJECT_DIRECTORY")
if objectDirectory == "" && payload.Repo.GetGitObjectDirectory() != "" {
objectDirectory = filepath.Join(repoPath, payload.Repo.GetGitObjectDirectory())
}
if objectDirectory != "" {
customEnvs = append(customEnvs, "GIT_OBJECT_DIRECTORY="+objectDirectory)
}
alternateObjectDirectories := env.ExtractValue(envs, "GIT_ALTERNATE_OBJECT_DIRECTORIES")
if alternateObjectDirectories == "" && len(payload.Repo.GetGitAlternateObjectDirectories()) != 0 {
var absolutePaths []string
for _, alternateObjectDirectory := range payload.Repo.GetGitAlternateObjectDirectories() {
absolutePaths = append(absolutePaths, filepath.Join(repoPath, alternateObjectDirectory))
}
alternateObjectDirectories = strings.Join(absolutePaths, ":")
}
if alternateObjectDirectories != "" {
customEnvs = append(customEnvs, "GIT_ALTERNATE_OBJECT_DIRECTORIES="+alternateObjectDirectories)
}
gitExecEnv := m.gitCmdFactory.GetExecutionEnvironment(ctx)
if gitDir := filepath.Dir(gitExecEnv.BinaryPath); gitDir != "." {
// By default, we should take PATH from the given set of environment variables, if
// it's contained in there. Otherwise, we need to take the current process's PATH
// environment, which would also be the default injected by the command package.
currentPath := env.ExtractValue(envs, "PATH")
if currentPath == "" {
currentPath = os.Getenv("PATH")
}
// We want to ensure that custom hooks use the same Git version as used by Gitaly.
// Given that our Git version may not be contained in PATH, we thus have to prepend
// the directory containing that executable to PATH such that it can be found.
customEnvs = append(customEnvs, fmt.Sprintf("PATH=%s:%s", gitDir, currentPath))
}
// We need to inject environment variables which set up the Git execution environment in
// case we're running with bundled Git such that Git can locate its binaries.
customEnvs = append(customEnvs, gitExecEnv.EnvironmentVariables...)
return append(customEnvs,
"GIT_DIR="+repoPath,
"GL_REPOSITORY="+payload.Repo.GetGlRepository(),
"GL_PROJECT_PATH="+payload.Repo.GetGlProjectPath(),
"GL_ID="+payload.UserDetails.UserID,
"GL_USERNAME="+payload.UserDetails.Username,
"GL_PROTOCOL="+payload.UserDetails.Protocol,
), nil
}
// pushOptionsEnv turns a slice of git push option values into a GIT_PUSH_OPTION_COUNT and individual
// GIT_PUSH_OPTION_0, GIT_PUSH_OPTION_1 etc.
func pushOptionsEnv(options []string) []string {
if len(options) == 0 {
return []string{}
}
envVars := []string{fmt.Sprintf("GIT_PUSH_OPTION_COUNT=%d", len(options))}
for i, pushOption := range options {
envVars = append(envVars, fmt.Sprintf("GIT_PUSH_OPTION_%d=%s", i, pushOption))
}
return envVars
}
|