internal/hookd/hookd.go
446 lines · 14729 bytes
1// Package hookd is the unix-socket bridge between git hooks and the daemon.
2// The hook process (gitbayd in hook mode) computes git facts — it inherits
3// git's quarantine environment, which the daemon does not see — and sends
4// them here; the daemon answers with a policy decision.
5//
6// pre-receive is two-phase when the repo requires signed commits: the first
7// response sets NeedCommits, and the hook answers with the raw commit
8// objects (only the hook can read them out of quarantine) for verification.
9package hookd
10
11import (
12 "crypto/sha256"
13 "encoding/json"
14 "fmt"
15 "log/slog"
16 "net"
17 "os"
18 "path"
19 "path/filepath"
20 "strings"
21 "time"
22
23 "gitbay.org/gitbay/internal/ci"
24 "gitbay.org/gitbay/internal/config"
25 "gitbay.org/gitbay/internal/control"
26 "gitbay.org/gitbay/internal/gitutil"
27 "gitbay.org/gitbay/internal/policy"
28 "gitbay.org/gitbay/internal/sig"
29 "gitbay.org/gitbay/internal/store"
30)
31
32// Env variable names passed to git transport subprocesses and inherited by
33// hooks.
34const (
35 EnvSocket = "GITBAY_HOOK_SOCKET"
36 EnvRepoID = "GITBAY_REPO_ID"
37 EnvUserID = "GITBAY_USER_ID"
38)
39
40type Request struct {
41 Hook string `json:"hook"` // pre-receive | post-receive
42 RepoID int64 `json:"repo_id"`
43 UserID int64 `json:"user_id"`
44 Updates []policy.RefUpdate `json:"updates"`
45}
46
47// RawCommit is one incoming commit object. When NeedCommits is set the
48// hook streams these one per JSON value and ends with a zero one, rather
49// than sending a single message holding every commit in the push: an
50// initial push of a large history is tens of thousands of them (#100).
51type RawCommit struct {
52 SHA string `json:"sha"`
53 Raw []byte `json:"raw"`
54}
55
56// Done marks the end of the commit stream.
57func (c RawCommit) Done() bool { return c.SHA == "" }
58
59type Response struct {
60 Allow bool `json:"allow"`
61 Message string `json:"message,omitempty"`
62 NeedCommits bool `json:"need_commits,omitempty"`
63}
64
65// SocketPath returns the hook socket location. It prefers the server root,
66// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
67// deep roots fall back to a hashed name under the system temp directory.
68// Hooks receive the chosen path via GITBAY_HOOK_SOCKET, so both sides always
69// agree.
70func SocketPath(root string) string {
71 p := filepath.Join(root, "hook.sock")
72 if len(p) <= 100 {
73 return p
74 }
75 sum := sha256.Sum256([]byte(root))
76 return filepath.Join(os.TempDir(), fmt.Sprintf("gitbay-%x.sock", sum[:8]))
77}
78
79type Server struct {
80 cfg config.Config
81 st *store.Store
82}
83
84// Serve listens on the unix socket until the listener is closed.
85func Serve(cfg config.Config, st *store.Store) (func() error, error) {
86 path := SocketPath(cfg.Server.Root)
87 os.Remove(path)
88 ln, err := net.Listen("unix", path)
89 if err != nil {
90 return nil, err
91 }
92 s := &Server{cfg: cfg, st: st}
93 go func() {
94 for {
95 conn, err := ln.Accept()
96 if err != nil {
97 return
98 }
99 go s.handle(conn)
100 }
101 }()
102 return ln.Close, nil
103}
104
105func (s *Server) handle(conn net.Conn) {
106 defer conn.Close()
107 dec := json.NewDecoder(conn)
108 enc := json.NewEncoder(conn)
109 var req Request
110 if err := dec.Decode(&req); err != nil {
111 enc.Encode(Response{Allow: false, Message: "bad hook request"})
112 return
113 }
114 switch req.Hook {
115 case "pre-receive":
116 s.preReceive(req, dec, enc)
117 case "post-receive":
118 s.postReceive(req)
119 enc.Encode(Response{Allow: true})
120 default:
121 enc.Encode(Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)})
122 }
123}
124
125func (s *Server) preReceive(req Request, dec *json.Decoder, enc *json.Encoder) {
126 repo, err := s.st.RepoByID(req.RepoID)
127 if err != nil {
128 enc.Encode(Response{Allow: false, Message: "unknown repository"})
129 return
130 }
131 if msg := policy.CheckPush(repo, req.Updates); msg != "" {
132 enc.Encode(Response{Allow: false, Message: msg})
133 return
134 }
135 if msg := s.releaseAnchors(repo, req.Updates); msg != "" {
136 enc.Encode(Response{Allow: false, Message: msg})
137 return
138 }
139 if !repo.Settings.RequireSignedCommits {
140 enc.Encode(Response{Allow: true})
141 return
142 }
143
144 // Phase two: ask the hook for the incoming commit objects.
145 if err := enc.Encode(Response{Allow: true, NeedCommits: true}); err != nil {
146 return
147 }
148 // Each commit is verified as it arrives, so nothing holds the push in
149 // memory. The first refusal decides the answer, but the stream is
150 // still drained to its end before replying: the hook is writing, and
151 // answering early would leave it writing into a socket nobody reads.
152 // Draining costs a decode per commit and no verification.
153 db := store.SigDB{Store: s.st}
154 refusal := ""
155 for {
156 var rc RawCommit
157 if err := dec.Decode(&rc); err != nil {
158 enc.Encode(Response{Allow: false, Message: "bad commits payload"})
159 return
160 }
161 if rc.Done() {
162 break
163 }
164 if refusal != "" {
165 continue
166 }
167 parsed, err := sig.ParseCommit(rc.Raw)
168 if err != nil {
169 refusal = fmt.Sprintf("unparseable commit %s", rc.SHA)
170 continue
171 }
172 res, err := sig.VerifyCommit(db, parsed)
173 if err != nil || res.State != sig.Verified {
174 state := "error"
175 if err == nil {
176 state = string(res.State)
177 }
178 refusal = fmt.Sprintf("this repository requires signed commits: %.10s is %s", rc.SHA, state)
179 }
180 }
181 if refusal != "" {
182 enc.Encode(Response{Allow: false, Message: refusal})
183 return
184 }
185 enc.Encode(Response{Allow: true})
186}
187
188// releaseAnchors refuses deleting or moving a tag that a release is
189// anchored to. A release outliving its tag served assets for a commit
190// nobody could reach (#201); the release goes first, then the tag.
191func (s *Server) releaseAnchors(repo store.Repo, updates []policy.RefUpdate) string {
192 for _, u := range updates {
193 tag, ok := strings.CutPrefix(u.Ref, "refs/tags/")
194 if !ok || gitutil.ZeroSHA(u.Old) {
195 continue
196 }
197 if _, err := s.st.ReleaseByTag(repo.ID, tag); err != nil {
198 continue
199 }
200 verb := "moved"
201 if u.IsDelete {
202 verb = "deleted"
203 }
204 return fmt.Sprintf("tag %s anchors a release and cannot be %s: delete the release first", tag, verb)
205 }
206 return ""
207}
208
209// postReceive applies the cross-repo MR effect: a push to a source branch
210// refreshes refs/merge-requests/N/head in every target repo, by fetching —
211// the target owns the objects, so the MR outlives the fork. This is the only
212// place a hook writes outside its own repository.
213func (s *Server) postReceive(req Request) {
214 pushedRepo, pushedRepoErr := s.st.RepoByID(req.RepoID)
215 if pushedRepoErr == nil {
216 s.adoptDefaultBranch(&pushedRepo, req.Updates)
217 }
218 for _, u := range req.Updates {
219 // Every ref update is an event webhooks can subscribe to.
220 s.st.RecordEvent(req.RepoID, req.UserID, "push", fmt.Sprintf(
221 `{"ref":%q,"old":%q,"new":%q,"forced":%v,"deleted":%v}`,
222 u.Ref, u.Old, u.New, u.IsForce, u.IsDelete))
223
224 // Any ref update — branch or tag — schedules the push mirrors.
225 s.st.MarkMirrorsDirty(req.RepoID, "push")
226
227 // Tag pushes run the tag-triggered CI jobs.
228 if tag, ok := strings.CutPrefix(u.Ref, "refs/tags/"); ok && !u.IsDelete && pushedRepoErr == nil {
229 s.queueTagBuilds(pushedRepo, req.UserID, tag, u.New)
230 }
231
232 branch, ok := cutHeads(u.Ref)
233 if !ok {
234 continue
235 }
236 // Commits landing on the default branch act on issue references
237 // in their messages (closes #N, plain #N).
238 if pushedRepoErr == nil && branch == pushedRepo.DefaultBranch && !u.IsDelete {
239 dir := control.RepoDir(s.cfg.Server.Root, pushedRepo.OwnerName, pushedRepo.Name)
240 control.ProcessCommitMessages(s.st, dir, pushedRepo, req.UserID, u.Old, u.New)
241 control.RecordLandedCommits(s.st, dir, pushedRepo, u.Old, u.New)
242 }
243 // A branch push with a .gitbay/ci.yml queues one build per job.
244 if pushedRepoErr == nil && !u.IsDelete {
245 s.queueBuilds(pushedRepo, req.UserID, branch, u.Old, u.New)
246 }
247 if u.IsForce {
248 s.st.Audit(req.UserID, "push.forced", map[string]any{
249 "repo": req.RepoID, "ref": u.Ref, "old": u.Old, "new": u.New})
250 }
251 mrs, err := s.st.OpenMRsBySource(req.RepoID, branch)
252 if err != nil {
253 slog.Error("post-receive: listing MRs", "err", err)
254 continue
255 }
256 srcRepo, err := s.st.RepoByID(req.RepoID)
257 if err != nil {
258 continue
259 }
260 srcDir := control.RepoDir(s.cfg.Server.Root, srcRepo.OwnerName, srcRepo.Name)
261 for _, mr := range mrs {
262 target, err := s.st.RepoByID(mr.RepoID)
263 if err != nil {
264 continue
265 }
266 if u.IsDelete {
267 if mr.State == "open" {
268 s.st.SetMRState(mr.ID, "source_gone")
269 }
270 continue // head ref retained: the diff stays viewable
271 }
272 dstDir := control.RepoDir(s.cfg.Server.Root, target.OwnerName, target.Name)
273 headRef := fmt.Sprintf("refs/merge-requests/%d/head", mr.Number)
274 if err := gitutil.FetchInto(dstDir, srcDir, u.New, headRef); err != nil {
275 slog.Error("post-receive: refreshing MR head", "mr", mr.Number, "err", err)
276 continue
277 }
278 // The merge base as it stands now, so a later range-diff
279 // compares each revision against the target it was written
280 // on rather than against today's. Best-effort: a base that
281 // cannot be worked out costs precision, not the record.
282 base, err := gitutil.MergeBase(dstDir, "refs/heads/"+mr.TargetRef, headRef)
283 if err != nil {
284 base = ""
285 }
286 if err := s.st.UpdateMRHead(mr.ID, u.New, base, sameChange(dstDir, mr, base, u.New)); err != nil {
287 slog.Error("post-receive: recording MR head", "mr", mr.Number, "err", err)
288 }
289 if srcRepo.ID != target.ID {
290 control.QueueMRBuilds(s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
291 target, req.UserID, mr.Number, u.New)
292 }
293 if mr.State == "source_gone" {
294 s.st.SetMRState(mr.ID, "open") // branch came back
295 }
296 }
297 }
298}
299
300// adoptDefaultBranch moves an unborn HEAD to the first branch a push
301// creates. A repository is initialised with HEAD at the stored default,
302// and a first push of master or trunk left HEAD naming a branch that did
303// not exist: clones checked out nothing and every surface asked git for
304// a branch that was not there (#189). A push that includes the default
305// branch itself needs nothing.
306func (s *Server) adoptDefaultBranch(repo *store.Repo, updates []policy.RefUpdate) {
307 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
308 if _, err := gitutil.ResolveRef(dir, "refs/heads/"+repo.DefaultBranch); err == nil {
309 return
310 }
311 for _, u := range updates {
312 branch, ok := cutHeads(u.Ref)
313 if !ok || u.IsDelete || !gitutil.ZeroSHA(u.Old) {
314 continue
315 }
316 if err := gitutil.SetHead(dir, branch); err != nil {
317 slog.Error("post-receive: moving HEAD", "repo", repo.Path(), "err", err)
318 return
319 }
320 if err := s.st.UpdateDefaultBranch(repo.ID, branch); err != nil {
321 slog.Error("post-receive: recording default branch", "repo", repo.Path(), "err", err)
322 return
323 }
324 repo.DefaultBranch = branch
325 return
326 }
327}
328
329// sameChange reports whether the new head proposes the diff the old one
330// did: the patch-id of each revision against its own merge base. A
331// rebase onto a moved target changes every sha and nothing about the
332// change, and the reviews of it should not go stale for that (#198).
333// Any doubt answers false, which is the old behaviour.
334func sameChange(dir string, mr store.MR, newBase, newHead string) bool {
335 if mr.HeadSHA == "" || newBase == "" || mr.HeadSHA == newHead {
336 return false
337 }
338 oldBase, err := gitutil.MergeBase(dir, "refs/heads/"+mr.TargetRef, mr.HeadSHA)
339 if err != nil {
340 return false
341 }
342 oldID, err := gitutil.PatchID(dir, oldBase, mr.HeadSHA)
343 if err != nil || oldID == "" {
344 return false
345 }
346 newID, err := gitutil.PatchID(dir, newBase, newHead)
347 return err == nil && newID == oldID
348}
349
350// queueBuilds queues the push jobs for a branch update. The work is
351// shared with the merge path, which moves a ref without reaching a hook.
352func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
353 control.QueueBranchBuilds(
354 s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
355 repo, userID, branch, old, sha, time.Now())
356}
357
358// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
359// The build records the tag as its ref and the peeled commit as its sha,
360// so statuses land on the commit, not an annotated tag object.
361func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
362 dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
363 sha, err := gitutil.PeelToCommit(dir, pushed)
364 if err != nil {
365 return
366 }
367 raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
368 if err != nil {
369 return
370 }
371 jobs, err := ci.Parse(raw)
372 if err != nil {
373 return // the branch push already reported ci/config
374 }
375 for _, j := range jobs {
376 if j.Tags == "" {
377 continue
378 }
379 if ok, _ := path.Match(j.Tags, tag); !ok {
380 continue
381 }
382 steps, _ := json.Marshal(j.Steps)
383 n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
384 if err != nil {
385 slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
386 continue
387 }
388 url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
389 s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
390 }
391}
392
393func cutHeads(ref string) (string, bool) {
394 const p = "refs/heads/"
395 if len(ref) > len(p) && ref[:len(p)] == p {
396 return ref[len(p):], true
397 }
398 return "", false
399}
400
401// Ask sends one request from the hook process to the daemon. stream is
402// called if the daemon asks for the incoming commit objects; it hands each
403// commit to the callback, which writes it on the wire.
404func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
405 conn, err := net.Dial("unix", socketPath)
406 if err != nil {
407 return Response{}, err
408 }
409 defer conn.Close()
410 enc := json.NewEncoder(conn)
411 dec := json.NewDecoder(conn)
412 if err := enc.Encode(req); err != nil {
413 return Response{}, err
414 }
415 var resp Response
416 if err := dec.Decode(&resp); err != nil {
417 return Response{}, err
418 }
419 if !resp.NeedCommits {
420 return resp, nil
421 }
422 if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
423 return Response{}, err
424 }
425 if err := enc.Encode(RawCommit{}); err != nil { // end of stream
426 return Response{}, err
427 }
428 err = dec.Decode(&resp)
429 return resp, err
430}
431
432// WriteHookScripts (re)generates the shared hooks directory. Called at
433// daemon startup so a moved binary self-heals; every repo points here via
434// core.hooksPath.
435func WriteHookScripts(hooksDir, gitbaydPath string) error {
436 if err := os.MkdirAll(hooksDir, 0o755); err != nil {
437 return err
438 }
439 for _, hook := range []string{"pre-receive", "post-receive"} {
440 script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
441 if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
442 return err
443 }
444 }
445 return nil
446}