internal/hookd/hookd.go

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

126 lines · 3458 bytes

  1// Package hookd is the unix-socket bridge between git hooks and the daemon.
  2// The hook process (forged 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 pure policy decision.
  5package hookd
  6
  7import (
  8	"encoding/json"
  9	"fmt"
 10	"net"
 11	"os"
 12	"path/filepath"
 13
 14	"github.com/krazywarez/forge/internal/policy"
 15	"github.com/krazywarez/forge/internal/store"
 16)
 17
 18// Env variable names passed to git transport subprocesses and inherited by
 19// hooks.
 20const (
 21	EnvSocket = "FORGE_HOOK_SOCKET"
 22	EnvRepoID = "FORGE_REPO_ID"
 23	EnvUserID = "FORGE_USER_ID"
 24)
 25
 26type Request struct {
 27	Hook    string             `json:"hook"` // pre-receive | post-receive
 28	RepoID  int64              `json:"repo_id"`
 29	UserID  int64              `json:"user_id"`
 30	Updates []policy.RefUpdate `json:"updates"`
 31}
 32
 33type Response struct {
 34	Allow   bool   `json:"allow"`
 35	Message string `json:"message,omitempty"`
 36}
 37
 38// SocketPath returns the hook socket location under the server root.
 39func SocketPath(root string) string { return filepath.Join(root, "hook.sock") }
 40
 41type Server struct {
 42	st *store.Store
 43}
 44
 45// Serve listens on the unix socket until the listener is closed.
 46func Serve(root string, st *store.Store) (func() error, error) {
 47	path := SocketPath(root)
 48	os.Remove(path)
 49	ln, err := net.Listen("unix", path)
 50	if err != nil {
 51		return nil, err
 52	}
 53	s := &Server{st: st}
 54	go func() {
 55		for {
 56			conn, err := ln.Accept()
 57			if err != nil {
 58				return
 59			}
 60			go s.handle(conn)
 61		}
 62	}()
 63	return ln.Close, nil
 64}
 65
 66func (s *Server) handle(conn net.Conn) {
 67	defer conn.Close()
 68	var req Request
 69	if err := json.NewDecoder(conn).Decode(&req); err != nil {
 70		json.NewEncoder(conn).Encode(Response{Allow: false, Message: "bad hook request"})
 71		return
 72	}
 73	json.NewEncoder(conn).Encode(s.decide(req))
 74}
 75
 76func (s *Server) decide(req Request) Response {
 77	switch req.Hook {
 78	case "pre-receive":
 79		repo, err := s.st.RepoByID(req.RepoID)
 80		if err != nil {
 81			return Response{Allow: false, Message: "unknown repository"}
 82		}
 83		if msg := policy.CheckPush(repo, req.Updates); msg != "" {
 84			return Response{Allow: false, Message: msg}
 85		}
 86		return Response{Allow: true}
 87	case "post-receive":
 88		// Event recording and signature verification enqueue land in M4.
 89		return Response{Allow: true}
 90	default:
 91		return Response{Allow: false, Message: fmt.Sprintf("unknown hook %q", req.Hook)}
 92	}
 93}
 94
 95// Ask sends one request from the hook process to the daemon.
 96func Ask(socketPath string, req Request) (Response, error) {
 97	conn, err := net.Dial("unix", socketPath)
 98	if err != nil {
 99		return Response{}, err
100	}
101	defer conn.Close()
102	if err := json.NewEncoder(conn).Encode(req); err != nil {
103		return Response{}, err
104	}
105	var resp Response
106	if err := json.NewDecoder(conn).Decode(&resp); err != nil {
107		return Response{}, err
108	}
109	return resp, nil
110}
111
112// WriteHookScripts (re)generates the shared hooks directory. Called at
113// daemon startup so a moved binary self-heals; every repo points here via
114// core.hooksPath.
115func WriteHookScripts(hooksDir, forgedPath string) error {
116	if err := os.MkdirAll(hooksDir, 0o755); err != nil {
117		return err
118	}
119	for _, hook := range []string{"pre-receive", "post-receive"} {
120		script := fmt.Sprintf("#!/bin/sh\nexec %q hook %s\n", forgedPath, hook)
121		if err := os.WriteFile(filepath.Join(hooksDir, hook), []byte(script), 0o755); err != nil {
122			return err
123		}
124	}
125	return nil
126}