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