1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase
4
5import (
6 "context"
7 "crypto/sha256"
8 "encoding/base64"
9 "encoding/hex"
10 "fmt"
11 "io"
12 "slices"
13 "strconv"
14 "strings"
15
16 "bnema.dev/ermite/internal/domain"
17 "bnema.dev/ermite/internal/ports"
18)
19
20// Browse are the read use cases over the Git data of a repository: refs,
21// trees, blobs, history and commits. Every method applies the access rule
22// first: a repository the principal may not read, or that does not exist,
23// answers domain.ErrNotFound before the Git reader is touched.
24type Browse struct {
25 meta ports.RepoMetaStore
26 reader ports.RepoReader
27 // history keeps the answers about a commit designated by its full id (a
28 // tree with the last commit of its entries, the latest commit of a path):
29 // they never change, and computing them costs a history walk. Nil
30 // disables it.
31 history *historyCache
32 // stats keeps the figures of the home page.
33 stats statsCache
34}
35
36var _ ports.BrowseService = (*Browse)(nil)
37
38// NewBrowse builds the read use cases over their ports, with a history cache
39// of the default bounds.
40func NewBrowse(meta ports.RepoMetaStore, reader ports.RepoReader) *Browse {
41 return &Browse{
42 meta: meta, reader: reader,
43 history: newHistoryCache(DefaultHistoryCacheEntries, DefaultHistoryCacheBytes),
44 }
45}
46
47// WithHistoryCache replaces the bounds of the history cache: at most
48// maxEntries answers and about maxBytes of memory, least recently used first
49// out. A bound that is not positive disables the cache. Call it before the
50// first request.
51func (b *Browse) WithHistoryCache(maxEntries int, maxBytes int64) *Browse {
52 b.history = newHistoryCache(maxEntries, maxBytes)
53 return b
54}
55
56// authorize returns the repository if p may read it. The caller uses it to
57// tell a repository from a later one created under the same name.
58func (b *Browse) authorize(ctx context.Context, p domain.Principal, name domain.RepoName) (domain.Repo, error) {
59 repo, err := lookupRepo(ctx, b.meta, name)
60 if err != nil {
61 return domain.Repo{}, err
62 }
63 if !canRead(p, repo) {
64 return domain.Repo{}, domain.ErrNotFound
65 }
66 return repo, nil
67}
68
69// repoEpoch identifies one life of a repository name: its creation time in
70// nanoseconds, 0 if unknown.
71func repoEpoch(r domain.Repo) int64 {
72 if r.CreatedAt.IsZero() {
73 return 0
74 }
75 return r.CreatedAt.UnixNano()
76}
77
78func kindRank(k domain.RefKind) int {
79 switch k {
80 case domain.RefKindHead:
81 return 0
82 case domain.RefKindBranch:
83 return 1
84 }
85 return 2
86}
87
88// ListRefs lists HEAD, the branches and the tags, in that order, then by
89// name. The page token names the last ref of the previous page, so refs that
90// appear or disappear between two pages never shift the others.
91func (b *Browse) ListRefs(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListRefsParams) (ports.RefPage, error) {
92 size, err := pageSizeUpTo(params.PageSize, MaxRefPageSize)
93 if err != nil {
94 return ports.RefPage{}, err
95 }
96 for _, k := range params.Kinds {
97 if k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag {
98 return ports.RefPage{}, fmt.Errorf("%w: %q", domain.ErrInvalidRefKind, k)
99 }
100 }
101 after, err := decodeRefToken(params.PageToken)
102 if err != nil {
103 return ports.RefPage{}, err
104 }
105 if _, err := b.authorize(ctx, p, repo); err != nil {
106 return ports.RefPage{}, err
107 }
108 all, err := b.reader.ListRefs(ctx, repo)
109 if err != nil {
110 return ports.RefPage{}, err
111 }
112
113 var wanted []domain.Ref
114 for _, r := range all {
115 if len(params.Kinds) == 0 || slices.Contains(params.Kinds, r.Kind) {
116 wanted = append(wanted, r)
117 }
118 }
119 var page ports.RefPage
120 page.Refs, page.NextPageToken = pageAfter(wanted, size,
121 func(r domain.Ref) bool { return after == nil || refAfter(r, *after) },
122 encodeRefToken)
123 return page, nil
124}
125
126// refAfter reports whether r sorts after the ref the token names.
127func refAfter(r domain.Ref, last refCursor) bool {
128 if kr, lr := kindRank(r.Kind), kindRank(last.kind); kr != lr {
129 return kr > lr
130 }
131 return r.Name > last.name
132}
133
134// refCursor is the decoded page token of ListRefs.
135type refCursor struct {
136 kind domain.RefKind
137 name string
138}
139
140const (
141 refTokenPrefix = "r1:"
142 commitTokenPrefix = "c1:"
143)
144
145func encodeRefToken(last domain.Ref) string {
146 return base64.RawURLEncoding.EncodeToString([]byte(refTokenPrefix + string(last.Kind) + ":" + last.Name))
147}
148
149// decodeRefToken returns nil for the empty token.
150func decodeRefToken(tok string) (*refCursor, error) {
151 if tok == "" {
152 return nil, nil
153 }
154 raw, ok := decodeToken(tok, refTokenPrefix)
155 if !ok {
156 return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
157 }
158 kind, name, ok := strings.Cut(raw, ":")
159 k := domain.RefKind(kind)
160 if !ok || name == "" || (k != domain.RefKindHead && k != domain.RefKindBranch && k != domain.RefKindTag) {
161 return nil, fmt.Errorf("%w: refs", domain.ErrInvalidPageToken)
162 }
163 return &refCursor{kind: k, name: name}, nil
164}
165
166// ResolveRevision turns a revision into the commit it designates.
167func (b *Browse) ResolveRevision(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.ResolvedRevision, error) {
168 if _, err := b.authorize(ctx, p, repo); err != nil {
169 return domain.ResolvedRevision{}, err
170 }
171 return b.reader.ResolveRevision(ctx, repo, rev)
172}
173
174// ResolveRefPath splits a path such as "feature/x/dir/file" into the longest
175// leading ref and the path inside it.
176func (b *Browse) ResolveRefPath(ctx context.Context, p domain.Principal, repo domain.RepoName, path string) (domain.RefPath, error) {
177 if _, err := b.authorize(ctx, p, repo); err != nil {
178 return domain.RefPath{}, err
179 }
180 return b.reader.ResolveRefPath(ctx, repo, path)
181}
182
183// GetTree lists a directory at a revision. With includeLastCommit, entries get
184// their last commit, for at most domain.MaxLastCommitEntries of them.
185func (b *Browse) GetTree(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath, includeLastCommit bool) (domain.Tree, error) {
186 r, err := b.authorize(ctx, p, repo)
187 if err != nil {
188 return domain.Tree{}, err
189 }
190 // The last commits are the costly part (a history walk per entry) and
191 // depend only on the commit and the path: cache them once the revision is
192 // a full commit id. A name or HEAD can move, so it is never a key.
193 if !includeLastCommit || !rev.IsObjectID() {
194 return b.reader.GetTree(ctx, repo, rev, path, includeLastCommit)
195 }
196 key := historyKey{repo: repo, created: repoEpoch(r), rev: rev.String(), path: path.String(), kind: kindTreeLast}
197 if e, ok := b.history.get(key); ok {
198 return cloneTree(e.tree), nil
199 }
200 tree, err := b.reader.GetTree(ctx, repo, rev, path, true)
201 if err != nil {
202 return domain.Tree{}, err
203 }
204 b.history.put(treeEntry(key, tree))
205 return tree, nil
206}
207
208// ListPaths lists the paths of every file and symbolic link of a revision, at
209// most domain.MaxPathEntries paths and domain.MaxPathBytes bytes of paths.
210func (b *Browse) ListPaths(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.PathList, error) {
211 if _, err := b.authorize(ctx, p, repo); err != nil {
212 return domain.PathList{}, err
213 }
214 return b.reader.ListPaths(ctx, repo, rev, domain.MaxPathEntries, domain.MaxPathBytes)
215}
216
217// OpenBlob returns the description of a file and a reader of its content. The
218// caller closes the reader.
219func (b *Browse) OpenBlob(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision, path domain.TreePath) (domain.BlobInfo, io.ReadCloser, error) {
220 if _, err := b.authorize(ctx, p, repo); err != nil {
221 return domain.BlobInfo{}, nil, err
222 }
223 return b.reader.OpenBlob(ctx, repo, rev, path)
224}
225
226// ListCommits returns a page of history. The page token holds the commit the
227// listing started from, how many commits were already returned and a digest
228// of the path; a token used with another path is invalid.
229func (b *Browse) ListCommits(ctx context.Context, p domain.Principal, repo domain.RepoName, params ports.ListCommitsParams) (ports.HistoryPage, error) {
230 size, err := pageSize(params.PageSize)
231 if err != nil {
232 return ports.HistoryPage{}, err
233 }
234 q := ports.CommitQuery{Revision: params.Revision, Path: params.Path, Limit: size}
235 if params.PageToken != "" {
236 head, skip, err := decodeCommitToken(params.PageToken, params.Path)
237 if err != nil {
238 return ports.HistoryPage{}, err
239 }
240 q.Revision, q.Skip = domain.RevisionOf(head), skip
241 }
242 r, err := b.authorize(ctx, p, repo)
243 if err != nil {
244 return ports.HistoryPage{}, err
245 }
246 res, err := b.listCommits(ctx, r, q)
247 if err != nil {
248 return ports.HistoryPage{}, err
249 }
250 out := ports.HistoryPage{Commits: res.Commits}
251 if res.HasMore {
252 out.NextPageToken = encodeCommitToken(res.Head, q.Skip+len(res.Commits), params.Path)
253 }
254 return out, nil
255}
256
257// listCommits asks the reader, through the history cache for the first
258// commit of a path at a full commit id (the "last change" of a file): that
259// answer is immutable.
260func (b *Browse) listCommits(ctx context.Context, r domain.Repo, q ports.CommitQuery) (ports.CommitPage, error) {
261 repo := r.Name
262 if q.Limit != 1 || q.Skip != 0 || !q.Revision.IsObjectID() {
263 return b.reader.ListCommits(ctx, repo, q)
264 }
265 key := historyKey{repo: r.Name, created: repoEpoch(r), rev: q.Revision.String(), path: q.Path.String(), kind: kindPage, limit: q.Limit}
266 if e, ok := b.history.get(key); ok {
267 return clonePage(e.page), nil
268 }
269 res, err := b.reader.ListCommits(ctx, repo, q)
270 if err != nil {
271 return ports.CommitPage{}, err
272 }
273 b.history.put(pageEntry(key, res))
274 return res, nil
275}
276
277func pathDigest(path domain.TreePath) string {
278 sum := sha256.Sum256([]byte(path.String()))
279 return hex.EncodeToString(sum[:6])
280}
281
282func encodeCommitToken(head domain.ObjectID, skip int, path domain.TreePath) string {
283 raw := commitTokenPrefix + head.String() + ":" + strconv.Itoa(skip) + ":" + pathDigest(path)
284 return base64.RawURLEncoding.EncodeToString([]byte(raw))
285}
286
287func decodeCommitToken(tok string, path domain.TreePath) (domain.ObjectID, int, error) {
288 bad := fmt.Errorf("%w: commits", domain.ErrInvalidPageToken)
289 raw, ok := decodeToken(tok, commitTokenPrefix)
290 if !ok {
291 return domain.ObjectID{}, 0, bad
292 }
293 parts := strings.Split(raw, ":")
294 if len(parts) != 3 {
295 return domain.ObjectID{}, 0, bad
296 }
297 head, err := domain.ParseObjectID(parts[0])
298 skip, err2 := strconv.Atoi(parts[1])
299 if err != nil || err2 != nil || skip < 0 || skip > MaxCommitSkip || parts[2] != pathDigest(path) {
300 return domain.ObjectID{}, 0, bad
301 }
302 return head, skip, nil
303}
304
305// GetCommit returns a commit with its statistics and its diff against the
306// first parent, within the default diff limits.
307func (b *Browse) GetCommit(ctx context.Context, p domain.Principal, repo domain.RepoName, rev domain.Revision) (domain.CommitDetail, error) {
308 if _, err := b.authorize(ctx, p, repo); err != nil {
309 return domain.CommitDetail{}, err
310 }
311 return b.reader.GetCommit(ctx, repo, rev, domain.DefaultDiffLimits())
312}