| 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 |
|
} |