cmd/gitbayd/hook.go

d6d57309d9ddb202b5c9a29ff4f4d22c000f3874
gitbay/cmd/gitbayd/hook.go history · blame · raw

188 lines · 5580 bytes

  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				Updates: updates,
175			}, func(emit func(hookd.RawCommit) error) error {
176				return streamIncomingCommits(updates, emit)
177			})
178			if err != nil {
179				return fmt.Errorf("gitbay daemon unreachable: %w", err)
180			}
181			if !resp.Allow {
182				fmt.Fprintln(os.Stderr, resp.Message)
183				os.Exit(1)
184			}
185			return nil
186		},
187	}
188}