internal/hookd/hookd.go

88fc476788ab9c640649427d815217360569b4d9
gitbay/internal/hookd/hookd.go history · blame · raw

425 lines · 13883 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); 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// queueBuilds queues the push jobs for a branch update. The work is
330// shared with the merge path, which moves a ref without reaching a hook.
331func (s *Server) queueBuilds(repo store.Repo, userID int64, branch, old, sha string) {
332	control.QueueBranchBuilds(
333		s.st, s.cfg.Server.Root, s.cfg.Server.SiteURL,
334		repo, userID, branch, old, sha, time.Now())
335}
336
337// queueTagBuilds runs the jobs whose tag pattern matches a pushed tag.
338// The build records the tag as its ref and the peeled commit as its sha,
339// so statuses land on the commit, not an annotated tag object.
340func (s *Server) queueTagBuilds(repo store.Repo, userID int64, tag, pushed string) {
341	dir := control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)
342	sha, err := gitutil.PeelToCommit(dir, pushed)
343	if err != nil {
344		return
345	}
346	raw, err := gitutil.ReadBlob(dir, sha, ci.ConfigPath, 1<<16)
347	if err != nil {
348		return
349	}
350	jobs, err := ci.Parse(raw)
351	if err != nil {
352		return // the branch push already reported ci/config
353	}
354	for _, j := range jobs {
355		if j.Tags == "" {
356			continue
357		}
358		if ok, _ := path.Match(j.Tags, tag); !ok {
359			continue
360		}
361		steps, _ := json.Marshal(j.Steps)
362		n, err := s.st.CreateBuild(repo.ID, j.Name, sha, tag, string(steps), j.Image, "", true)
363		if err != nil {
364			slog.Error("queueing tag build", "repo", repo.Path(), "job", j.Name, "err", err)
365			continue
366		}
367		url := fmt.Sprintf("%s/%s/builds/%d", s.cfg.Server.SiteURL, repo.Path(), n)
368		s.st.SetCommitStatus(repo.ID, sha, "ci/"+j.Name, "pending", "tag "+tag, url, userID)
369	}
370}
371
372func cutHeads(ref string) (string, bool) {
373	const p = "refs/heads/"
374	if len(ref) > len(p) && ref[:len(p)] == p {
375		return ref[len(p):], true
376	}
377	return "", false
378}
379
380// Ask sends one request from the hook process to the daemon. stream is
381// called if the daemon asks for the incoming commit objects; it hands each
382// commit to the callback, which writes it on the wire.
383func Ask(socketPath string, req Request, stream func(emit func(RawCommit) error) error) (Response, error) {
384	conn, err := net.Dial("unix", socketPath)
385	if err != nil {
386		return Response{}, err
387	}
388	defer conn.Close()
389	enc := json.NewEncoder(conn)
390	dec := json.NewDecoder(conn)
391	if err := enc.Encode(req); err != nil {
392		return Response{}, err
393	}
394	var resp Response
395	if err := dec.Decode(&resp); err != nil {
396		return Response{}, err
397	}
398	if !resp.NeedCommits {
399		return resp, nil
400	}
401	if err := stream(func(rc RawCommit) error { return enc.Encode(rc) }); err != nil {
402		return Response{}, err
403	}
404	if err := enc.Encode(RawCommit{}); err != nil { // end of stream
405		return Response{}, err
406	}
407	err = dec.Decode(&resp)
408	return resp, err
409}
410
411// WriteHookScripts (re)generates the shared hooks directory. Called at
412// daemon startup so a moved binary self-heals; every repo points here via
413// core.hooksPath.
414func WriteHookScripts(hooksDir, gitbaydPath string) error {
415	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
416		return err
417	}
418	for _, hook := range []string{"pre-receive", "post-receive"} {
419		script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", gitbaydPath, hook)
420		if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
421			return err
422		}
423	}
424	return nil
425}