coverage~bigbes/sr-ht-spec3cb1c03dhooks/hook.go

Coverage
86.5% 122/141 statements
Δ
Blob
397bbfd
1 package hooks
2
3 import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "time"
13 )
14
15 // Mode is which git hook this process is acting as.
16 type Mode string
17
18 const (
19 ModePreReceive Mode = "pre-receive"
20 ModeUpdate Mode = "update"
21 ModePostReceive Mode = "post-receive"
22 )
23
24 // Modes is every hook this package installs, in the order git runs them.
25 82 func Modes() []Mode { return []Mode{ModePreReceive, ModeUpdate, ModePostReceive} }
26
27 // Exit codes. git treats any non-zero exit from pre-receive or update as a
28 // refusal; it ignores post-receive's entirely.
29 const (
30 exitOK = 0
31 exitRefused = 1
32 exitUsage = 2
33 )
34
35 // ModeFromArgs decides whether this process is a git hook, and which one.
36 //
37 // Two spellings are accepted, and they are the same mechanism seen from two
38 // sides. Install writes each hook as a symlink to the specsrht binary, so git
39 // execs it with argv[0] naming the hook — that is the production path, and it
40 // is why there is no generated shell stub to keep in sync with this package.
41 // The explicit "specsrht hook <name>" form exists so an operator can run the
42 // same code by hand against a repository, which is otherwise impossible to do
43 // without creating a symlink.
44 //
45 // It returns the mode, the hook's own arguments, and whether this is a hook
46 // invocation at all.
47 70 func ModeFromArgs(args []string) (Mode, []string, bool) {
48 70 if len(args) == 0 {
49 1 return "", nil, false
50 1 }
51 69 if m, ok := modeNamed(filepath.Base(args[0])); ok {
52 63 return m, args[1:], true
53 63 }
54 6 if len(args) >= 3 && args[1] == "hook" {
55 1 if m, ok := modeNamed(args[2]); ok {
56 1 return m, args[3:], true
57 1 }
58 }
59 5 return "", nil, false
60 }
61
62 70 func modeNamed(s string) (Mode, bool) {
63 126 for _, m := range Modes() {
64 126 if string(m) == s {
65 64 return m, true
66 64 }
67 }
68 6 return "", false
69 }
70
71 // Runtime is everything Run touches outside its own package, so a test can
72 // drive a hook without a real push, a real environment or a real repository.
73 type Runtime struct {
74 // Args is the full argument vector, argv[0] included.
75 Args []string
76
77 // Env, Stdin, Stderr, Getwd and EvalSymlinks default to the process's own
78 // when nil.
79 Env Lookup
80 Stdin io.Reader
81 Stderr io.Writer
82 Getwd func() (string, error)
83 EvalSymlinks func(string) (string, error)
84
85 // PushID correlates the hooks of one push. It defaults to the pid of the
86 // receive-pack process this hook is a child of.
87 PushID func() string
88
89 // DialTimeout and Timeout override the client's defaults.
90 DialTimeout time.Duration
91 Timeout time.Duration
92 }
93
94 37 func (rt Runtime) withDefaults() Runtime {
95 37 if rt.Env == nil {
96 25 rt.Env = os.LookupEnv
97 25 }
98 37 if rt.Stdin == nil {
99 25 rt.Stdin = os.Stdin
100 25 }
101 37 if rt.Stderr == nil {
102 24 rt.Stderr = os.Stderr
103 24 }
104 37 if rt.Getwd == nil {
105 25 rt.Getwd = os.Getwd
106 25 }
107 37 if rt.EvalSymlinks == nil {
108 25 rt.EvalSymlinks = filepath.EvalSymlinks
109 25 }
110 37 if rt.PushID == nil {
111 25 rt.PushID = func() string { return strconv.Itoa(os.Getppid()) }
112 }
113 37 return rt
114 }
115
116 // Run executes this process as a git hook and returns the exit code.
117 //
118 // It never returns an error: a hook communicates by writing to standard error
119 // — which git forwards to the pushing client, prefixed with "remote: " — and
120 // by its exit status. Everything it has to say is therefore said here, in full
121 // sentences, because this text is the entire user interface of a failed push.
122 37 func Run(rt Runtime) int {
123 37 rt = rt.withDefaults()
124 37
125 37 mode, args, ok := ModeFromArgs(rt.Args)
126 37 if !ok {
127 1 fmt.Fprintf(rt.Stderr, "not a git hook invocation; run this binary as %s, %s or %s\n",
128 1 ModePreReceive, ModeUpdate, ModePostReceive)
129 1 return exitUsage
130 1 }
131
132 36 ctx := context.Background()
133 36 switch mode {
134 17 case ModePreReceive:
135 17 return runPreReceive(ctx, rt)
136 14 case ModeUpdate:
137 14 return runUpdate(ctx, rt, args)
138 5 case ModePostReceive:
139 5 return runPostReceive(ctx, rt)
140 0 default:
141 0 fmt.Fprintf(rt.Stderr, "unhandled hook %q\n", mode)
142 0 return exitUsage
143 }
144 }
145
146 // context assembles the facts every call needs: which repository, which
147 // socket, which credential, which push.
148 type hookContext struct {
149 repo string
150 socket string
151 cred Credential
152 push string
153 client Client
154 }
155
156 34 func (rt Runtime) hookContext() (hookContext, error) {
157 34 repo, err := RepoDir(rt.Env, rt.Getwd, rt.EvalSymlinks)
158 34 if err != nil {
159 0 return hookContext{}, err
160 0 }
161 34 cred, err := CredentialFromEnv(rt.Env)
162 34 if err != nil {
163 1 return hookContext{}, err
164 1 }
165 33 socket := ResolveSocket(rt.Env, repo)
166 33 return hookContext{
167 33 repo: repo,
168 33 socket: socket,
169 33 cred: cred,
170 33 push: rt.PushID(),
171 33 client: Client{Socket: socket, DialTimeout: rt.DialTimeout, Timeout: rt.Timeout},
172 33 }, nil
173 }
174
175 // runPreReceive forwards the push options — the only hook git gives them to —
176 // and the full list of proposed updates, so the update calls that follow know
177 // whether validation was waived. It rejects nothing on content; it rejects on
178 // not being able to talk to the daemon, because failing here costs the pusher
179 // one message instead of one per ref.
180 17 func runPreReceive(ctx context.Context, rt Runtime) int {
181 17 updates, err := readRefUpdates(rt.Stdin)
182 17 if err != nil {
183 1 writeMisconfigured(rt.Stderr, err)
184 1 return exitRefused
185 1 }
186 16 if len(updates) == 0 {
187 0 // git does not run pre-receive with an empty command list; if it ever
188 0 // does, there is nothing to record and nothing to refuse.
189 0 return exitOK
190 0 }
191
192 16 opts, err := PushOptions(rt.Env)
193 16 if err != nil {
194 0 writeMisconfigured(rt.Stderr, err)
195 0 return exitRefused
196 0 }
197
198 16 hc, err := rt.hookContext()
199 16 if err != nil {
200 0 writeMisconfigured(rt.Stderr, err)
201 0 return exitRefused
202 0 }
203
204 16 resp, err := hc.client.Call(ctx, Request{
205 16 Version: ProtocolVersion,
206 16 Method: MethodPushOptions,
207 16 Repo: hc.repo,
208 16 Push: hc.push,
209 16 Credential: hc.cred,
210 16 Options: opts,
211 16 Updates: updates,
212 16 })
213 16 if err != nil {
214 2 writeUnreachable(rt.Stderr, hc, "", err)
215 2 return exitRefused
216 2 }
217 14 return report(rt.Stderr, hc, "", resp)
218 }
219
220 // runUpdate is the rejecting hook: the refs rule and content validation for
221 // one ref, before that ref moves. It is the earliest point at which the daemon
222 // can read what is being pushed — during pre-receive the objects are still in
223 // receive-pack's quarantine and invisible to any other process.
224 14 func runUpdate(ctx context.Context, rt Runtime, args []string) int {
225 14 if len(args) != 3 {
226 1 writeMisconfigured(rt.Stderr, fmt.Errorf(
227 1 "the update hook takes <ref> <old> <new>, got %d argument(s)", len(args)))
228 1 return exitRefused
229 1 }
230 13 update := RefUpdate{Ref: args[0], Old: args[1], New: args[2]}
231 13
232 13 hc, err := rt.hookContext()
233 13 if err != nil {
234 1 writeMisconfigured(rt.Stderr, err)
235 1 return exitRefused
236 1 }
237
238 12 resp, err := hc.client.Call(ctx, Request{
239 12 Version: ProtocolVersion,
240 12 Method: MethodValidateRef,
241 12 Repo: hc.repo,
242 12 Push: hc.push,
243 12 Credential: hc.cred,
244 12 Updates: []RefUpdate{update},
245 12 })
246 12 if err != nil {
247 1 writeUnreachable(rt.Stderr, hc, update.Ref, err)
248 1 return exitRefused
249 1 }
250 11 return report(rt.Stderr, hc, update.Ref, resp)
251 }
252
253 // runPostReceive tells the daemon the push landed. It cannot reject anything —
254 // git has already moved the refs and ignores this exit status — so a failure
255 // is a warning, and the reconciler is the backstop that repairs the index rev
256 // stamp this call was supposed to advance.
257 5 func runPostReceive(ctx context.Context, rt Runtime) int {
258 5 updates, err := readRefUpdates(rt.Stdin)
259 5 if err != nil {
260 0 writeNotNotified(rt.Stderr, err)
261 0 return exitOK
262 0 }
263 5 if len(updates) == 0 {
264 0 return exitOK
265 0 }
266
267 5 hc, err := rt.hookContext()
268 5 if err != nil {
269 0 writeNotNotified(rt.Stderr, err)
270 0 return exitOK
271 0 }
272
273 5 resp, err := hc.client.Call(ctx, Request{
274 5 Version: ProtocolVersion,
275 5 Method: MethodPushed,
276 5 Repo: hc.repo,
277 5 Push: hc.push,
278 5 Credential: hc.cred,
279 5 Updates: updates,
280 5 })
281 5 switch {
282 1 case err != nil:
283 1 writeNotNotified(rt.Stderr, err)
284 0 case resp.Rejected:
285 0 writeNotNotified(rt.Stderr, fmt.Errorf("the daemon refused the notification: %s",
286 0 strings.TrimSpace(resp.Message)))
287 0 case resp.Error != "":
288 0 writeNotNotified(rt.Stderr, fmt.Errorf("the daemon failed to record the push: %s", resp.Error))
289 }
290 5 return exitOK
291 }
292
293 // report turns a well-formed response into an exit code and, when it is not an
294 // acceptance, the text the pusher reads.
295 25 func report(w io.Writer, hc hookContext, ref string, resp Response) int {
296 25 switch {
297 17 case resp.OK:
298 17 return exitOK
299 8 case resp.Rejected:
300 8 // The daemon composed this for a terminal; print it as written rather
301 8 // than wrapping it in a second frame.
302 8 msg := strings.TrimRight(resp.Message, "\n")
303 8 fmt.Fprintf(w, "%s\n", msg)
304 8 return exitRefused
305 0 default:
306 0 writeUnreachable(w, hc, ref, fmt.Errorf("%s", resp.Error))
307 0 return exitRefused
308 }
309 }
310
311 // readRefUpdates parses the "<old> <new> <ref>" lines git feeds pre-receive and
312 // post-receive on standard input.
313 //
314 // Standard input is read to the end even on a malformed line: git writes the
315 // whole list before waiting, and a hook that exits early enough leaves it
316 // writing into a closed pipe.
317 22 func readRefUpdates(r io.Reader) ([]RefUpdate, error) {
318 22 var (
319 22 updates []RefUpdate
320 22 bad error
321 22 )
322 22 sc := bufio.NewScanner(io.LimitReader(r, maxMessageBytes))
323 22 for sc.Scan() {
324 22 line := strings.TrimSpace(sc.Text())
325 22 if line == "" {
326 0 continue
327 }
328 22 fields := strings.Fields(line)
329 22 if len(fields) != 3 {
330 1 if bad == nil {
331 1 bad = fmt.Errorf("git sent %q, which is not \"<old> <new> <ref>\"", line)
332 1 }
333 1 continue
334 }
335 21 updates = append(updates, RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]})
336 }
337 22 if err := sc.Err(); err != nil {
338 0 return nil, fmt.Errorf("read the ref list git sent on standard input: %w", err)
339 0 }
340 22 if bad != nil {
341 1 return nil, bad
342 1 }
343 21 return updates, nil
344 }
345
346 // writeMisconfigured reports a problem with how the hook itself is wired: no
347 // principal in the environment, an unreadable repository path, a nonsensical
348 // argument vector. None of it is the pusher's fault and none of it is fixed by
349 // changing what they pushed, so the message says so.
350 3 func writeMisconfigured(w io.Writer, err error) {
351 3 fmt.Fprintf(w, "spec.sr.ht refused this push: its receive hook is misconfigured.\n\n")
352 3 fmt.Fprintf(w, " %v\n\n", err)
353 3 fmt.Fprintf(w, "This is a server-side wiring problem, not a problem with what you\n")
354 3 fmt.Fprintf(w, "pushed. Nothing was written.\n")
355 3 }
356
357 // writeUnreachable is the fail-closed message: the daemon could not be reached,
358 // or could not answer, so the push is refused unvalidated rather than accepted
359 // unvalidated.
360 3 func writeUnreachable(w io.Writer, hc hookContext, ref string, cause error) {
361 3 fmt.Fprintf(w, "spec.sr.ht could not validate this push, so it was refused.\n\n")
362 3 fmt.Fprintf(w, " repository: %s\n", hc.repo)
363 3 if ref != "" {
364 1 fmt.Fprintf(w, " ref: %s\n", ref)
365 1 }
366 3 fmt.Fprintf(w, " daemon: %s\n\n", hc.socket)
367 3 fmt.Fprintf(w, " %v\n\n", cause)
368 3 fmt.Fprintf(w, "Nothing was written; the ref still points where it did.\n\n")
369 3 fmt.Fprintf(w, "spec.sr.ht refuses a push it cannot validate rather than accepting it\n")
370 3 fmt.Fprintf(w, "unchecked: a refused push costs you one command, an unvalidated one\n")
371 3 fmt.Fprintf(w, "corrupts the document registry silently and surfaces weeks later.\n")
372 3 fmt.Fprintf(w, "--push-option=%s does not help here — it waives frontmatter\n", OptionSkipValidation)
373 3 fmt.Fprintf(w, "and document-id checks, not the daemon that performs them.\n\n")
374 3 fmt.Fprintf(w, "Start the spec.sr.ht daemon and push again.\n")
375 }
376
377 // writeNotNotified is post-receive's only failure mode. The refs are already
378 // updated and cannot be taken back, so this warns and names the backstop.
379 1 func writeNotNotified(w io.Writer, cause error) {
380 1 fmt.Fprintf(w, "warning: spec.sr.ht was not told that this push landed.\n")
381 1 fmt.Fprintf(w, "warning: %v\n", cause)
382 1 fmt.Fprintf(w, "warning: the refs are updated and your content is safe, but this space's\n")
383 1 fmt.Fprintf(w, "warning: search index is now stale. The reconciler repairs it at the next\n")
384 1 fmt.Fprintf(w, "warning: daemon start and on its periodic pass.\n")
385 1 }