cmd/gitbayd/hook.go
190 lines · 5660 bytes
3 symbols in this file
1package main
2
3import (
4 "bufio"
5 "context"
6 "fmt"
7 "io"
8 "os"
9 "os/exec"
10 "strconv"
11 "strings"
12
13 "github.com/spf13/cobra"
14
15 "gitbay.org/gitbay/internal/gitutil"
16 "gitbay.org/gitbay/internal/hookd"
17 "gitbay.org/gitbay/internal/policy"
18 "gitbay.org/gitbay/internal/toolpath"
19)
20
21// incomingSHAs lists the commits this push introduces, in order, without
22// duplicates. It runs in the hook process, which inherits git's quarantine
23// environment — the daemon cannot see these objects yet.
24func incomingSHAs(updates []policy.RefUpdate) ([]string, error) {
25 seen := map[string]bool{}
26 var out []string
27 for _, u := range updates {
28 if u.IsDelete {
29 continue
30 }
31 // Everything reachable from the new tip that no existing ref has.
32 raw, err := exec.Command(toolpath.Look("git"), "rev-list", u.New, "--not", "--all").Output()
33 if err != nil {
34 return nil, fmt.Errorf("rev-list %s: %w", u.New, err)
35 }
36 for _, sha := range strings.Fields(string(raw)) {
37 if seen[sha] {
38 continue
39 }
40 seen[sha] = true
41 out = append(out, sha)
42 }
43 }
44 return out, nil
45}
46
47// streamIncomingCommits reads every incoming commit through one
48// `cat-file --batch` and hands each to emit as it arrives.
49//
50// This used to fork a cat-file per commit and build the whole payload in
51// memory before sending it: a 50k-commit first push to a protected branch
52// forked 50k processes and held 50k raw commits at once (#100). One
53// subprocess now serves the whole push, and nothing is accumulated.
54func streamIncomingCommits(updates []policy.RefUpdate, emit func(hookd.RawCommit) error) error {
55 shas, err := incomingSHAs(updates)
56 if err != nil {
57 return err
58 }
59 if len(shas) == 0 {
60 return nil
61 }
62 // Cancelling kills git on an early return. Without it, bailing out
63 // part-way through a large push leaves git blocked writing into a
64 // pipe nobody is reading and Wait blocked on git.
65 ctx, cancel := context.WithCancel(context.Background())
66 cmd := exec.CommandContext(ctx, toolpath.Look("git"), "cat-file", "--batch")
67 stdin, err := cmd.StdinPipe()
68 if err != nil {
69 cancel()
70 return err
71 }
72 stdout, err := cmd.StdoutPipe()
73 if err != nil {
74 cancel()
75 return err
76 }
77 if err := cmd.Start(); err != nil {
78 cancel()
79 return fmt.Errorf("cat-file --batch: %w", err)
80 }
81 defer cmd.Wait() // second: reaps the process cancel just signalled
82 defer cancel()
83 // Feeding stdin from another goroutine: the pipe buffer is smaller
84 // than 50k object names, so writing them all before reading would
85 // block against a git that is blocked writing its own output.
86 writeErr := make(chan error, 1)
87 go func() {
88 defer stdin.Close()
89 w := bufio.NewWriter(stdin)
90 for _, sha := range shas {
91 if _, err := fmt.Fprintln(w, sha); err != nil {
92 writeErr <- err
93 return
94 }
95 }
96 writeErr <- w.Flush()
97 }()
98
99 r := bufio.NewReader(stdout)
100 for range shas {
101 // Each record is "<oid> <type> <size>\n", then size bytes, then
102 // a newline.
103 header, err := r.ReadString('\n')
104 if err != nil {
105 return fmt.Errorf("cat-file --batch: %w", err)
106 }
107 fields := strings.Fields(header)
108 if len(fields) != 3 {
109 return fmt.Errorf("cat-file --batch: unexpected %q", strings.TrimSpace(header))
110 }
111 size, err := strconv.Atoi(fields[2])
112 if err != nil {
113 return fmt.Errorf("cat-file --batch: bad size in %q", strings.TrimSpace(header))
114 }
115 raw := make([]byte, size)
116 if _, err := io.ReadFull(r, raw); err != nil {
117 return fmt.Errorf("cat-file %s: %w", fields[0], err)
118 }
119 if _, err := r.Discard(1); err != nil {
120 return fmt.Errorf("cat-file %s: %w", fields[0], err)
121 }
122 if err := emit(hookd.RawCommit{SHA: fields[0], Raw: raw}); err != nil {
123 return err
124 }
125 }
126 if err := <-writeErr; err != nil {
127 return fmt.Errorf("cat-file --batch: %w", err)
128 }
129 return nil
130}
131
132// hookCmd runs inside a git hook. It computes git facts here — the hook
133// process inherits git's quarantine environment, so incoming objects are
134// visible — and asks the daemon for a policy decision over the unix socket.
135func hookCmd() *cobra.Command {
136 return &cobra.Command{
137 Use: "hook <pre-receive|post-receive>",
138 Hidden: true,
139 Args: cobra.ExactArgs(1),
140 RunE: func(cmd *cobra.Command, args []string) error {
141 sock := os.Getenv(hookd.EnvSocket)
142 repoID, err1 := strconv.ParseInt(os.Getenv(hookd.EnvRepoID), 10, 64)
143 userID, err2 := strconv.ParseInt(os.Getenv(hookd.EnvUserID), 10, 64)
144 if sock == "" || err1 != nil || err2 != nil {
145 return fmt.Errorf("missing GITBAY_* environment; this command only runs as a git hook")
146 }
147
148 var updates []policy.RefUpdate
149 scanner := bufio.NewScanner(os.Stdin)
150 for scanner.Scan() {
151 fields := strings.Fields(scanner.Text())
152 if len(fields) != 3 {
153 continue
154 }
155 u := policy.RefUpdate{Old: fields[0], New: fields[1], Ref: fields[2]}
156 u.IsDelete = gitutil.ZeroSHA(u.New)
157 if !u.IsDelete && !gitutil.ZeroSHA(u.Old) {
158 anc, err := gitutil.IsAncestor(".", u.Old, u.New)
159 if err != nil {
160 return fmt.Errorf("checking ancestry for %s: %w", u.Ref, err)
161 }
162 u.IsForce = !anc
163 }
164 updates = append(updates, u)
165 }
166 if err := scanner.Err(); err != nil {
167 return err
168 }
169
170 resp, err := hookd.Ask(sock, hookd.Request{
171 Hook: args[0],
172 RepoID: repoID,
173 UserID: userID,
174 Scope: os.Getenv(hookd.EnvScope),
175 Token: os.Getenv(hookd.EnvToken),
176 Updates: updates,
177 }, func(emit func(hookd.RawCommit) error) error {
178 return streamIncomingCommits(updates, emit)
179 })
180 if err != nil {
181 return fmt.Errorf("gitbay daemon unreachable: %w", err)
182 }
183 if !resp.Allow {
184 fmt.Fprintln(os.Stderr, resp.Message)
185 os.Exit(1)
186 }
187 return nil
188 },
189 }
190}