internal/hookd/hookd.go
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}