Go · 3464 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package protocol
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "os/exec"
11 "sync"
12 "time"
13 )
14
15 // stderrCap bounds the in-memory stderr drainer's buffer so a chatty
16 // subprocess can't OOM us. Truncated stderr is fine — we only surface
17 // it on error and a few KB is enough to identify the failure.
18 const stderrCap = 16 * 1024
19
20 // Service identifies which git plumbing program we're driving.
21 type Service string
22
23 const (
24 UploadPack Service = "git-upload-pack"
25 ReceivePack Service = "git-receive-pack"
26 )
27
28 // Cmd builds an *exec.Cmd that drives the requested service against the
29 // bare repo at gitDir. AdvertiseRefs flips on the --advertise-refs flag
30 // used by the info/refs response. extraEnv is appended to os.Environ()
31 // — the caller uses it to thread SHITHUB_USER_ID/etc. through git's
32 // environment so post-receive hooks can read them.
33 //
34 // Subprocess lifecycle:
35 // - Killed on ctx cancel (Go ≥ 1.20: cmd.Cancel default kills with
36 // SIGKILL; we tighten to a 250ms WaitDelay so a stuck subprocess
37 // doesn't pin a worker forever).
38 // - Stderr is drained by the helper Drain() so the OS pipe buffer
39 // doesn't fill and deadlock the caller's stdout copy. Captured
40 // stderr is exposed via Stderr() on the returned Process.
41 func Cmd(ctx context.Context, svc Service, gitDir string, advertiseRefs bool, extraEnv []string) *exec.Cmd {
42 args := []string{"--stateless-rpc"}
43 if advertiseRefs {
44 args = append(args, "--advertise-refs")
45 }
46 args = append(args, gitDir)
47 //nolint:gosec // G204: svc is an enum from a fixed set; gitDir is constructed via storage.RepoFS path validation.
48 cmd := exec.CommandContext(ctx, string(svc), args...)
49 cmd.Env = extraEnv
50 cmd.WaitDelay = 250 * time.Millisecond
51 return cmd
52 }
53
54 // DrainStderr starts a goroutine that copies stderr into a bounded ring
55 // buffer; returns a function the caller invokes after Wait() to get the
56 // captured bytes. The cap is `stderrCap`; further writes are silently
57 // discarded.
58 //
59 // Always call this BEFORE Start() and call the returned closure AFTER
60 // Wait() to avoid the deadlock where a verbose stderr fills the OS
61 // pipe.
62 func DrainStderr(cmd *exec.Cmd) func() []byte {
63 pipe, err := cmd.StderrPipe()
64 if err != nil {
65 // Stderr already wired (or another wiring error). Fall back to
66 // a no-op drain so callers don't have to special-case.
67 return func() []byte { return nil }
68 }
69 buf := &cappedBuffer{cap: stderrCap}
70 done := make(chan struct{})
71 go func() {
72 defer close(done)
73 _, _ = io.Copy(buf, pipe)
74 }()
75 return func() []byte {
76 <-done
77 return buf.Bytes()
78 }
79 }
80
81 // cappedBuffer is a bytes.Buffer that drops writes after the cap.
82 type cappedBuffer struct {
83 mu sync.Mutex
84 buf bytes.Buffer
85 cap int
86 }
87
88 func (c *cappedBuffer) Write(p []byte) (int, error) {
89 c.mu.Lock()
90 defer c.mu.Unlock()
91 free := c.cap - c.buf.Len()
92 if free <= 0 {
93 return len(p), nil // accept-and-drop
94 }
95 if len(p) > free {
96 _, _ = c.buf.Write(p[:free])
97 return len(p), nil
98 }
99 return c.buf.Write(p)
100 }
101
102 func (c *cappedBuffer) Bytes() []byte {
103 c.mu.Lock()
104 defer c.mu.Unlock()
105 out := make([]byte, c.buf.Len())
106 copy(out, c.buf.Bytes())
107 return out
108 }
109
110 // ErrSubprocessFailed is returned by Run when the git subprocess exits
111 // non-zero. The captured stderr is wrapped via %s so the operator sees
112 // it in logs without it being escaped through the chain.
113 var ErrSubprocessFailed = errors.New("git subprocess exited non-zero")
114