coverage~bigbes/sr-ht-compare9720ccc2gitx/gitx.go

Coverage
94.9% 37/39 statements
Δ
Blob
6142bc0
Uncovered L77-L78L136-L137
1 // Package gitx is the git access layer of diff.sr.ht. It opens bare
2 // repositories on disk with go-git and answers ref, log, and diff queries
3 // entirely in-process (no git binary is executed at runtime). Every operation
4 // is bounded by a context timeout, generated patch text is capped in size (so
5 // a pathological diff cannot be streamed unbounded to a browser), and every
6 // user-controlled revision is validated with core.ValidRef before it reaches
7 // go-git's revision parser.
8 package gitx
9
10 import (
11 "context"
12 "fmt"
13 "os"
14 "path/filepath"
15 "strings"
16 "time"
17
18 "github.com/go-git/go-git/v5"
19 "github.com/go-git/go-git/v5/plumbing/object"
20
21 "sourcecraft.dev/bigbes/sr-ht-compare/core"
22 )
23
24 const (
25 // defaultTimeout bounds a single gitx operation.
26 defaultTimeout = 10 * time.Second
27 // pageDiffLimit caps an in-page rendered diff; overflow sets Truncated.
28 pageDiffLimit = 5 << 20 // 5 MiB
29 // rawDiffLimit caps a downloadable .patch.
30 rawDiffLimit = 50 << 20 // 50 MiB
31 // shortSHALen is how many hex chars an abbreviated SHA carries.
32 shortSHALen = 8
33 )
34
35 // diffOpts are the go-git tree-diff options used everywhere: rename detection
36 // on, matching git's default behaviour.
37 var diffOpts = object.DefaultDiffTreeOptions
38
39 // Repo is a handle to a bare git repository on disk. It is safe for concurrent
40 // use: go-git's object reads are read-only and the struct holds no mutable
41 // per-request state.
42 type Repo struct {
43 dir string
44 repo *git.Repository
45
46 // limitOverride, when > 0, replaces the diff-family byte cap. It exists so
47 // tests can exercise truncation with a tiny cap instead of huge fixtures.
48 limitOverride int64
49
50 // timeout overrides defaultTimeout when > 0 (tests may shorten it).
51 timeout time.Duration
52 }
53
54 // Open validates owner/name, resolves the bare-repo path under reposRoot,
55 // verifies it looks like a bare repository (its HEAD file exists), and opens it
56 // with go-git. Invalid names and missing/broken repositories all yield
57 // core.ErrNotFound, so repository existence is never leaked and no crafted name
58 // can escape reposRoot: the validators reject '/', '..' and a leading '-'
59 // before any path is built.
60 19 func Open(reposRoot, owner, name string) (*Repo, error) {
61 19 if !core.ValidOwner(owner) {
62 3 return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound)
63 3 }
64 16 if !core.ValidRepoName(name) {
65 3 return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound)
66 3 }
67
68 13 dir := filepath.Join(reposRoot, "~"+owner, name)
69 13
70 13 // Cheap bare-repo sanity check before handing the path to go-git.
71 13 if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() {
72 2 return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name)
73 2 }
74
75 11 repo, err := git.PlainOpen(dir)
76 11 if err != nil {
77 0 return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err)
78 0 }
79 11 return &Repo{dir: dir, repo: repo}, nil
80 }
81
82 // Dir returns the on-disk path of the bare repository.
83 1 func (r *Repo) Dir() string { return r.dir }
84
85 // withTimeout derives a per-operation timeout context.
86 24 func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
87 24 d := r.timeout
88 24 if d <= 0 {
89 24 d = defaultTimeout
90 24 }
91 24 return context.WithTimeout(ctx, d)
92 }
93
94 // diffLimit returns the effective byte cap for a diff-family command, honoring
95 // a test override.
96 8 func (r *Repo) diffLimit(def int64) int64 {
97 8 if r.limitOverride > 0 {
98 1 return r.limitOverride
99 1 }
100 7 return def
101 }
102
103 // badRef wraps a go-git revision-resolution failure as core.ErrBadRef.
104 1 func badRef(rev string, err error) error {
105 1 return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err)
106 1 }
107
108 // commitInfo projects a go-git commit into the package's CommitInfo.
109 10 func commitInfo(c *object.Commit) *CommitInfo {
110 10 subject, body := splitMessage(c.Message)
111 10 sha := c.Hash.String()
112 10 short := sha
113 10 if len(short) > shortSHALen {
114 10 short = short[:shortSHALen]
115 10 }
116 10 ci := &CommitInfo{
117 10 SHA: sha,
118 10 ShortSHA: short,
119 10 AuthorName: c.Author.Name,
120 10 AuthorEmail: c.Author.Email,
121 10 Date: c.Author.When,
122 10 Subject: subject,
123 10 Body: body,
124 10 }
125 11 for _, p := range c.ParentHashes {
126 11 ci.ParentSHAs = append(ci.ParentSHAs, p.String())
127 11 }
128 10 return ci
129 }
130
131 // splitMessage splits a commit message into its subject (first line) and body
132 // (the remainder, with the separating blank line removed).
133 10 func splitMessage(msg string) (subject, body string) {
134 10 msg = strings.TrimRight(msg, "\n")
135 10 if i := strings.IndexByte(msg, '\n'); i >= 0 {
136 0 return msg[:i], strings.TrimLeft(msg[i+1:], "\n")
137 0 }
138 10 return msg, ""
139 }
140
141 // cutPatch enforces the size cap on generated patch text. go-git materializes
142 // the whole patch in memory, so the cap is applied after generation. To keep
143 // the browser-side parser from ever seeing a torn hunk, the text is cut at a
144 // file boundary: the last "\ndiff --git " that starts at or before the limit.
145 // If a single leading file already exceeds the limit there is no earlier
146 // boundary and the text is cut hard at the limit.
147 12 func cutPatch(text string, limit int64) (string, bool) {
148 12 if limit <= 0 || int64(len(text)) <= limit {
149 9 return text, false
150 9 }
151 3 head := text[:limit]
152 3 // A unified-diff file header is the only place "diff --git" starts a line;
153 3 // content lines always carry a +/-/space prefix after the newline. Cutting
154 3 // before the last such header keeps only whole files.
155 3 if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 {
156 1 return text[:idx+1], true
157 1 }
158 2 return head, true
159 }