internal/hookd/hookd.go

b347d6c8c464e3c965455795f4e22e0aaeed0652
gitbay/internal/hookd/hookd.go history · blame · raw

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