1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package domain
4
5import (
6 "fmt"
7 "strings"
8)
9
10// MaxRepoNameLen is the longest repository name, in characters.
11const MaxRepoNameLen = 100
12
13// gitSuffix is the suffix of a Git URL and of a repository directory.
14const gitSuffix = ".git"
15
16// reservedNames cannot name a repository: they are routes of the server.
17var reservedNames = map[string]bool{
18 "-": true,
19 "robots.txt": true,
20 "favicon.ico": true,
21}
22
23// RepoName is a validated repository name. Only ParseRepoName and
24// ParseRepoPath build a non-zero value; the zero value is invalid.
25type RepoName struct{ s string }
26
27// String returns the name.
28func (n RepoName) String() string { return n.s }
29
30// IsZero reports whether n is the invalid zero value.
31func (n RepoName) IsZero() bool { return n.s == "" }
32
33// ParseRepoName validates s as a repository name. It does not strip any
34// ".git" suffix: a name ending in ".git" is invalid.
35//
36// The error matches ErrInvalidName and is an *InvalidNameError.
37func ParseRepoName(s string) (RepoName, error) {
38 reason := nameProblem(s)
39 if reason == "" {
40 return RepoName{s: s}, nil
41 }
42 e := &InvalidNameError{Name: s, Reason: reason}
43 if lower := strings.ToLower(s); lower != s && nameProblem(lower) == "" {
44 e.Suggestion = lower
45 }
46 return RepoName{}, e
47}
48
49// ParseRepoPath parses the repository segment of a URL path. It strips one
50// trailing ".git" before validating, so "tools.git" names "tools".
51func ParseRepoPath(segment string) (RepoName, error) {
52 return ParseRepoName(strings.TrimSuffix(segment, gitSuffix))
53}
54
55// InvalidNameError describes why a repository name is invalid. It matches
56// ErrInvalidName.
57type InvalidNameError struct {
58 Name string
59 Reason string
60 Suggestion string // a valid alternative, empty when there is none
61}
62
63// Error implements error.
64func (e *InvalidNameError) Error() string {
65 msg := fmt.Sprintf("invalid repository name %q: %s", e.Name, e.Reason)
66 if e.Suggestion != "" {
67 msg += fmt.Sprintf(" (try %q)", e.Suggestion)
68 }
69 return msg
70}
71
72// Unwrap makes errors.Is(err, ErrInvalidName) true.
73func (e *InvalidNameError) Unwrap() error { return ErrInvalidName }
74
75// nameProblem returns why s is not a valid name, or "" when it is valid.
76func nameProblem(s string) string {
77 if s == "" {
78 return "name is empty"
79 }
80 if len(s) > MaxRepoNameLen {
81 return fmt.Sprintf("name is longer than %d characters", MaxRepoNameLen)
82 }
83 for _, r := range s {
84 switch {
85 case r >= 'A' && r <= 'Z':
86 return "uppercase letters are not allowed"
87 case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
88 default:
89 return fmt.Sprintf("character %q is not allowed (use a-z, 0-9, '.', '_', '-')", r)
90 }
91 }
92 switch {
93 case strings.HasPrefix(s, ".") || strings.HasSuffix(s, "."):
94 return "name must not start or end with '.'"
95 case strings.Contains(s, ".."):
96 return "name must not contain '..'"
97 case strings.HasSuffix(s, gitSuffix):
98 return "name must not end in '.git'"
99 case reservedNames[s]:
100 return "name is reserved"
101 }
102 return ""
103}