1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "fmt"
8 "slices"
9 "strings"
10 "time"
11 "unicode/utf8"
12)
13
14// Errors of the read side. Callers match them with errors.Is; the rpc adapter
15// maps all of them but the not found ones to invalid_argument.
16var (
17 // ErrInvalidObjectID means a string is not a full SHA-1 object id.
18 ErrInvalidObjectID = errors.New("invalid object id")
19 // ErrInvalidRevision means a revision breaks the rules of ParseRevision.
20 ErrInvalidRevision = errors.New("invalid revision")
21 // ErrInvalidPath means a path inside a repository breaks the rules of
22 // ParseTreePath.
23 ErrInvalidPath = errors.New("invalid path")
24 // ErrInvalidPageToken means a page token was not produced by the server
25 // for this kind of listing.
26 ErrInvalidPageToken = errors.New("invalid page token")
27 // ErrInvalidPageSize means a page size is negative.
28 ErrInvalidPageSize = errors.New("invalid page size")
29 // ErrInvalidRefKind means a ref kind is not one of the known kinds.
30 ErrInvalidRefKind = errors.New("invalid ref kind")
31 // ErrNotDirectory means a path that must name a directory names something else.
32 ErrNotDirectory = errors.New("not a directory")
33 // ErrNotFile means a path that must name a file names something else.
34 ErrNotFile = errors.New("not a file")
35)
36
37// ObjectIDLen is the length of an object id in hexadecimal characters.
38// ermite creates SHA-1 repositories only.
39const ObjectIDLen = 40
40
41// ObjectID is a full, lowercase, hexadecimal SHA-1 object id. Only
42// ParseObjectID builds a non-zero value; the zero value means "no object".
43type ObjectID struct{ s string }
44
45// ParseObjectID validates s as a full object id.
46func ParseObjectID(s string) (ObjectID, error) {
47 if len(s) != ObjectIDLen || !isLowerHex(s) {
48 return ObjectID{}, fmt.Errorf("%w: %q", ErrInvalidObjectID, s)
49 }
50 return ObjectID{s: s}, nil
51}
52
53// String returns the id, or "" for the zero value.
54func (id ObjectID) String() string { return id.s }
55
56// IsZero reports whether id is the zero value.
57func (id ObjectID) IsZero() bool { return id.s == "" }
58
59func isLowerHex(s string) bool {
60 for i := range len(s) {
61 c := s[i]
62 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
63 return false
64 }
65 }
66 return s != ""
67}
68
69// MinAbbrevLen is the shortest abbreviation of an object id accepted as a
70// revision.
71const MinAbbrevLen = 7
72
73// maxRevisionLen bounds a revision, which is a ref name or an object id.
74const maxRevisionLen = 512
75
76// Revision names a commit: a branch, a tag, a full ref name ("refs/heads/x"),
77// an object id or an abbreviation of at least MinAbbrevLen characters. The
78// zero value is HEAD. Parsing checks the syntax only, with the rules of
79// `git check-ref-format`; whether the revision exists is the reader's concern.
80type Revision struct{ s string }
81
82// ParseRevision validates s. The empty string is HEAD.
83func ParseRevision(s string) (Revision, error) {
84 if s == "" {
85 return Revision{}, nil
86 }
87 if reason := refNameProblem(s); reason != "" {
88 return Revision{}, fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
89 }
90 return Revision{s: s}, nil
91}
92
93// RevisionOf returns the revision that designates exactly the commit id.
94func RevisionOf(id ObjectID) Revision { return Revision(id) }
95
96// String returns the revision text, or "" for HEAD.
97func (r Revision) String() string { return r.s }
98
99// IsHead reports whether r is the zero value, which designates HEAD.
100func (r Revision) IsHead() bool { return r.s == "" }
101
102// IsObjectID reports whether r is a full object id.
103func (r Revision) IsObjectID() bool { return len(r.s) == ObjectIDLen && isLowerHex(r.s) }
104
105// IsAbbrevID reports whether r is a hexadecimal abbreviation of an object id
106// (MinAbbrevLen to ObjectIDLen-1 characters).
107func (r Revision) IsAbbrevID() bool {
108 return len(r.s) >= MinAbbrevLen && len(r.s) < ObjectIDLen && isLowerHex(r.s)
109}
110
111// refNameProblem returns why s cannot be a ref name or revision, or "".
112func refNameProblem(s string) string {
113 switch {
114 case len(s) > maxRevisionLen:
115 return "too long"
116 case !utf8.ValidString(s):
117 return "not valid UTF-8"
118 case strings.HasPrefix(s, "-"):
119 return "starts with a dash"
120 case strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/"):
121 return "starts or ends with a slash"
122 case strings.HasSuffix(s, "."):
123 return "ends with a dot"
124 case s == "@":
125 return "is @"
126 case strings.Contains(s, ".."), strings.Contains(s, "@{"), strings.Contains(s, "//"):
127 return "contains a forbidden sequence"
128 }
129 for _, c := range s {
130 if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[\\", c) {
131 return "contains a forbidden character"
132 }
133 }
134 for part := range strings.SplitSeq(s, "/") {
135 if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
136 return "has a component that starts with a dot or ends with .lock"
137 }
138 }
139 return ""
140}
141
142// maxTreePathLen bounds a path inside a repository, as Linux does.
143const maxTreePathLen = 4096
144
145// TreePath is a slash-separated path from the root of a repository, without
146// leading or trailing slash. The zero value is the root. Only ParseTreePath
147// builds a non-root value.
148type TreePath struct{ s string }
149
150// ParseTreePath validates s. It accepts one trailing slash, which it drops,
151// and the empty string (the root). Empty, "." and ".." components, NUL and
152// control characters, and text that is not valid UTF-8 are rejected.
153func ParseTreePath(s string) (TreePath, error) {
154 s = strings.TrimSuffix(s, "/")
155 if s == "" {
156 return TreePath{}, nil
157 }
158 if why := treePathProblem(s); why != "" {
159 return TreePath{}, fmt.Errorf("%w: %q: %s", ErrInvalidPath, truncate(s), why)
160 }
161 return TreePath{s: s}, nil
162}
163
164// IsAddressablePath reports whether s is a non-root path that ParseTreePath
165// accepts as it is (no trailing slash). It allocates nothing.
166func IsAddressablePath(s string) bool {
167 return s != "" && treePathProblem(s) == ""
168}
169
170// IsAddressablePathBytes is IsAddressablePath for a path held in bytes; it
171// spares the copy into a string.
172func IsAddressablePathBytes(b []byte) bool {
173 return len(b) > 0 && len(b) <= maxTreePathLen && utf8.Valid(b) && pathShapeProblem(b) == ""
174}
175
176// treePathProblem returns why s cannot be a non-root tree path, or "".
177func treePathProblem(s string) string {
178 if len(s) > maxTreePathLen || !utf8.ValidString(s) {
179 return "too long or not valid UTF-8"
180 }
181 return pathShapeProblem(s)
182}
183
184// pathShapeProblem checks the control characters and the components of a path
185// that is valid UTF-8 and short enough. The bytes of a multi-byte character
186// are all 0x80 or more, so it looks at bytes.
187func pathShapeProblem[S ~string | ~[]byte](s S) string {
188 start := 0
189 for i := 0; i <= len(s); i++ {
190 if i < len(s) && s[i] != '/' {
191 if c := s[i]; c < 0x20 || c == 0x7f {
192 return "control character"
193 }
194 continue
195 }
196 n := i - start
197 if n == 0 || (n == 1 && s[start] == '.') || (n == 2 && s[start] == '.' && s[start+1] == '.') {
198 return "empty, . or .. component"
199 }
200 start = i + 1
201 }
202 return ""
203}
204
205func truncate(s string) string {
206 if len(s) > 64 {
207 return s[:64] + "…"
208 }
209 return s
210}
211
212// String returns the path, or "" for the root.
213func (p TreePath) String() string { return p.s }
214
215// IsRoot reports whether p is the root of the repository.
216func (p TreePath) IsRoot() bool { return p.s == "" }
217
218// Join returns the path of the entry name inside the directory p. name must
219// be a single component.
220func (p TreePath) Join(name string) string {
221 if p.s == "" {
222 return name
223 }
224 return p.s + "/" + name
225}
226
227// RefKind is the kind of a Git ref.
228type RefKind string
229
230// Ref kinds, in the order refs are listed.
231const (
232 // RefKindHead is the symbolic ref HEAD.
233 RefKindHead RefKind = "head"
234 // RefKindBranch is a branch (refs/heads/*).
235 RefKindBranch RefKind = "branch"
236 // RefKindTag is a tag (refs/tags/*), lightweight or annotated.
237 RefKindTag RefKind = "tag"
238)
239
240// Ref is a branch, a tag or HEAD.
241type Ref struct {
242 // Name is the full name: "refs/heads/main", "refs/tags/v1" or "HEAD".
243 Name string
244 // ShortName is Name without "refs/heads/" or "refs/tags/". For HEAD it is
245 // the short name of the default branch, empty if that branch is unborn.
246 ShortName string
247 Kind RefKind
248 // ID is the object the ref points to (the tag object of an annotated
249 // tag). Zero for HEAD when the default branch is unborn.
250 ID ObjectID
251 // CommitID is the commit the ref designates once tags are followed. Zero
252 // for a tag that does not lead to a commit and for an unborn HEAD.
253 CommitID ObjectID
254}
255
256// ResolvedRevision is the result of resolving a Revision.
257type ResolvedRevision struct {
258 // CommitID is the commit the revision designates, tags followed.
259 CommitID ObjectID
260 // Ref is the full name of the ref the revision matched; empty when the
261 // revision was an object id or HEAD.
262 Ref string
263}
264
265// RefPath is a URL path split into a revision and the path inside it. It is
266// the answer to "feature/x/dir/file": the ref feature/x and the path dir/file.
267type RefPath struct {
268 // Name is the matched ref's short name, or the object id text.
269 Name string
270 // Ref is the matched ref's full name; empty when Name is an object id.
271 Ref string
272 // CommitID is the commit Name designates.
273 CommitID ObjectID
274 // Rest is what follows Name in the path, without leading slash.
275 Rest TreePath
276}
277
278// EntryKind is the kind of an entry of a Git tree.
279type EntryKind string
280
281// Entry kinds.
282const (
283 // EntryFile is a regular file (mode 100644).
284 EntryFile EntryKind = "file"
285 // EntryExecutable is an executable file (mode 100755).
286 EntryExecutable EntryKind = "executable"
287 // EntryDirectory is a directory (mode 040000).
288 EntryDirectory EntryKind = "directory"
289 // EntrySymlink is a symbolic link (mode 120000).
290 EntrySymlink EntryKind = "symlink"
291 // EntrySubmodule is a submodule commit (mode 160000).
292 EntrySubmodule EntryKind = "submodule"
293)
294
295// File mode bits as Git stores them.
296const (
297 modeTypeMask = 0o170000
298 modeDirectory = 0o040000
299 modeSymlink = 0o120000
300 modeRegular = 0o100000
301 modeSubmodule = 0o160000
302)
303
304// EntryKindOf returns the kind of an entry from its Git mode, or "" if the
305// mode is not a Git mode.
306func EntryKindOf(mode uint32) EntryKind {
307 switch mode & modeTypeMask {
308 case modeDirectory:
309 return EntryDirectory
310 case modeSymlink:
311 return EntrySymlink
312 case modeSubmodule:
313 return EntrySubmodule
314 case modeRegular:
315 if mode&0o111 != 0 {
316 return EntryExecutable
317 }
318 return EntryFile
319 }
320 return ""
321}
322
323// TreeEntry is one entry of a directory.
324type TreeEntry struct {
325 // Name is the name in its directory; Path is the path from the root.
326 Name, Path string
327 Kind EntryKind
328 Mode uint32
329 // ID is the blob, tree or submodule commit.
330 ID ObjectID
331 // Size is the size of a file or symlink; 0 for directories and submodules.
332 Size int64
333 // LastCommit is the latest commit that changed the entry; nil unless
334 // asked for, and for the entries the reader left out (see
335 // Tree.LastCommitsOmitted).
336 LastCommit *Commit
337}
338
339// Tree is a listed directory.
340type Tree struct {
341 // CommitID is the commit the revision resolved to.
342 CommitID ObjectID
343 // ID is the tree object that was listed.
344 ID ObjectID
345 // Entries are the directories first, then the other entries, each group
346 // ordered by name.
347 Entries []TreeEntry
348 // LastCommitsOmitted is true when last commits were asked for and the
349 // reader gave only the first entries (MaxLastCommitEntries) one.
350 LastCommitsOmitted bool
351 // Truncated is true when the directory has more than MaxTreeEntries
352 // entries: Entries holds the first ones in the documented order.
353 Truncated bool
354}
355
356// MaxTreeEntries bounds the entries of a listed directory.
357const MaxTreeEntries = 5000
358
359// MaxPathEntries bounds the paths ListPaths returns.
360const MaxPathEntries = 100_000
361
362// MaxPathBytes bounds the total bytes of the paths ListPaths returns.
363const MaxPathBytes = 8 << 20
364
365// PathList is the recursive list of the file paths of a revision.
366type PathList struct {
367 // CommitID is the commit the revision resolved to.
368 CommitID ObjectID
369 // TreeID is the root tree of that commit.
370 TreeID ObjectID
371 // Paths are the paths of the files and symbolic links, in tree order (the
372 // order of `git ls-tree -r`).
373 Paths []string
374 // Truncated is true when more paths exist than the ones in Paths.
375 Truncated bool
376
377 size int // bytes of Paths, kept by Add
378}
379
380// Add lists p, the next path of a walk in tree order, within the bounds of
381// ListPaths: at most maxPaths paths and maxBytes bytes of paths. A path that
382// is not addressable (IsAddressablePath) is left out. When a bound is reached
383// p is not listed, Truncated is set, and Add returns false: the walk can stop.
384// Otherwise it returns true. The bytes of p are kept, not copied.
385func (l *PathList) Add(p string, maxPaths, maxBytes int) bool {
386 if !IsAddressablePath(p) {
387 return true
388 }
389 if len(l.Paths) >= maxPaths || l.size+len(p) > maxBytes {
390 l.Truncated = true
391 return false
392 }
393 l.size += len(p)
394 l.Paths = append(l.Paths, p)
395 return true
396}
397
398// MaxLastCommitEntries bounds the entries of one directory that get a last
399// commit: each costs a history lookup.
400const MaxLastCommitEntries = 200
401
402// BlobInfo describes a file at a revision.
403type BlobInfo struct {
404 ID ObjectID
405 Path string
406 Size int64
407 // Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
408 Binary bool
409 // MediaType comes from the file name and the first bytes, without
410 // parameters; "application/octet-stream" when unknown.
411 MediaType string
412 Mode uint32
413}
414
415// BinarySniffLen is how many leading bytes decide whether a blob is binary.
416const BinarySniffLen = 8000
417
418// Signature is who did something in Git, and when.
419type Signature struct {
420 Name, Email string
421 Time time.Time
422}
423
424// Commit is the metadata of a commit.
425type Commit struct {
426 ID ObjectID
427 Parents []ObjectID
428 Author Signature
429 Committer Signature
430 // Summary is the first line of Message.
431 Summary string
432 // Message is the whole message without trailing line breaks, converted to
433 // valid UTF-8.
434 Message string
435}
436
437// FileStatus tells what a commit did to a file.
438type FileStatus string
439
440// File statuses.
441const (
442 FileAdded FileStatus = "added"
443 FileDeleted FileStatus = "deleted"
444 FileModified FileStatus = "modified"
445 FileRenamed FileStatus = "renamed"
446 FileCopied FileStatus = "copied"
447 FileTypeChanged FileStatus = "type_changed"
448)
449
450// LineKind tells how a line of a hunk relates to the two sides of a diff.
451type LineKind string
452
453// Line kinds.
454const (
455 LineContext LineKind = "context"
456 LineAddition LineKind = "addition"
457 LineDeletion LineKind = "deletion"
458)
459
460// Line is one line of a hunk.
461type Line struct {
462 Kind LineKind
463 // OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
464 OldNumber, NewNumber int
465 // Text has no line terminator and is valid UTF-8.
466 Text string
467 // NoNewlineAtEOF is set on the last line of a side that has no final line
468 // break.
469 NoNewlineAtEOF bool
470}
471
472// Hunk is a contiguous changed region with its context.
473type Hunk struct {
474 OldStart, OldLines int
475 NewStart, NewLines int
476 // Header is the text after the second "@@", often the enclosing function.
477 Header string
478 Lines []Line
479}
480
481// FileDiff is the change of one file.
482type FileDiff struct {
483 // OldPath is empty for an added file, NewPath for a deleted one.
484 OldPath, NewPath string
485 Status FileStatus
486 // OldMode and NewMode are 0 for an added and a deleted file.
487 OldMode, NewMode uint32
488 // OldID and NewID are the blob ids, zero for an added and a deleted file.
489 OldID, NewID ObjectID
490 // Binary is Git's numstat "-": no hunks, no additions or deletions.
491 Binary bool
492 Additions, Deletions int
493 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
494 Similarity int
495 // Truncated means the file exceeded a diff limit: Hunks is empty although
496 // the file changed. Additions and Deletions stay accurate. A file outside
497 // the DiffLimits window has no Hunks and is not Truncated.
498 Truncated bool
499 Hunks []Hunk
500}
501
502// Diff is the structured difference a commit introduces against its first
503// parent, or against the empty tree for a root commit.
504type Diff struct {
505 // Files are ordered as Git reports them (by path). Files past
506 // DiffLimits.MaxFiles (when it is not 0) are left out.
507 Files []FileDiff
508 // Truncated is true when files were left out or a listed file is truncated.
509 // Files listed without hunks because they are outside the DiffLimits window
510 // do not make it true.
511 Truncated bool
512}
513
514// DiffStats are the totals over every changed file, including the ones the
515// diff leaves out or truncates. Binary files count 0 lines.
516type DiffStats struct {
517 FilesChanged, Additions, Deletions int
518}
519
520// CommitDetail is a commit with its statistics and diff.
521type CommitDetail struct {
522 Commit Commit
523 Stats DiffStats
524 Diff Diff
525}
526
527// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
528type DiffLimits struct {
529 // MaxFiles is the number of files kept; 0 means no limit on the number of
530 // files (every changed file is listed).
531 MaxFiles int
532 // MaxFileLines is the most lines one file may have; a file over it is
533 // truncated.
534 MaxFileLines int
535 // MaxTotalLines is the most lines of all the files. A file is truncated
536 // when it does not fit in what remains; a smaller one after it may still
537 // fit.
538 MaxTotalLines int
539 // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
540 // the memory of a diff whose lines are very long; files are truncated
541 // like for MaxTotalLines.
542 MaxTotalBytes int
543 // From and To select the files whose hunks are read: those with an index in
544 // [From, To), counted from 0 in the order of Diff.Files. The other files
545 // are listed (paths, status, modes, ids, Binary, Additions, Deletions,
546 // Similarity) without hunks; they are not Truncated and use none of
547 // MaxTotalLines and MaxTotalBytes. To == 0 means no end, so the zero values
548 // select every file. MaxFileLines applies to each file of the window.
549 From, To int
550}
551
552// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
553// lines per file, 20,000 lines in total, plus 8 MiB of text.
554func DefaultDiffLimits() DiffLimits {
555 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
556}
557
558// ParseBranchName validates s as the short name of a branch ("feat/x"), with
559// the syntax rules of ParseRevision, and rejects a full ref name, which would
560// not be short.
561func ParseBranchName(s string) (string, error) {
562 switch {
563 case s == "":
564 return "", fmt.Errorf("%w: empty branch name", ErrInvalidRevision)
565 case strings.HasPrefix(s, "refs/"):
566 return "", fmt.Errorf("%w: %q: give the short name of the branch", ErrInvalidRevision, s)
567 }
568 if reason := refNameProblem(s); reason != "" {
569 return "", fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
570 }
571 return s, nil
572}
573
574// MaxRefPathTries bounds the number of leading segments of a path tried as a
575// ref name by SplitRefPath.
576const MaxRefPathTries = 32
577
578// SplitRefPath splits a path such as "feature/x/dir/file" into the longest
579// leading part that resolve accepts as a revision and the rest. resolve
580// reports domain.ErrNotFound for a name that is not a revision; any other
581// error stops the search. At most MaxRefPathTries leading segments are tried.
582// It returns ErrNotFound if nothing matches.
583func SplitRefPath(path string, resolve func(Revision) (ResolvedRevision, error)) (RefPath, error) {
584 path = strings.Trim(path, "/")
585 segs := strings.Split(path, "/")
586 if path == "" || slices.Contains(segs, "") {
587 return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
588 }
589 for n := min(len(segs), MaxRefPathTries); n >= 1; n-- {
590 name := strings.Join(segs[:n], "/")
591 rev, err := ParseRevision(name)
592 if err != nil {
593 continue
594 }
595 res, err := resolve(rev)
596 if errors.Is(err, ErrNotFound) {
597 continue
598 }
599 if err != nil {
600 return RefPath{}, err
601 }
602 rest, err := ParseTreePath(strings.Join(segs[n:], "/"))
603 if err != nil {
604 return RefPath{}, err
605 }
606 return RefPath{Name: name, Ref: res.Ref, CommitID: res.CommitID, Rest: rest}, nil
607 }
608 return RefPath{}, fmt.Errorf("path %q: %w", path, ErrNotFound)
609}
610
611// SortTreeEntries orders the entries of a directory as the port promises:
612// directories first, then the other entries, each group by name.
613func SortTreeEntries(entries []TreeEntry) {
614 slices.SortStableFunc(entries, func(a, b TreeEntry) int {
615 ad, bd := a.Kind == EntryDirectory, b.Kind == EntryDirectory
616 switch {
617 case ad && !bd:
618 return -1
619 case !ad && bd:
620 return 1
621 }
622 return strings.Compare(a.Name, b.Name)
623 })
624}
625
626// BoundTree keeps the first MaxTreeEntries entries of t and sets Truncated
627// when there were more.
628func (t *Tree) BoundTree() {
629 if len(t.Entries) > MaxTreeEntries {
630 t.Entries, t.Truncated = t.Entries[:MaxTreeEntries], true
631 }
632}