tinyrelayfile
tinyrelay/tinygit/browse.go/

tinygit/browse.go raw
1package tinygit
2
3import (
4 "bufio"
5 "bytes"
6 "context"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "os/exec"
12 "path"
13 "sort"
14 "strconv"
15 "strings"
16 "unicode/utf8"
17)
18
19const previewBytes = 256 * 1024
20
21type BrowseRequest struct {
22 Owner string `json:"owner"`
23 Repo string `json:"repo"`
24 Ref string `json:"ref"`
25 Path string `json:"path"`
26 View string `json:"view"`
27 Offset int `json:"offset"`
28 Limit int `json:"limit"`
29}
30
31type BrowseEntry struct {
32 Name string `json:"name"`
33 Path string `json:"path"`
34 Mode string `json:"mode"`
35 Type string `json:"type"`
36 OID string `json:"oid"`
37 Size int64 `json:"size"`
38}
39
40type BrowseCommit struct {
41 OID string `json:"oid"`
42 Parents []string `json:"parents"`
43 Author string `json:"author"`
44 Time int64 `json:"time"`
45 Subject string `json:"subject"`
46}
47
48type BrowsePage struct {
49 Owner string `json:"owner"`
50 Identifier string `json:"identifier"`
51 Head string `json:"head"`
52 Private bool `json:"private"`
53 Clone []string `json:"clone"`
54 Relays []string `json:"relays"`
55 // Maintainers is filled by the host, which knows about agent grants.
56 Maintainers []Maintainer `json:"maintainers,omitempty"`
57 Refs map[string]string `json:"refs"`
58 Commit string `json:"commit"`
59 Path string `json:"path"`
60 View string `json:"view"`
61 Entries []BrowseEntry `json:"entries"`
62 Content string `json:"content"`
63 Binary bool `json:"binary"`
64 Truncated bool `json:"truncated"`
65 Commits []BrowseCommit `json:"commits"`
66 Diff string `json:"diff"`
67 NextOffset int `json:"next_offset"`
68}
69
70// Repositories returns detached snapshots. Callers must enforce tenant and
71// repository read permissions before revealing these records.
72func (g *GitRelay) Repositories() []Repository {
73 g.mu.RLock()
74 defer g.mu.RUnlock()
75 rows := make([]Repository, 0, len(g.repos))
76 for _, r := range g.repos {
77 rows = append(rows, cloneBrowseRepo(r))
78 }
79 sort.Slice(rows, func(i, j int) bool {
80 return key(rows[i].Owner, rows[i].Identifier) < key(rows[j].Owner, rows[j].Identifier)
81 })
82 return rows
83}
84
85func cloneBrowseRepo(r Repository) Repository {
86 refs := make(map[string]string, len(r.Refs))
87 for name, oid := range r.Refs {
88 refs[name] = oid
89 }
90 r.Refs = refs
91 r.Clone = append([]string(nil), r.Clone...)
92 r.Relays = append([]string(nil), r.Relays...)
93 r.Maintainers = append([]string(nil), r.Maintainers...)
94 return r
95}
96
97func (g *GitRelay) BrowseRepository(owner, identifier string) (Repository, error) {
98 g.mu.RLock()
99 defer g.mu.RUnlock()
100 r, ok := g.repos[key(owner, identifier)]
101 if !ok {
102 return Repository{}, os.ErrNotExist
103 }
104 return cloneBrowseRepo(r), nil
105}
106
107func validBrowsePath(value string) bool {
108 return value == "" || (!strings.ContainsRune(value, 0) && !strings.HasPrefix(value, "/") && path.Clean(value) == value && value != ".." && !strings.HasPrefix(value, "../"))
109}
110
111func newBrowsePage(r Repository, q BrowseRequest) BrowsePage {
112 return BrowsePage{Owner: r.Owner, Identifier: r.Identifier, Head: r.Head, Private: r.Private, Clone: r.Clone, Relays: r.Relays, Refs: r.Refs, Path: q.Path, View: q.View, NextOffset: -1}
113}
114
115// Browse only reads objects reachable from materialized, signed public refs.
116// It never checks out a working tree, invokes a shell, or follows a symlink.
117func (g *GitRelay) Browse(ctx context.Context, q BrowseRequest) (BrowsePage, error) {
118 if !validBrowsePath(q.Path) || q.Offset < 0 {
119 return BrowsePage{}, errors.New("invalid: repository path or offset")
120 }
121 if q.View == "" {
122 q.View = "tree"
123 }
124 if q.Limit <= 0 || q.Limit > 200 {
125 q.Limit = 50
126 }
127 r, err := g.BrowseRepository(q.Owner, q.Repo)
128 if err != nil {
129 return BrowsePage{}, err
130 }
131 page := newBrowsePage(r, q)
132 r.Refs, err = g.browseRefs(ctx, r)
133 if err != nil {
134 return page, err
135 }
136 page.Refs = r.Refs
137 if len(r.Refs) == 0 {
138 return page, nil
139 }
140 page.Commit, err = g.browseCommit(ctx, r, q.Ref)
141 if err != nil {
142 return page, err
143 }
144 switch q.View {
145 case "tree":
146 err = g.browseTree(ctx, r, q, &page)
147 case "file":
148 err = g.browseFile(ctx, r, q, &page)
149 case "history":
150 err = g.browseHistory(ctx, r, q, &page)
151 case "commit":
152 err = g.browseDiff(ctx, r, &page)
153 case "activity":
154 default:
155 err = errors.New("invalid: unknown repository view")
156 }
157 return page, err
158}
159
160func (g *GitRelay) browseCommand(ctx context.Context, r Repository, args ...string) *exec.Cmd {
161 base := []string{"--no-pager", "--literal-pathspecs", "-c", "core.quotePath=false", "-c", "log.showSignature=false", "--git-dir", g.repoPath(r)}
162 return exec.CommandContext(ctx, "git", append(base, args...)...)
163}
164
165func (g *GitRelay) browseRefs(ctx context.Context, r Repository) (map[string]string, error) {
166 b, err := g.browseCommand(ctx, r, "for-each-ref", "--format=%(refname) %(objectname)", "refs/heads/", "refs/tags/", "refs/nostr/").Output()
167 if err != nil {
168 return nil, fmt.Errorf("read repository refs: %w", err)
169 }
170 refs := make(map[string]string)
171 for _, line := range strings.Split(string(b), "\n") {
172 fields := strings.Fields(line)
173 if len(fields) == 2 && r.Refs[fields[0]] == fields[1] {
174 refs[fields[0]] = fields[1]
175 }
176 }
177 return refs, nil
178}
179
180func (g *GitRelay) browseCommit(ctx context.Context, r Repository, ref string) (string, error) {
181 if ref == "" || ref == "HEAD" {
182 ref = strings.TrimPrefix(r.Head, "ref: ")
183 }
184 for _, candidate := range []string{ref, "refs/heads/" + ref, "refs/tags/" + ref} {
185 if oid, ok := r.Refs[candidate]; ok {
186 return g.peelBrowseCommit(ctx, r, oid)
187 }
188 }
189 if !hexObject(ref) {
190 return "", errors.New("not found: published ref")
191 }
192 oid, err := g.peelBrowseCommit(ctx, r, ref)
193 if err != nil {
194 return "", err
195 }
196 cmd := g.browseCommand(ctx, r, "rev-list", "--max-count=1", "--stdin")
197 var input strings.Builder
198 input.WriteString(oid + "\n")
199 for _, tip := range r.Refs {
200 input.WriteString("^" + tip + "\n")
201 }
202 cmd.Stdin = strings.NewReader(input.String())
203 b, err := cmd.Output()
204 if err != nil || len(bytes.TrimSpace(b)) != 0 {
205 return "", errors.New("not found: published commit")
206 }
207 return oid, nil
208}
209
210func hexObject(value string) bool {
211 if len(value) != 40 && len(value) != 64 {
212 return false
213 }
214 for _, ch := range value {
215 if !(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'f') {
216 return false
217 }
218 }
219 return true
220}
221
222func (g *GitRelay) peelBrowseCommit(ctx context.Context, r Repository, oid string) (string, error) {
223 if !hexObject(oid) {
224 return "", errors.New("invalid: object id")
225 }
226 b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", oid+"^{commit}").Output()
227 if err != nil {
228 return "", errors.New("not found: commit")
229 }
230 return strings.TrimSpace(string(b)), nil
231}
232
233func readBrowseOutput(cmd *exec.Cmd, limit int64) ([]byte, bool, error) {
234 out, err := cmd.StdoutPipe()
235 if err != nil {
236 return nil, false, err
237 }
238 if err = cmd.Start(); err != nil {
239 return nil, false, err
240 }
241 b, readErr := io.ReadAll(io.LimitReader(out, limit+1))
242 truncated := int64(len(b)) > limit
243 if truncated || readErr != nil {
244 _ = out.Close()
245 _ = cmd.Process.Kill()
246 }
247 waitErr := cmd.Wait()
248 if readErr != nil {
249 return nil, false, readErr
250 }
251 if truncated {
252 return b[:limit], true, nil
253 }
254 if waitErr != nil {
255 return nil, false, fmt.Errorf("read Git object: %w", waitErr)
256 }
257 return b, false, nil
258}
259
260func (g *GitRelay) browseTree(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
261 tree := page.Commit + ":" + q.Path
262 cmd := g.browseCommand(ctx, r, "ls-tree", "-z", "-l", tree)
263 out, err := cmd.StdoutPipe()
264 if err != nil {
265 return err
266 }
267 if err := cmd.Start(); err != nil {
268 return err
269 }
270 reader := bufio.NewReader(out)
271 readErr := readBrowseTree(reader, q, page)
272 if readErr != nil || page.NextOffset >= 0 {
273 // Closing this page cancels only its read-only Git child.
274 _ = out.Close()
275 _ = cmd.Process.Kill()
276 }
277 waitErr := cmd.Wait()
278 if readErr != nil {
279 return readErr
280 }
281 if page.NextOffset >= 0 {
282 return nil
283 }
284 return waitErr
285}
286
287func readBrowseTree(reader *bufio.Reader, q BrowseRequest, page *BrowsePage) error {
288 for index := 0; ; index++ {
289 row, err := reader.ReadString(0)
290 if errors.Is(err, io.EOF) && row == "" {
291 return nil
292 }
293 if err != nil {
294 return err
295 }
296 if index < q.Offset {
297 continue
298 }
299 if len(page.Entries) >= q.Limit {
300 page.NextOffset = index
301 return nil
302 }
303 meta, name, ok := strings.Cut(strings.TrimSuffix(row, "\x00"), "\t")
304 fields := strings.Fields(meta)
305 if !ok || len(fields) != 4 {
306 return errors.New("invalid Git tree entry")
307 }
308 var size int64
309 if fields[3] != "-" {
310 size, err = strconv.ParseInt(fields[3], 10, 64)
311 if err != nil {
312 return err
313 }
314 }
315 page.Entries = append(page.Entries, BrowseEntry{Name: name, Path: path.Join(q.Path, name), Mode: fields[0], Type: fields[1], OID: fields[2], Size: size})
316 }
317}
318
319func (g *GitRelay) fileObject(ctx context.Context, r Repository, commit, file string) (string, error) {
320 if file == "" || !validBrowsePath(file) {
321 return "", errors.New("invalid: file path")
322 }
323 b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", commit+":"+file).Output()
324 if err != nil {
325 return "", os.ErrNotExist
326 }
327 oid := strings.TrimSpace(string(b))
328 if !hexObject(oid) {
329 return "", os.ErrNotExist
330 }
331 kind, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
332 if err != nil || strings.TrimSpace(string(kind)) != "blob" {
333 return "", errors.New("not found: source file")
334 }
335 return oid, nil
336}
337
338func (g *GitRelay) browseFile(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
339 oid, err := g.fileObject(ctx, r, page.Commit, q.Path)
340 if err != nil {
341 return err
342 }
343 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "cat-file", "blob", oid), previewBytes)
344 if err != nil {
345 return err
346 }
347 page.Binary = bytes.IndexByte(b, 0) >= 0 || (!utf8.Valid(b) && !truncated)
348 page.Truncated = truncated
349 if !page.Binary {
350 page.Content = strings.ToValidUTF8(string(b), "�")
351 }
352 return nil
353}
354
355func (g *GitRelay) browseHistory(ctx context.Context, r Repository, q BrowseRequest, page *BrowsePage) error {
356 args := []string{"log", "--format=%H%x00%P%x00%an%x00%at%x00%s%x00", "--max-count=" + strconv.Itoa(q.Limit+1), "--skip=" + strconv.Itoa(q.Offset), page.Commit, "--"}
357 if q.Path != "" {
358 args = append(args, q.Path)
359 }
360 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, args...), previewBytes)
361 if err != nil {
362 return err
363 }
364 if truncated {
365 return errors.New("commit metadata exceeds preview capacity")
366 }
367 fields := strings.Split(string(b), "\x00")
368 for i := 0; i+4 < len(fields); i += 5 {
369 if len(page.Commits) >= q.Limit {
370 page.NextOffset = q.Offset + q.Limit
371 break
372 }
373 stamp, err := strconv.ParseInt(fields[i+3], 10, 64)
374 if err != nil {
375 return err
376 }
377 page.Commits = append(page.Commits, BrowseCommit{OID: strings.TrimSpace(fields[i]), Parents: strings.Fields(fields[i+1]), Author: fields[i+2], Time: stamp, Subject: fields[i+4]})
378 }
379 return nil
380}
381
382func (g *GitRelay) browseDiff(ctx context.Context, r Repository, page *BrowsePage) error {
383 if err := g.browseHistory(ctx, r, BrowseRequest{Limit: 1}, page); err != nil {
384 return err
385 }
386 b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "show", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", page.Commit, "--"), previewBytes)
387 page.Diff, page.Truncated = strings.ToValidUTF8(string(b), "�"), truncated
388 return err
389}
390
391// WriteSource streams a published source blob directly to the destination.
392func (g *GitRelay) WriteSource(ctx context.Context, q BrowseRequest, dst io.Writer) error {
393 r, err := g.BrowseRepository(q.Owner, q.Repo)
394 if err != nil {
395 return err
396 }
397 r.Refs, err = g.browseRefs(ctx, r)
398 if err != nil {
399 return err
400 }
401 commit, err := g.browseCommit(ctx, r, q.Ref)
402 if err != nil {
403 return err
404 }
405 oid, err := g.fileObject(ctx, r, commit, q.Path)
406 if err != nil {
407 return err
408 }
409 cmd := g.browseCommand(ctx, r, "cat-file", "blob", oid)
410 cmd.Stdout = dst
411 if err := cmd.Run(); err != nil {
412 return fmt.Errorf("stream source: %w", err)
413 }
414 return nil
415}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs