internal/control/read.go

160fc0ec2bbbb4367ce5db1814c1a7404b656048
gitbay/internal/control/read.go history · blame · raw

211 lines · 6010 bytes

  1package control
  2
  3import (
  4	"encoding/base64"
  5	"fmt"
  6	"io"
  7	"path"
  8	"strings"
  9
 10	"gitbay.org/gitbay/internal/gitutil"
 11	"gitbay.org/gitbay/internal/policy"
 12	"gitbay.org/gitbay/internal/protocol"
 13)
 14
 15func init() {
 16	register(Command{
 17		Path:     []string{"repo", "tree"},
 18		Summary:  "list a directory: repo tree <owner/name> [<path>] [--ref <ref>]",
 19		ReadOnly: true,
 20		Run:      runRepoTree,
 21	})
 22	register(Command{
 23		Path:     []string{"repo", "cat"},
 24		Summary:  "read a file: repo cat <owner/name> <path> [--ref <ref>]",
 25		ReadOnly: true,
 26		Run:      runRepoCat,
 27	})
 28}
 29
 30// readArgs pulls the shared "<owner/name> [positional...] [--ref r]" shape
 31// off argv. Positionals are returned in order so each command can name them
 32// in its own usage message.
 33func readArgs(c *Ctx, args []string, usage string, maxPos int) (pos []string, ref string, code int) {
 34	for i := 0; i < len(args); i++ {
 35		switch args[i] {
 36		case "--ref":
 37			if i+1 >= len(args) {
 38				return nil, "", c.fail(protocol.ExitUsage, "--ref requires a value")
 39			}
 40			ref = args[i+1]
 41			i++
 42		default:
 43			if strings.HasPrefix(args[i], "--") {
 44				return nil, "", c.fail(protocol.ExitUsage, "unknown flag %q\nusage: %s", args[i], usage)
 45			}
 46			if len(pos) >= maxPos {
 47				return nil, "", c.fail(protocol.ExitUsage, "usage: %s", usage)
 48			}
 49			pos = append(pos, args[i])
 50		}
 51	}
 52	return pos, ref, -1
 53}
 54
 55// cleanRepoPath keeps a caller inside the repository: no absolute paths, no
 56// "..", no leading slash. git would resolve those against the work tree.
 57func cleanRepoPath(p string) (string, bool) {
 58	p = strings.Trim(p, "/")
 59	if p == "" {
 60		return "", true
 61	}
 62	cleaned := path.Clean(p)
 63	if cleaned == "." || cleaned == ".." ||
 64		strings.HasPrefix(cleaned, "../") || strings.HasPrefix(cleaned, "/") {
 65		return "", false
 66	}
 67	return cleaned, true
 68}
 69
 70// entryOut is one tree entry. The sha lets a client cache by object id
 71// rather than by path and ref, which is what makes an offline client
 72// tractable.
 73type entryOut struct {
 74	Name string `json:"name"`
 75	Type string `json:"type"` // blob | tree
 76	Mode string `json:"mode"`
 77	SHA  string `json:"sha"`
 78	Size int64  `json:"size,omitempty"` // absent for trees
 79}
 80
 81func runRepoTree(c *Ctx, args []string) int {
 82	const usage = "repo tree <owner/name> [<path>] [--ref <ref>]"
 83	pos, ref, code := readArgs(c, args, usage, 2)
 84	if code >= 0 {
 85		return code
 86	}
 87	if len(pos) == 0 {
 88		return c.fail(protocol.ExitUsage, "usage: %s", usage)
 89	}
 90	repo, code := resolveRepo(c, pos[0], policy.CanRead)
 91	if code >= 0 {
 92		return code
 93	}
 94	dirPath := ""
 95	if len(pos) == 2 {
 96		var ok bool
 97		if dirPath, ok = cleanRepoPath(pos[1]); !ok {
 98			return c.fail(protocol.ExitUsage, "path must stay inside the repository")
 99		}
100	}
101	if ref == "" {
102		ref = repo.DefaultBranch
103	}
104	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
105	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
106		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
107	}
108	entries, err := gitutil.ListTree(dir, ref, dirPath)
109	if err != nil {
110		return c.fail(protocol.ExitNotFound, "no such path %q in %s at %s", dirPath, repo.Path(), ref)
111	}
112
113	type out struct {
114		Path    string     `json:"path"`
115		Ref     string     `json:"ref"`
116		Dir     string     `json:"dir"`
117		Entries []entryOut `json:"entries"`
118	}
119	d := out{Path: repo.Path(), Ref: ref, Dir: dirPath, Entries: []entryOut{}}
120	for _, e := range entries {
121		eo := entryOut{Name: e.Name, Type: e.Type, Mode: e.Mode, SHA: e.SHA}
122		if e.Type != "tree" && e.Size >= 0 {
123			eo.Size = e.Size
124		}
125		d.Entries = append(d.Entries, eo)
126	}
127	return c.emit(d, func(w io.Writer) {
128		for _, e := range d.Entries {
129			name := e.Name
130			if e.Type == "tree" {
131				name += "/"
132			}
133			fmt.Fprintf(w, "%s\t%s\t%s\n", e.SHA[:min(10, len(e.SHA))], sizeCol(e), name)
134		}
135	})
136}
137
138func sizeCol(e entryOut) string {
139	if e.Type == "tree" {
140		return "-"
141	}
142	return fmt.Sprintf("%d", e.Size)
143}
144
145func runRepoCat(c *Ctx, args []string) int {
146	const usage = "repo cat <owner/name> <path> [--ref <ref>]"
147	pos, ref, code := readArgs(c, args, usage, 2)
148	if code >= 0 {
149		return code
150	}
151	if len(pos) != 2 {
152		return c.fail(protocol.ExitUsage, "usage: %s", usage)
153	}
154	repo, code := resolveRepo(c, pos[0], policy.CanRead)
155	if code >= 0 {
156		return code
157	}
158	filePath, ok := cleanRepoPath(pos[1])
159	if !ok || filePath == "" {
160		return c.fail(protocol.ExitUsage, "path must stay inside the repository")
161	}
162	if ref == "" {
163		ref = repo.DefaultBranch
164	}
165	dir := RepoDir(c.Cfg.Server.Root, repo.OwnerName, repo.Name)
166	if _, err := gitutil.ResolveRef(dir, ref); err != nil {
167		return c.fail(protocol.ExitNotFound, "no ref %q in %s", ref, repo.Path())
168	}
169	// One byte over the cap distinguishes "exactly at the limit" from
170	// "truncated", so a client is told which it got.
171	limit := c.Cfg.Limits.MaxBlobBytes
172	data, err := gitutil.ReadBlob(dir, ref, filePath, limit+1)
173	if err != nil {
174		return c.fail(protocol.ExitNotFound, "no such file %q in %s at %s", filePath, repo.Path(), ref)
175	}
176	truncated := int64(len(data)) > limit
177	if truncated {
178		data = data[:limit]
179	}
180	binary := gitutil.IsBinary(data)
181
182	type out struct {
183		Path      string `json:"path"`
184		Ref       string `json:"ref"`
185		File      string `json:"file"`
186		Size      int    `json:"size"`
187		Binary    bool   `json:"binary"`
188		Truncated bool   `json:"truncated,omitempty"`
189		// Exactly one of these is set: text for UTF-8-safe content,
190		// base64 for anything else, so a client never has to guess.
191		Content string `json:"content,omitempty"`
192		Base64  string `json:"base64,omitempty"`
193	}
194	d := out{Path: repo.Path(), Ref: ref, File: filePath, Size: len(data),
195		Binary: binary, Truncated: truncated}
196	if binary {
197		d.Base64 = base64.StdEncoding.EncodeToString(data)
198	} else {
199		d.Content = string(data)
200	}
201	return c.emit(d, func(w io.Writer) {
202		if binary {
203			fmt.Fprintf(w, "%s: %d bytes of binary content (use --json for base64)\n", filePath, len(data))
204			return
205		}
206		w.Write(data)
207		if len(data) > 0 && data[len(data)-1] != '\n' {
208			fmt.Fprintln(w)
209		}
210	})
211}