1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package githttp
4
5import (
6 "bufio"
7 "context"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "strings"
14 "time"
15)
16
17// hookDialTimeout bounds connecting to the socket and the whole exchange.
18const (
19 hookDialTimeout = 5 * time.Second
20 hookCallTimeout = 2 * time.Minute
21)
22
23// ErrHookRejected is returned by RunHook when ermite refuses the push.
24var ErrHookRejected = errors.New("push refused")
25
26// RunHook is `ermite hook <hook>`: it reads the ref updates git feeds a
27// receive hook ("<old> <new> <ref>" per line) from in, sends them with the
28// token to ermite over the socket, and writes ermite's message to errOut.
29//
30// For pre-receive it fails closed: a missing socket or token, an unreachable
31// or erroring server, or a refusal all return an error (git then rejects the
32// push). For post-receive the push already happened: failures are reported on
33// errOut and RunHook returns nil.
34func RunHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
35 err := callHook(ctx, hook, in, errOut, socket, token)
36 if err == nil {
37 return nil
38 }
39 if hook == HookPostReceive {
40 _, _ = fmt.Fprintf(errOut, "ermite: %s: %v\n", hook, err)
41 return nil
42 }
43 return err
44}
45
46func callHook(ctx context.Context, hook string, in io.Reader, errOut io.Writer, socket, token string) error {
47 if hook != HookPreReceive && hook != HookPostReceive {
48 return fmt.Errorf("unknown hook %q", hook)
49 }
50 if socket == "" || token == "" {
51 return fmt.Errorf("%w: %s and %s are not set (is this push going through ermite?)", ErrHookRejected, EnvHookSocket, EnvHookToken)
52 }
53 updates, err := readUpdates(in)
54 if err != nil {
55 return fmt.Errorf("%w: %v", ErrHookRejected, err)
56 }
57
58 ctx, cancel := context.WithTimeout(ctx, hookCallTimeout)
59 defer cancel()
60 d := net.Dialer{Timeout: hookDialTimeout}
61 conn, err := d.DialContext(ctx, "unix", socket)
62 if err != nil {
63 return fmt.Errorf("%w: ermite is unreachable: %v", ErrHookRejected, err)
64 }
65 defer func() { _ = conn.Close() }()
66 if dl, ok := ctx.Deadline(); ok {
67 _ = conn.SetDeadline(dl)
68 }
69
70 req, err := encodeLine(HookRequest{Token: token, Hook: hook, Updates: updates})
71 if err != nil {
72 return err
73 }
74 if _, err := conn.Write(req); err != nil {
75 return fmt.Errorf("%w: send to ermite: %v", ErrHookRejected, err)
76 }
77 line, err := readLine(conn)
78 if err != nil {
79 return fmt.Errorf("%w: no reply from ermite: %v", ErrHookRejected, err)
80 }
81 var reply HookReply
82 if err := json.Unmarshal(line, &reply); err != nil {
83 return fmt.Errorf("%w: malformed reply from ermite", ErrHookRejected)
84 }
85 if reply.Message != "" {
86 _, _ = fmt.Fprintln(errOut, reply.Message)
87 }
88 if !reply.OK {
89 return ErrHookRejected
90 }
91 return nil
92}
93
94// readUpdates parses the lines git writes to a receive hook.
95func readUpdates(in io.Reader) ([]HookUpdate, error) {
96 var out []HookUpdate
97 sc := bufio.NewScanner(in)
98 sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
99 for sc.Scan() {
100 line := strings.TrimSpace(sc.Text())
101 if line == "" {
102 continue
103 }
104 f := strings.SplitN(line, " ", 3)
105 if len(f) != 3 {
106 return nil, fmt.Errorf("unexpected hook input %q", line)
107 }
108 out = append(out, HookUpdate{Old: f[0], New: f[1], Ref: f[2]})
109 }
110 if err := sc.Err(); err != nil {
111 return nil, fmt.Errorf("read hook input: %w", err)
112 }
113 return out, nil
114}