internal/control/read.go
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}