1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "errors"
7 "fmt"
8 "strings"
9 "time"
10 "unicode/utf8"
11)
12
13// Errors of the read side. Callers match them with errors.Is; the rpc adapter
14// maps all of them but the not found ones to invalid_argument.
15var (
16 // ErrInvalidObjectID means a string is not a full SHA-1 object id.
17 ErrInvalidObjectID = errors.New("invalid object id")
18 // ErrInvalidRevision means a revision breaks the rules of ParseRevision.
19 ErrInvalidRevision = errors.New("invalid revision")
20 // ErrInvalidPath means a path inside a repository breaks the rules of
21 // ParseTreePath.
22 ErrInvalidPath = errors.New("invalid path")
23 // ErrInvalidPageToken means a page token was not produced by the server
24 // for this kind of listing.
25 ErrInvalidPageToken = errors.New("invalid page token")
26 // ErrInvalidPageSize means a page size is negative.
27 ErrInvalidPageSize = errors.New("invalid page size")
28 // ErrInvalidRefKind means a ref kind is not one of the known kinds.
29 ErrInvalidRefKind = errors.New("invalid ref kind")
30 // ErrNotDirectory means a path that must name a directory names something else.
31 ErrNotDirectory = errors.New("not a directory")
32 // ErrNotFile means a path that must name a file names something else.
33 ErrNotFile = errors.New("not a file")
34)
35
36// ObjectIDLen is the length of an object id in hexadecimal characters.
37// ermite creates SHA-1 repositories only.
38const ObjectIDLen = 40
39
40// ObjectID is a full, lowercase, hexadecimal SHA-1 object id. Only
41// ParseObjectID builds a non-zero value; the zero value means "no object".
42type ObjectID struct{ s string }
43
44// ParseObjectID validates s as a full object id.
45func ParseObjectID(s string) (ObjectID, error) {
46 if len(s) != ObjectIDLen || !isLowerHex(s) {
47 return ObjectID{}, fmt.Errorf("%w: %q", ErrInvalidObjectID, s)
48 }
49 return ObjectID{s: s}, nil
50}
51
52// String returns the id, or "" for the zero value.
53func (id ObjectID) String() string { return id.s }
54
55// IsZero reports whether id is the zero value.
56func (id ObjectID) IsZero() bool { return id.s == "" }
57
58func isLowerHex(s string) bool {
59 for i := range len(s) {
60 c := s[i]
61 if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
62 return false
63 }
64 }
65 return s != ""
66}
67
68// MinAbbrevLen is the shortest abbreviation of an object id accepted as a
69// revision.
70const MinAbbrevLen = 7
71
72// maxRevisionLen bounds a revision, which is a ref name or an object id.
73const maxRevisionLen = 512
74
75// Revision names a commit: a branch, a tag, a full ref name ("refs/heads/x"),
76// an object id or an abbreviation of at least MinAbbrevLen characters. The
77// zero value is HEAD. Parsing checks the syntax only, with the rules of
78// `git check-ref-format`; whether the revision exists is the reader's concern.
79type Revision struct{ s string }
80
81// ParseRevision validates s. The empty string is HEAD.
82func ParseRevision(s string) (Revision, error) {
83 if s == "" {
84 return Revision{}, nil
85 }
86 if reason := refNameProblem(s); reason != "" {
87 return Revision{}, fmt.Errorf("%w: %q: %s", ErrInvalidRevision, s, reason)
88 }
89 return Revision{s: s}, nil
90}
91
92// RevisionOf returns the revision that designates exactly the commit id.
93func RevisionOf(id ObjectID) Revision { return Revision(id) }
94
95// String returns the revision text, or "" for HEAD.
96func (r Revision) String() string { return r.s }
97
98// IsHead reports whether r is the zero value, which designates HEAD.
99func (r Revision) IsHead() bool { return r.s == "" }
100
101// IsObjectID reports whether r is a full object id.
102func (r Revision) IsObjectID() bool { return len(r.s) == ObjectIDLen && isLowerHex(r.s) }
103
104// IsAbbrevID reports whether r is a hexadecimal abbreviation of an object id
105// (MinAbbrevLen to ObjectIDLen-1 characters).
106func (r Revision) IsAbbrevID() bool {
107 return len(r.s) >= MinAbbrevLen && len(r.s) < ObjectIDLen && isLowerHex(r.s)
108}
109
110// refNameProblem returns why s cannot be a ref name or revision, or "".
111func refNameProblem(s string) string {
112 switch {
113 case len(s) > maxRevisionLen:
114 return "too long"
115 case !utf8.ValidString(s):
116 return "not valid UTF-8"
117 case strings.HasPrefix(s, "-"):
118 return "starts with a dash"
119 case strings.HasPrefix(s, "/") || strings.HasSuffix(s, "/"):
120 return "starts or ends with a slash"
121 case strings.HasSuffix(s, "."):
122 return "ends with a dot"
123 case s == "@":
124 return "is @"
125 case strings.Contains(s, ".."), strings.Contains(s, "@{"), strings.Contains(s, "//"):
126 return "contains a forbidden sequence"
127 }
128 for _, c := range s {
129 if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[\\", c) {
130 return "contains a forbidden character"
131 }
132 }
133 for part := range strings.SplitSeq(s, "/") {
134 if strings.HasPrefix(part, ".") || strings.HasSuffix(part, ".lock") {
135 return "has a component that starts with a dot or ends with .lock"
136 }
137 }
138 return ""
139}
140
141// maxTreePathLen bounds a path inside a repository, as Linux does.
142const maxTreePathLen = 4096
143
144// TreePath is a slash-separated path from the root of a repository, without
145// leading or trailing slash. The zero value is the root. Only ParseTreePath
146// builds a non-root value.
147type TreePath struct{ s string }
148
149// ParseTreePath validates s. It accepts one trailing slash, which it drops,
150// and the empty string (the root). Empty, "." and ".." components, NUL and
151// control characters, and text that is not valid UTF-8 are rejected.
152func ParseTreePath(s string) (TreePath, error) {
153 s = strings.TrimSuffix(s, "/")
154 if s == "" {
155 return TreePath{}, nil
156 }
157 if len(s) > maxTreePathLen || !utf8.ValidString(s) {
158 return TreePath{}, fmt.Errorf("%w: %q: too long or not valid UTF-8", ErrInvalidPath, truncate(s))
159 }
160 for _, c := range s {
161 if c < 0x20 || c == 0x7f {
162 return TreePath{}, fmt.Errorf("%w: %q: control character", ErrInvalidPath, truncate(s))
163 }
164 }
165 for part := range strings.SplitSeq(s, "/") {
166 if part == "" || part == "." || part == ".." {
167 return TreePath{}, fmt.Errorf("%w: %q: empty, . or .. component", ErrInvalidPath, truncate(s))
168 }
169 }
170 return TreePath{s: s}, nil
171}
172
173func truncate(s string) string {
174 if len(s) > 64 {
175 return s[:64] + "…"
176 }
177 return s
178}
179
180// String returns the path, or "" for the root.
181func (p TreePath) String() string { return p.s }
182
183// IsRoot reports whether p is the root of the repository.
184func (p TreePath) IsRoot() bool { return p.s == "" }
185
186// Join returns the path of the entry name inside the directory p. name must
187// be a single component.
188func (p TreePath) Join(name string) string {
189 if p.s == "" {
190 return name
191 }
192 return p.s + "/" + name
193}
194
195// RefKind is the kind of a Git ref.
196type RefKind string
197
198// Ref kinds, in the order refs are listed.
199const (
200 // RefKindHead is the symbolic ref HEAD.
201 RefKindHead RefKind = "head"
202 // RefKindBranch is a branch (refs/heads/*).
203 RefKindBranch RefKind = "branch"
204 // RefKindTag is a tag (refs/tags/*), lightweight or annotated.
205 RefKindTag RefKind = "tag"
206)
207
208// Ref is a branch, a tag or HEAD.
209type Ref struct {
210 // Name is the full name: "refs/heads/main", "refs/tags/v1" or "HEAD".
211 Name string
212 // ShortName is Name without "refs/heads/" or "refs/tags/". For HEAD it is
213 // the short name of the default branch, empty if that branch is unborn.
214 ShortName string
215 Kind RefKind
216 // ID is the object the ref points to (the tag object of an annotated
217 // tag). Zero for HEAD when the default branch is unborn.
218 ID ObjectID
219 // CommitID is the commit the ref designates once tags are followed. Zero
220 // for a tag that does not lead to a commit and for an unborn HEAD.
221 CommitID ObjectID
222}
223
224// ResolvedRevision is the result of resolving a Revision.
225type ResolvedRevision struct {
226 // CommitID is the commit the revision designates, tags followed.
227 CommitID ObjectID
228 // Ref is the full name of the ref the revision matched; empty when the
229 // revision was an object id or HEAD.
230 Ref string
231}
232
233// RefPath is a URL path split into a revision and the path inside it. It is
234// the answer to "feature/x/dir/file": the ref feature/x and the path dir/file.
235type RefPath struct {
236 // Name is the matched ref's short name, or the object id text.
237 Name string
238 // Ref is the matched ref's full name; empty when Name is an object id.
239 Ref string
240 // CommitID is the commit Name designates.
241 CommitID ObjectID
242 // Rest is what follows Name in the path, without leading slash.
243 Rest TreePath
244}
245
246// EntryKind is the kind of an entry of a Git tree.
247type EntryKind string
248
249// Entry kinds.
250const (
251 // EntryFile is a regular file (mode 100644).
252 EntryFile EntryKind = "file"
253 // EntryExecutable is an executable file (mode 100755).
254 EntryExecutable EntryKind = "executable"
255 // EntryDirectory is a directory (mode 040000).
256 EntryDirectory EntryKind = "directory"
257 // EntrySymlink is a symbolic link (mode 120000).
258 EntrySymlink EntryKind = "symlink"
259 // EntrySubmodule is a submodule commit (mode 160000).
260 EntrySubmodule EntryKind = "submodule"
261)
262
263// File mode bits as Git stores them.
264const (
265 modeTypeMask = 0o170000
266 modeDirectory = 0o040000
267 modeSymlink = 0o120000
268 modeRegular = 0o100000
269 modeSubmodule = 0o160000
270)
271
272// EntryKindOf returns the kind of an entry from its Git mode, or "" if the
273// mode is not a Git mode.
274func EntryKindOf(mode uint32) EntryKind {
275 switch mode & modeTypeMask {
276 case modeDirectory:
277 return EntryDirectory
278 case modeSymlink:
279 return EntrySymlink
280 case modeSubmodule:
281 return EntrySubmodule
282 case modeRegular:
283 if mode&0o111 != 0 {
284 return EntryExecutable
285 }
286 return EntryFile
287 }
288 return ""
289}
290
291// TreeEntry is one entry of a directory.
292type TreeEntry struct {
293 // Name is the name in its directory; Path is the path from the root.
294 Name, Path string
295 Kind EntryKind
296 Mode uint32
297 // ID is the blob, tree or submodule commit.
298 ID ObjectID
299 // Size is the size of a file or symlink; 0 for directories and submodules.
300 Size int64
301 // LastCommit is the latest commit that changed the entry; nil unless
302 // asked for, and for the entries the reader left out (see
303 // Tree.LastCommitsOmitted).
304 LastCommit *Commit
305}
306
307// Tree is a listed directory.
308type Tree struct {
309 // CommitID is the commit the revision resolved to.
310 CommitID ObjectID
311 // ID is the tree object that was listed.
312 ID ObjectID
313 // Entries are the directories first, then the other entries, each group
314 // ordered by name.
315 Entries []TreeEntry
316 // LastCommitsOmitted is true when last commits were asked for and the
317 // reader gave only the first entries (MaxLastCommitEntries) one.
318 LastCommitsOmitted bool
319 // Truncated is true when the directory has more than MaxTreeEntries
320 // entries: Entries holds the first ones in the documented order.
321 Truncated bool
322}
323
324// MaxTreeEntries bounds the entries of a listed directory.
325const MaxTreeEntries = 5000
326
327// MaxLastCommitEntries bounds the entries of one directory that get a last
328// commit: each costs a history lookup.
329const MaxLastCommitEntries = 200
330
331// BlobInfo describes a file at a revision.
332type BlobInfo struct {
333 ID ObjectID
334 Path string
335 Size int64
336 // Binary follows Git's rule: a NUL byte in the first 8,000 bytes.
337 Binary bool
338 // MediaType comes from the file name and the first bytes, without
339 // parameters; "application/octet-stream" when unknown.
340 MediaType string
341 Mode uint32
342}
343
344// BinarySniffLen is how many leading bytes decide whether a blob is binary.
345const BinarySniffLen = 8000
346
347// Signature is who did something in Git, and when.
348type Signature struct {
349 Name, Email string
350 Time time.Time
351}
352
353// Commit is the metadata of a commit.
354type Commit struct {
355 ID ObjectID
356 Parents []ObjectID
357 Author Signature
358 Committer Signature
359 // Summary is the first line of Message.
360 Summary string
361 // Message is the whole message without trailing line breaks, converted to
362 // valid UTF-8.
363 Message string
364}
365
366// FileStatus tells what a commit did to a file.
367type FileStatus string
368
369// File statuses.
370const (
371 FileAdded FileStatus = "added"
372 FileDeleted FileStatus = "deleted"
373 FileModified FileStatus = "modified"
374 FileRenamed FileStatus = "renamed"
375 FileCopied FileStatus = "copied"
376 FileTypeChanged FileStatus = "type_changed"
377)
378
379// LineKind tells how a line of a hunk relates to the two sides of a diff.
380type LineKind string
381
382// Line kinds.
383const (
384 LineContext LineKind = "context"
385 LineAddition LineKind = "addition"
386 LineDeletion LineKind = "deletion"
387)
388
389// Line is one line of a hunk.
390type Line struct {
391 Kind LineKind
392 // OldNumber is 0 for an addition; NewNumber is 0 for a deletion.
393 OldNumber, NewNumber int
394 // Text has no line terminator and is valid UTF-8.
395 Text string
396 // NoNewlineAtEOF is set on the last line of a side that has no final line
397 // break.
398 NoNewlineAtEOF bool
399}
400
401// Hunk is a contiguous changed region with its context.
402type Hunk struct {
403 OldStart, OldLines int
404 NewStart, NewLines int
405 // Header is the text after the second "@@", often the enclosing function.
406 Header string
407 Lines []Line
408}
409
410// FileDiff is the change of one file.
411type FileDiff struct {
412 // OldPath is empty for an added file, NewPath for a deleted one.
413 OldPath, NewPath string
414 Status FileStatus
415 // OldMode and NewMode are 0 for an added and a deleted file.
416 OldMode, NewMode uint32
417 // OldID and NewID are the blob ids, zero for an added and a deleted file.
418 OldID, NewID ObjectID
419 // Binary is Git's numstat "-": no hunks, no additions or deletions.
420 Binary bool
421 Additions, Deletions int
422 // Similarity is a percentage for a renamed or copied file, 0 otherwise.
423 Similarity int
424 // Truncated means the file exceeded a diff limit: Hunks is empty although
425 // the file changed. Additions and Deletions stay accurate.
426 Truncated bool
427 Hunks []Hunk
428}
429
430// Diff is the structured difference a commit introduces against its first
431// parent, or against the empty tree for a root commit.
432type Diff struct {
433 // Files are ordered as Git reports them (by path). Files past
434 // DiffLimits.MaxFiles are left out.
435 Files []FileDiff
436 // Truncated is true when files were left out or a listed file is truncated.
437 Truncated bool
438}
439
440// DiffStats are the totals over every changed file, including the ones the
441// diff leaves out or truncates. Binary files count 0 lines.
442type DiffStats struct {
443 FilesChanged, Additions, Deletions int
444}
445
446// CommitDetail is a commit with its statistics and diff.
447type CommitDetail struct {
448 Commit Commit
449 Stats DiffStats
450 Diff Diff
451}
452
453// DiffLimits bound the size of a Diff. Lines are hunk lines, context included.
454type DiffLimits struct {
455 // MaxFiles is the number of files kept.
456 MaxFiles int
457 // MaxFileLines is the most lines one file may have; a file over it is
458 // truncated.
459 MaxFileLines int
460 // MaxTotalLines is the most lines of all the files. A file is truncated
461 // when it does not fit in what remains; a smaller one after it may still
462 // fit.
463 MaxTotalLines int
464 // MaxTotalBytes is the most text, in bytes, of all the lines. It bounds
465 // the memory of a diff whose lines are very long; files are truncated
466 // like for MaxTotalLines.
467 MaxTotalBytes int
468}
469
470// DefaultDiffLimits are the limits of the API contract: 300 files, 2,000
471// lines per file, 20,000 lines in total, plus 8 MiB of text.
472func DefaultDiffLimits() DiffLimits {
473 return DiffLimits{MaxFiles: 300, MaxFileLines: 2000, MaxTotalLines: 20000, MaxTotalBytes: 8 << 20}
474}