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