Commit d99ece54b6

d99ece54b69ad6047c2be2d584357471e3c8b27e

parent: 3a7b3219b3

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-03 16:49 UTC

gitutil: bound git archive by time and size

Archive ran git with no context and streamed to the client for as long
as the client stayed connected; Grep had a timeout, Archive did not.
It now runs under a two-minute deadline and a 512 MiB cap, kills git
past either, and reports which bound was hit instead of ending the
stream short.

Closes #124

Layout: unified · split

internal/gitutil/archive_test.go added +36
@@ -0,0 +1,36 @@
1package gitutil
2
3import (
4 "bytes"
5 "errors"
6 "testing"
7)
8
9// An archive is bounded: past MaxArchiveBytes git is stopped and the
10// caller gets ErrArchiveTooLarge rather than a truncated stream (#124).
11func TestArchiveCap(t *testing.T) {
12 dir := t.TempDir()
13 git(t, dir, "init", "-q", "-b", "main")
14 write(t, dir, "big.txt", string(bytes.Repeat([]byte("gitbay archive cap test line\n"), 4000)))
15 git(t, dir, "add", ".")
16 git(t, dir, "commit", "-q", "-m", "base")
17
18 var out bytes.Buffer
19 if err := Archive(dir, "main", "x", &out); err != nil {
20 t.Fatalf("archive: %v", err)
21 }
22 if out.Len() < 2 || out.Bytes()[0] != 0x1f || out.Bytes()[1] != 0x8b {
23 t.Fatalf("not gzip output: %d bytes", out.Len())
24 }
25
26 defer func(v int64) { MaxArchiveBytes = v }(MaxArchiveBytes)
27 MaxArchiveBytes = 64
28 out.Reset()
29 err := Archive(dir, "main", "x", &out)
30 if !errors.Is(err, ErrArchiveTooLarge) {
31 t.Fatalf("over the cap: err=%v, %d bytes written", err, out.Len())
32 }
33 if int64(out.Len()) > 64 {
34 t.Fatalf("wrote %d bytes past a 64-byte cap", out.Len())
35 }
36}
internal/gitutil/read.go +46 −4
@@ -2,11 +2,14 @@ package gitutil
2 2
3import ( 3import (
4 "bytes" 4 "bytes"
5 "context"
6 "errors"
5 "fmt" 7 "fmt"
6 "io" 8 "io"
7 "os/exec" 9 "os/exec"
8 "strconv" 10 "strconv"
9 "strings" 11 "strings"
12 "time"
10) 13)
11 14
12type TreeEntry struct { 15type TreeEntry struct {
@@ -101,11 +104,50 @@ func Refs(dir, kind string) ([]Ref, error) {
101 return refs, nil 104 return refs, nil
102} 105}
103 106
104// Archive streams a tar.gz of ref to w. 107// Bounds on one archive: a request cannot hold git and a goroutine for
108// longer than this, or stream more than this, however large the
109// repository or however slowly the client reads (#124).
110const archiveTimeout = 2 * time.Minute
111
112var MaxArchiveBytes int64 = 512 << 20
113
114var ErrArchiveTooLarge = errors.New("archive exceeds the size limit")
115
116// Archive streams a tar.gz of ref to w, within archiveTimeout and
117// MaxArchiveBytes. Past either, git is killed and the error says which.
105func Archive(dir, ref, prefix string, w io.Writer) error { 118func Archive(dir, ref, prefix string, w io.Writer) error {
106 cmd := exec.Command("git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref) 119 ctx, cancel := context.WithTimeout(context.Background(), archiveTimeout)
107 cmd.Stdout = w 120 defer cancel()
108 return cmd.Run() 121 cmd := exec.CommandContext(ctx, "git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref)
122 lw := &cappedWriter{w: w, left: MaxArchiveBytes, stop: cancel}
123 cmd.Stdout = lw
124 err := cmd.Run()
125 switch {
126 case lw.exceeded:
127 return ErrArchiveTooLarge
128 case errors.Is(ctx.Err(), context.DeadlineExceeded):
129 return fmt.Errorf("git archive: timed out after %s", archiveTimeout)
130 }
131 return err
132}
133
134// cappedWriter passes bytes through until the cap, then stops the
135// producer instead of writing a truncated tail.
136type cappedWriter struct {
137 w io.Writer
138 left int64
139 stop func()
140 exceeded bool
141}
142
143func (c *cappedWriter) Write(p []byte) (int, error) {
144 if int64(len(p)) > c.left {
145 c.exceeded = true
146 c.stop()
147 return 0, ErrArchiveTooLarge
148 }
149 c.left -= int64(len(p))
150 return c.w.Write(p)
109} 151}
110 152
111// ShowPatch returns the stat+patch text for one commit. 153// ShowPatch returns the stat+patch text for one commit.