// Package gitx is the git access layer of compare.sr.ht. It opens bare // repositories on disk with go-git and answers ref, log, and diff queries // entirely in-process (no git binary is executed at runtime). Every operation // is bounded by a context timeout, generated patch text is capped in size (so // a pathological diff cannot be streamed unbounded to a browser), and every // user-controlled revision is validated with core.ValidRef before it reaches // go-git's revision parser. package gitx import ( "context" "fmt" "os" "path/filepath" "strings" "time" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" "go.bigb.es/sourcehut-compare/core" ) const ( // defaultTimeout bounds a single gitx operation. defaultTimeout = 10 * time.Second // pageDiffLimit caps an in-page rendered diff; overflow sets Truncated. pageDiffLimit = 5 << 20 // 5 MiB // rawDiffLimit caps a downloadable .patch. rawDiffLimit = 50 << 20 // 50 MiB // shortSHALen is how many hex chars an abbreviated SHA carries. shortSHALen = 8 ) // diffOpts are the go-git tree-diff options used everywhere: rename detection // on, matching git's default behaviour. var diffOpts = object.DefaultDiffTreeOptions // Repo is a handle to a bare git repository on disk. It is safe for concurrent // use: go-git's object reads are read-only and the struct holds no mutable // per-request state. type Repo struct { dir string repo *git.Repository // limitOverride, when > 0, replaces the diff-family byte cap. It exists so // tests can exercise truncation with a tiny cap instead of huge fixtures. limitOverride int64 // timeout overrides defaultTimeout when > 0 (tests may shorten it). timeout time.Duration } // Open validates owner/name, resolves the bare-repo path under reposRoot, // verifies it looks like a bare repository (its HEAD file exists), and opens it // with go-git. Invalid names and missing/broken repositories all yield // core.ErrNotFound, so repository existence is never leaked and no crafted name // can escape reposRoot: the validators reject '/', '..' and a leading '-' // before any path is built. func Open(reposRoot, owner, name string) (*Repo, error) { if !core.ValidOwner(owner) { return nil, fmt.Errorf("%w: invalid owner", core.ErrNotFound) } if !core.ValidRepoName(name) { return nil, fmt.Errorf("%w: invalid repo name", core.ErrNotFound) } dir := filepath.Join(reposRoot, "~"+owner, name) // Cheap bare-repo sanity check before handing the path to go-git. if fi, err := os.Stat(filepath.Join(dir, "HEAD")); err != nil || fi.IsDir() { return nil, fmt.Errorf("%w: ~%s/%s", core.ErrNotFound, owner, name) } repo, err := git.PlainOpen(dir) if err != nil { return nil, fmt.Errorf("%w: ~%s/%s: %v", core.ErrNotFound, owner, name, err) } return &Repo{dir: dir, repo: repo}, nil } // Dir returns the on-disk path of the bare repository. func (r *Repo) Dir() string { return r.dir } // withTimeout derives a per-operation timeout context. func (r *Repo) withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { d := r.timeout if d <= 0 { d = defaultTimeout } return context.WithTimeout(ctx, d) } // diffLimit returns the effective byte cap for a diff-family command, honoring // a test override. func (r *Repo) diffLimit(def int64) int64 { if r.limitOverride > 0 { return r.limitOverride } return def } // badRef wraps a go-git revision-resolution failure as core.ErrBadRef. func badRef(rev string, err error) error { return fmt.Errorf("%w: %q: %v", core.ErrBadRef, rev, err) } // commitInfo projects a go-git commit into the package's CommitInfo. func commitInfo(c *object.Commit) *CommitInfo { subject, body := splitMessage(c.Message) sha := c.Hash.String() short := sha if len(short) > shortSHALen { short = short[:shortSHALen] } ci := &CommitInfo{ SHA: sha, ShortSHA: short, AuthorName: c.Author.Name, AuthorEmail: c.Author.Email, Date: c.Author.When, Subject: subject, Body: body, } for _, p := range c.ParentHashes { ci.ParentSHAs = append(ci.ParentSHAs, p.String()) } return ci } // splitMessage splits a commit message into its subject (first line) and body // (the remainder, with the separating blank line removed). func splitMessage(msg string) (subject, body string) { msg = strings.TrimRight(msg, "\n") if i := strings.IndexByte(msg, '\n'); i >= 0 { return msg[:i], strings.TrimLeft(msg[i+1:], "\n") } return msg, "" } // cutPatch enforces the size cap on generated patch text. go-git materializes // the whole patch in memory, so the cap is applied after generation. To keep // the browser-side parser from ever seeing a torn hunk, the text is cut at a // file boundary: the last "\ndiff --git " that starts at or before the limit. // If a single leading file already exceeds the limit there is no earlier // boundary and the text is cut hard at the limit. func cutPatch(text string, limit int64) (string, bool) { if limit <= 0 || int64(len(text)) <= limit { return text, false } head := text[:limit] // A unified-diff file header is the only place "diff --git" starts a line; // content lines always carry a +/-/space prefix after the newline. Cutting // before the last such header keeps only whole files. if idx := strings.LastIndex(head, "\ndiff --git"); idx >= 0 { return text[:idx+1], true } return head, true }