repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

22.5 KB · 776 lines · Go Raw History
  1package main
  2
  3// Every repository read here is a git subprocess. The rules that keep that safe:
  4// plumbing commands only, NUL delimiters (-z and %x00) because ref names and
  5// commit messages contain newlines, and "--" before every user supplied ref or path.
  6
  7import (
  8	"bufio"
  9	"bytes"
 10	"context"
 11	"fmt"
 12	"io"
 13	"os"
 14	"os/exec"
 15	"path/filepath"
 16	"sort"
 17	"strconv"
 18	"strings"
 19	"sync"
 20	"time"
 21)
 22
 23// gitTimeout stays under Cloudflare's 100 second ceiling, so a wedged subprocess
 24// surfaces as a 500 here rather than a 524 from the edge.
 25const gitTimeout = 20 * time.Second
 26
 27// maxBlobSize is the largest file read into memory to render. Anything larger is
 28// offered as a raw download, which streams.
 29const maxBlobSize = 25 << 20
 30
 31// Repo is one bare repository on disk. Name is the URL segment and the display
 32// name; Path is the --git-dir.
 33type Repo struct {
 34	Name string
 35	Path string
 36}
 37
 38// validName gates every name arriving from a URL before it is joined to a path,
 39// and is the traversal fence. Interior dots are allowed because
 40// `blog.bythewood.me` is a repository name here; a leading dot is not.
 41func validName(name string) bool {
 42	if name == "" || len(name) > 100 {
 43		return false
 44	}
 45	if strings.HasPrefix(name, ".") {
 46		return false
 47	}
 48	// A leading dash reads as an option to any git command that sees it unguarded.
 49	if strings.HasPrefix(name, "-") {
 50		return false
 51	}
 52	if strings.Contains(name, "..") {
 53		return false
 54	}
 55	if strings.ContainsAny(name, "/\\\x00") {
 56		return false
 57	}
 58	for _, r := range name {
 59		switch {
 60		case r >= 'a' && r <= 'z',
 61			r >= 'A' && r <= 'Z',
 62			r >= '0' && r <= '9',
 63			r == '.' || r == '-' || r == '_':
 64		default:
 65			return false
 66		}
 67	}
 68	return true
 69}
 70
 71// Store owns the repository root, the cat-file readers keyed by repository, and
 72// the listing cards keyed the same way.
 73type Store struct {
 74	Root string
 75
 76	mu      sync.Mutex
 77	batches map[string]*catFile
 78
 79	// A separate lock from mu so reading a card never waits on a cat-file open.
 80	overviewMu sync.Mutex
 81	overviews  map[string]overviewEntry
 82}
 83
 84func NewStore(root string) *Store {
 85	return &Store{
 86		Root:      root,
 87		batches:   make(map[string]*catFile),
 88		overviews: make(map[string]overviewEntry),
 89	}
 90}
 91
 92// Open resolves a name to a repository. Invalid or missing is (Repo{}, false),
 93// never a git error that leaks a path.
 94func (s *Store) Open(name string) (Repo, bool) {
 95	if !validName(name) {
 96		return Repo{}, false
 97	}
 98	path := filepath.Join(s.Root, name+".git")
 99	info, err := os.Stat(path)
100	if err != nil || !info.IsDir() {
101		return Repo{}, false
102	}
103	return Repo{Name: name, Path: path}, true
104}
105
106// Discover lists every bare repository under the root; anything not ending in
107// .git is ignored, so the root may hold other things without publishing them.
108func (s *Store) Discover() ([]Repo, error) {
109	entries, err := os.ReadDir(s.Root)
110	if err != nil {
111		return nil, fmt.Errorf("read repo root: %w", err)
112	}
113
114	var repos []Repo
115	for _, e := range entries {
116		if !e.IsDir() || !strings.HasSuffix(e.Name(), ".git") {
117			continue
118		}
119		name := strings.TrimSuffix(e.Name(), ".git")
120		if !validName(name) {
121			continue
122		}
123		repos = append(repos, Repo{Name: name, Path: filepath.Join(s.Root, e.Name())})
124	}
125	sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
126	return repos, nil
127}
128
129// gitCmd builds a git invocation against one repository with the safety flags
130// every call in this file needs, so no call site has to remember them.
131func gitCmd(ctx context.Context, repo Repo, args ...string) *exec.Cmd {
132	full := []string{
133		// Without this git escapes non-ASCII path bytes into octal in its own output.
134		"-c", "core.quotePath=false",
135		// A served repository is data, never a place to run a program from.
136		"-c", "core.fsmonitor=false",
137		"--git-dir", repo.Path,
138	}
139	cmd := exec.CommandContext(ctx, "git", append(full, args...)...)
140	// git refuses some operations without a HOME, the mirror fetch among them.
141	cmd.Env = append(os.Environ(),
142		"GIT_TERMINAL_PROMPT=0",
143		"GIT_CONFIG_NOSYSTEM=1",
144		"HOME="+os.TempDir(),
145	)
146	return cmd
147}
148
149// run executes a plumbing command and returns stdout; stderr is folded into the error.
150func run(ctx context.Context, repo Repo, args ...string) ([]byte, error) {
151	ctx, cancel := context.WithTimeout(ctx, gitTimeout)
152	defer cancel()
153
154	cmd := gitCmd(ctx, repo, args...)
155	var stdout, stderr bytes.Buffer
156	cmd.Stdout = &stdout
157	cmd.Stderr = &stderr
158
159	if err := cmd.Run(); err != nil {
160		return nil, fmt.Errorf("git %s: %w: %s",
161			strings.Join(args, " "), err, strings.TrimSpace(stderr.String()))
162	}
163	return stdout.Bytes(), nil
164}
165
166// Ref is a branch or a tag with its commit already resolved. %(*objectname) is
167// the commit an annotated tag points at and is empty for a lightweight one, so
168// Target picks whichever of the two is real.
169type Ref struct {
170	Name      string
171	FullName  string
172	Target    string
173	Subject   string
174	Author    string
175	When      time.Time
176	Annotated bool
177	Message   string
178}
179
180// refFormat is one line per ref, NUL between fields. A trailing %00 plus -z would
181// double-terminate, so for-each-ref keeps the record separator.
182const refFormat = "%(refname:short)%00%(refname)%00%(objectname)%00%(*objectname)%00" +
183	"%(contents:subject)%00%(authorname)%00%(taggername)%00%(creatordate:iso-strict)%00%(contents:body)"
184
185func (s *Store) refs(ctx context.Context, repo Repo, pattern string) ([]Ref, error) {
186	out, err := run(ctx, repo, "for-each-ref",
187		"--sort=-creatordate", "--format="+refFormat, pattern)
188	if err != nil {
189		return nil, err
190	}
191
192	var refs []Ref
193	for _, line := range strings.Split(string(out), "\n") {
194		if strings.TrimSpace(line) == "" {
195			continue
196		}
197		f := strings.Split(line, "\x00")
198		if len(f) < 9 {
199			continue
200		}
201		author := f[5]
202		if author == "" {
203			author = f[6]
204		}
205		// The starred object name is set only for an annotated tag.
206		target, annotated := f[2], false
207		if f[3] != "" {
208			target, annotated = f[3], true
209		}
210		refs = append(refs, Ref{
211			Name:      f[0],
212			FullName:  f[1],
213			Target:    target,
214			Subject:   f[4],
215			Author:    author,
216			When:      parseISO(f[7]),
217			Annotated: annotated,
218			Message:   f[8],
219		})
220	}
221	return refs, nil
222}
223
224func (s *Store) Branches(ctx context.Context, repo Repo) ([]Ref, error) {
225	return s.refs(ctx, repo, "refs/heads/")
226}
227
228func (s *Store) Tags(ctx context.Context, repo Repo) ([]Ref, error) {
229	return s.refs(ctx, repo, "refs/tags/")
230}
231
232// Head returns the default branch's short name. HEAD is a symbolic ref, so this
233// answers on an empty repository too.
234func (s *Store) Head(ctx context.Context, repo Repo) string {
235	out, err := run(ctx, repo, "symbolic-ref", "--short", "HEAD")
236	if err != nil {
237		return "main"
238	}
239	return strings.TrimSpace(string(out))
240}
241
242// IsEmpty reports a repository with no commits; every other read fails on one.
243func (s *Store) IsEmpty(ctx context.Context, repo Repo) bool {
244	_, err := run(ctx, repo, "rev-parse", "--verify", "--quiet", "HEAD")
245	return err != nil
246}
247
248// Commit is one entry in a log.
249type Commit struct {
250	SHA     string
251	Short   string
252	Author  string
253	Email   string
254	When    time.Time
255	Commit  time.Time
256	Parents []string
257	Refs    string
258	Subject string
259	Body    string
260}
261
262// logFormat is ten NUL separated fields. With -z git appends a NUL after each
263// record too, so splitting the stream on NUL yields groups of ten.
264const logFormat = "%H%x00%h%x00%an%x00%ae%x00%aI%x00%cI%x00%P%x00%D%x00%s%x00%b"
265
266const logFields = 10
267
268// Log reads commits reachable from rev; the caller caps limit.
269func (s *Store) Log(ctx context.Context, repo Repo, rev string, skip, limit int) ([]Commit, error) {
270	args := []string{"log", "-z", "--format=" + logFormat,
271		"--skip=" + strconv.Itoa(skip), "-n", strconv.Itoa(limit), rev, "--"}
272	out, err := run(ctx, repo, args...)
273	if err != nil {
274		return nil, err
275	}
276	return parseLog(out), nil
277}
278
279// LogFile is Log narrowed to one path.
280func (s *Store) LogFile(ctx context.Context, repo Repo, rev, path string, skip, limit int) ([]Commit, error) {
281	args := []string{"log", "-z", "--format=" + logFormat,
282		"--skip=" + strconv.Itoa(skip), "-n", strconv.Itoa(limit), rev, "--", path}
283	out, err := run(ctx, repo, args...)
284	if err != nil {
285		return nil, err
286	}
287	return parseLog(out), nil
288}
289
290func parseLog(out []byte) []Commit {
291	fields := strings.Split(string(out), "\x00")
292	var commits []Commit
293	for i := 0; i+logFields <= len(fields); i += logFields {
294		f := fields[i : i+logFields]
295		// Some git versions leave a leading newline on the next group's first
296		// field, so trim rather than trusting the separator.
297		sha := strings.TrimLeft(f[0], "\n")
298		if len(sha) < 7 {
299			break
300		}
301		var parents []string
302		if f[6] != "" {
303			parents = strings.Fields(f[6])
304		}
305		commits = append(commits, Commit{
306			SHA:     sha,
307			Short:   f[1],
308			Author:  f[2],
309			Email:   f[3],
310			When:    parseISO(f[4]),
311			Commit:  parseISO(f[5]),
312			Parents: parents,
313			Refs:    f[7],
314			Subject: f[8],
315			Body:    strings.TrimRight(f[9], "\n"),
316		})
317	}
318	return commits
319}
320
321// CommitOne reads a single commit, or errors if rev does not name one.
322func (s *Store) CommitOne(ctx context.Context, repo Repo, rev string) (Commit, error) {
323	out, err := run(ctx, repo, "log", "-z", "--format="+logFormat, "-n", "1", rev, "--")
324	if err != nil {
325		return Commit{}, err
326	}
327	commits := parseLog(out)
328	if len(commits) == 0 {
329		return Commit{}, fmt.Errorf("no such commit: %s", rev)
330	}
331	return commits[0], nil
332}
333
334// CountCommits walks the graph, and is the one number on a repository page that
335// costs real work on a large history.
336func (s *Store) CountCommits(ctx context.Context, repo Repo, rev string) int {
337	out, err := run(ctx, repo, "rev-list", "--count", rev, "--")
338	if err != nil {
339		return 0
340	}
341	n, _ := strconv.Atoi(strings.TrimSpace(string(out)))
342	return n
343}
344
345// TreeEntry is one row of a directory listing.
346type TreeEntry struct {
347	Mode string
348	Type string
349	SHA  string
350	Size int64
351	Name string
352	Path string
353}
354
355// IsDir is true for a subdirectory. A gitlink is neither tree nor blob, so this
356// is not the same as "not a blob".
357func (e TreeEntry) IsDir() bool { return e.Type == "tree" }
358
359// IsSubmodule reports a gitlink, which ls-tree types as "commit" inside a tree.
360func (e TreeEntry) IsSubmodule() bool { return e.Type == "commit" }
361
362// IsSymlink reads the mode, since a symlink is a blob whose content is its target.
363func (e TreeEntry) IsSymlink() bool { return e.Mode == "120000" }
364
365// Tree lists one directory, not recursively. --long asks for sizes, which git
366// answers from the object header rather than by inflating each blob.
367func (s *Store) Tree(ctx context.Context, repo Repo, rev, path string) ([]TreeEntry, error) {
368	spec := rev + ":" + path
369	if path == "" {
370		spec = rev + ":"
371	}
372	out, err := run(ctx, repo, "ls-tree", "-z", "--long", spec)
373	if err != nil {
374		return nil, err
375	}
376
377	var entries []TreeEntry
378	for _, rec := range strings.Split(string(out), "\x00") {
379		if rec == "" {
380			continue
381		}
382		// "<mode> SP <type> SP <sha> SP* <size> TAB <name>": the size is space
383		// padded and "-" for a tree, so split on the tab first, Fields second.
384		tab := strings.IndexByte(rec, '\t')
385		if tab < 0 {
386			continue
387		}
388		meta, name := strings.Fields(rec[:tab]), rec[tab+1:]
389		if len(meta) < 4 {
390			continue
391		}
392		size, _ := strconv.ParseInt(meta[3], 10, 64)
393		full := name
394		if path != "" {
395			full = path + "/" + name
396		}
397		entries = append(entries, TreeEntry{
398			Mode: meta[0], Type: meta[1], SHA: meta[2],
399			Size: size, Name: name, Path: full,
400		})
401	}
402
403	// git returns tree order, which is byte order with a trailing slash on trees
404	// and reads as arbitrary to a person.
405	sort.SliceStable(entries, func(i, j int) bool {
406		if entries[i].IsDir() != entries[j].IsDir() {
407			return entries[i].IsDir()
408		}
409		return entries[i].Name < entries[j].Name
410	})
411	return entries, nil
412}
413
414// Blob reads a file at a revision. The size comes from the object header first,
415// so an oversized file costs a header read rather than a buffer.
416func (s *Store) Blob(ctx context.Context, repo Repo, rev, path string) ([]byte, int64, error) {
417	spec := rev + ":" + path
418
419	size, err := s.objectSize(ctx, repo, spec)
420	if err != nil {
421		return nil, 0, err
422	}
423	if size > maxBlobSize {
424		return nil, size, errTooLarge
425	}
426
427	out, err := run(ctx, repo, "cat-file", "blob", spec)
428	if err != nil {
429		return nil, size, err
430	}
431	return out, size, nil
432}
433
434var errTooLarge = fmt.Errorf("blob exceeds %d bytes", maxBlobSize)
435
436func (s *Store) objectSize(ctx context.Context, repo Repo, spec string) (int64, error) {
437	out, err := run(ctx, repo, "cat-file", "-s", spec)
438	if err != nil {
439		return 0, err
440	}
441	return strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
442}
443
444// StreamBlob writes a file's bytes to w without buffering, which is what makes
445// /raw work on a file too large to render.
446func (s *Store) StreamBlob(ctx context.Context, repo Repo, rev, path string, w io.Writer) error {
447	cmd := gitCmd(ctx, repo, "cat-file", "blob", rev+":"+path)
448	cmd.Stdout = w
449	cmd.Stderr = io.Discard
450	return cmd.Run()
451}
452
453// Resolve turns a user supplied revision into a commit SHA, and is the validation
454// step for anything arriving in a URL. The ^{commit} peel means every caller
455// downstream can assume it holds a commit rather than a tag.
456func (s *Store) Resolve(ctx context.Context, repo Repo, rev string) (string, error) {
457	out, err := run(ctx, repo, "rev-parse", "--verify", "--quiet", rev+"^{commit}")
458	if err != nil {
459		return "", fmt.Errorf("no such revision: %s", rev)
460	}
461	sha := strings.TrimSpace(string(out))
462	if sha == "" {
463		return "", fmt.Errorf("no such revision: %s", rev)
464	}
465	return sha, nil
466}
467
468// Size reports the on-disk size in bytes, the number that decides whether a first
469// push fits under cloudflareBodyLimit.
470func (s *Store) Size(ctx context.Context, repo Repo) int64 {
471	out, err := run(ctx, repo, "count-objects", "-v")
472	if err != nil {
473		return 0
474	}
475	var total int64
476	for _, line := range strings.Split(string(out), "\n") {
477		k, v, ok := strings.Cut(line, ": ")
478		if !ok {
479			continue
480		}
481		// size and size-pack are both reported in KiB.
482		if k == "size" || k == "size-pack" {
483			n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
484			total += n * 1024
485		}
486	}
487	return total
488}
489
490// Overview is one repository's listing card. The index renders one per
491// repository and push-to-create means that count only grows, so this read is
492// the one whose cost scales with the site.
493type Overview struct {
494	Size     int64
495	Branches int
496	Tags     int
497	LastPush time.Time
498	Empty    bool
499}
500
501type overviewEntry struct {
502	value Overview
503	at    time.Time
504}
505
506// overviewTTL is a backstop. Every path in this process that writes to a
507// repository invalidates it, so the TTL only covers a change made to the volume
508// from outside, which means a repository seeded by hand.
509const overviewTTL = 10 * time.Minute
510
511// overviewFormat is the ref name and the date of the commit under it.
512// committerdate is empty on an annotated tag object, which is why only
513// refs/heads/ feeds LastPush.
514const overviewFormat = "%(refname)%00%(committerdate:iso-strict)"
515
516// Overview answers from cache when it can. A miss costs two subprocesses, and a
517// concurrent miss on one repository may pay that twice rather than hold a lock
518// across a fork.
519func (s *Store) Overview(ctx context.Context, repo Repo) Overview {
520	s.overviewMu.Lock()
521	entry, ok := s.overviews[repo.Name]
522	s.overviewMu.Unlock()
523	if ok && time.Since(entry.at) < overviewTTL {
524		return entry.value
525	}
526
527	fresh := s.readOverview(ctx, repo)
528
529	s.overviewMu.Lock()
530	s.overviews[repo.Name] = overviewEntry{value: fresh, at: time.Now()}
531	s.overviewMu.Unlock()
532	return fresh
533}
534
535// InvalidateOverview drops one repository's card, and is called from every write
536// path here so a push that has landed is never listed as if it had not.
537func (s *Store) InvalidateOverview(name string) {
538	s.overviewMu.Lock()
539	delete(s.overviews, name)
540	s.overviewMu.Unlock()
541}
542
543// readOverview walks both ref namespaces once, because the branch count, the
544// tag count and the newest commit date all come out of the same walk.
545func (s *Store) readOverview(ctx context.Context, repo Repo) Overview {
546	o := Overview{Size: s.Size(ctx, repo)}
547
548	// A card that cannot be read renders empty rather than failing the page,
549	// which is how the listing treats every other per-repository error.
550	out, _ := run(ctx, repo, "for-each-ref",
551		"--format="+overviewFormat, "refs/heads/", "refs/tags/")
552
553	for _, line := range strings.Split(string(out), "\n") {
554		name, date, ok := strings.Cut(line, "\x00")
555		if !ok {
556			continue
557		}
558		switch {
559		case strings.HasPrefix(name, "refs/heads/"):
560			o.Branches++
561			if when := parseISO(date); when.After(o.LastPush) {
562				o.LastPush = when
563			}
564		case strings.HasPrefix(name, "refs/tags/"):
565			o.Tags++
566		}
567	}
568
569	// No refs of either kind is a repository push-to-create made with nothing
570	// landed in it yet. The repository page asks git itself through IsEmpty.
571	o.Empty = o.Branches == 0 && o.Tags == 0
572	return o
573}
574
575// Archive streams a tarball or zip of a revision. The prefix gives it a top level
576// directory, so extracting does not scatter files into the current one.
577func (s *Store) Archive(ctx context.Context, repo Repo, rev, format, prefix string, w io.Writer) error {
578	switch format {
579	case "tar.gz", "zip":
580	default:
581		return fmt.Errorf("unsupported archive format: %s", format)
582	}
583	cmd := gitCmd(ctx, repo, "archive",
584		"--format="+format, "--prefix="+prefix+"/", rev)
585	cmd.Stdout = w
586	cmd.Stderr = io.Discard
587	return cmd.Run()
588}
589
590// InitBare creates a repository and is the whole of push-to-create.
591// receive.autogc=false keeps a repack out of a push, and core.logAllRefUpdates
592// gives a bare repository a reflog so a force push leaves the old tip recoverable.
593func (s *Store) InitBare(ctx context.Context, name string) (Repo, error) {
594	if !validName(name) {
595		return Repo{}, fmt.Errorf("invalid repository name: %q", name)
596	}
597	path := filepath.Join(s.Root, name+".git")
598	if _, err := os.Stat(path); err == nil {
599		return Repo{Name: name, Path: path}, nil
600	}
601
602	ctx, cancel := context.WithTimeout(ctx, gitTimeout)
603	defer cancel()
604
605	cmd := exec.CommandContext(ctx, "git", "init", "--bare", "--initial-branch=main", path)
606	cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+os.TempDir())
607	if out, err := cmd.CombinedOutput(); err != nil {
608		return Repo{}, fmt.Errorf("git init %s: %w: %s", name, err, out)
609	}
610
611	repo := Repo{Name: name, Path: path}
612	for _, kv := range [][2]string{
613		{"receive.autogc", "false"},
614		{"core.logAllRefUpdates", "true"},
615		{"gc.reflogExpire", "never"},
616		{"gc.reflogExpireUnreachable", "never"},
617		// Do not set http.receivepack. An explicit false denies the
618		// authenticated smart push too, so push-to-create would create the
619		// repository and then reject the push that made it with a 403.
620	} {
621		if _, err := run(ctx, repo, "config", kv[0], kv[1]); err != nil {
622			return Repo{}, err
623		}
624	}
625	return repo, nil
626}
627
628// GC repacks one repository. Called from a ticker, never from a request.
629func (s *Store) GC(ctx context.Context, repo Repo) error {
630	ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
631	defer cancel()
632
633	cmd := gitCmd(ctx, repo, "gc", "--auto", "--quiet")
634	var stderr bytes.Buffer
635	cmd.Stderr = &stderr
636	if err := cmd.Run(); err != nil {
637		return fmt.Errorf("gc %s: %w: %s", repo.Name, err, strings.TrimSpace(stderr.String()))
638	}
639	// Repacking is the other thing that moves a card, since it moves the size.
640	s.InvalidateOverview(repo.Name)
641	return nil
642}
643
644// parseISO reads git's iso-strict form, returning a zero time on failure so a
645// malformed date on one ref does not fail a page.
646func parseISO(s string) time.Time {
647	s = strings.TrimSpace(s)
648	if s == "" {
649		return time.Time{}
650	}
651	t, err := time.Parse(time.RFC3339, s)
652	if err != nil {
653		return time.Time{}
654	}
655	return t
656}
657
658// catFile is a long lived `git cat-file --batch` per repository, so a listing does
659// not fork once per object. Access is serialised because the protocol is a
660// conversation, and two callers on one pipe would read each other's bytes.
661type catFile struct {
662	mu     sync.Mutex
663	cmd    *exec.Cmd
664	stdin  io.WriteCloser
665	stdout *bufio.Reader
666}
667
668func newCatFile(repo Repo) (*catFile, error) {
669	// Not CommandContext: this process outlives any one request and is closed by
670	// Store.Close at shutdown.
671	cmd := exec.Command("git", "-c", "core.quotePath=false",
672		"--git-dir", repo.Path, "cat-file", "--batch")
673	cmd.Env = append(os.Environ(), "GIT_CONFIG_NOSYSTEM=1", "HOME="+os.TempDir())
674
675	stdin, err := cmd.StdinPipe()
676	if err != nil {
677		return nil, err
678	}
679	stdout, err := cmd.StdoutPipe()
680	if err != nil {
681		return nil, err
682	}
683	if err := cmd.Start(); err != nil {
684		return nil, err
685	}
686	return &catFile{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 64<<10)}, nil
687}
688
689// object reads one object by revision. The reply is "<sha> <type> <size>" then
690// exactly size bytes and a newline, or "<spec> missing".
691func (c *catFile) object(spec string) (typ string, data []byte, err error) {
692	c.mu.Lock()
693	defer c.mu.Unlock()
694
695	if _, err := io.WriteString(c.stdin, spec+"\n"); err != nil {
696		return "", nil, err
697	}
698
699	header, err := c.stdout.ReadString('\n')
700	if err != nil {
701		return "", nil, err
702	}
703	header = strings.TrimSuffix(header, "\n")
704
705	parts := strings.Fields(header)
706	if len(parts) < 3 {
707		// "missing" or "ambiguous": what a request for an absent path looks like.
708		return "", nil, fmt.Errorf("cat-file: %s", header)
709	}
710	size, err := strconv.ParseInt(parts[2], 10, 64)
711	if err != nil {
712		return "", nil, fmt.Errorf("cat-file: bad size in %q", header)
713	}
714	if size > maxBlobSize {
715		// The payload still has to be drained or the stream desynchronises
716		// and every later read on this pipe returns another object's bytes.
717		if _, err := io.CopyN(io.Discard, c.stdout, size+1); err != nil {
718			return "", nil, err
719		}
720		return parts[1], nil, errTooLarge
721	}
722
723	buf := make([]byte, size)
724	if _, err := io.ReadFull(c.stdout, buf); err != nil {
725		return "", nil, err
726	}
727	// The trailing newline git writes after every payload.
728	if _, err := c.stdout.Discard(1); err != nil {
729		return "", nil, err
730	}
731	return parts[1], buf, nil
732}
733
734func (c *catFile) close() {
735	c.mu.Lock()
736	defer c.mu.Unlock()
737	_ = c.stdin.Close()
738	_ = c.cmd.Wait()
739}
740
741// batch returns the reader for a repository, starting one on first use.
742func (s *Store) batch(repo Repo) (*catFile, error) {
743	s.mu.Lock()
744	defer s.mu.Unlock()
745
746	if c, ok := s.batches[repo.Name]; ok {
747		return c, nil
748	}
749	c, err := newCatFile(repo)
750	if err != nil {
751		return nil, err
752	}
753	s.batches[repo.Name] = c
754	return c, nil
755}
756
757// Object reads one object through the long lived reader.
758func (s *Store) Object(repo Repo, spec string) (string, []byte, error) {
759	c, err := s.batch(repo)
760	if err != nil {
761		return "", nil, err
762	}
763	return c.object(spec)
764}
765
766// Close stops every cat-file reader, so a restart leaves no git process per
767// repository behind.
768func (s *Store) Close() {
769	s.mu.Lock()
770	defer s.mu.Unlock()
771	for name, c := range s.batches {
772		c.close()
773		delete(s.batches, name)
774	}
775}