internal/hookd/hookd.go

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

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