Go · 3231 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package repos
4
5 import (
6 "fmt"
7 "regexp"
8 "strings"
9 "unicode/utf8"
10 )
11
12 // MaxNameLen / MaxDescriptionLen mirror the DB CHECK constraints in
13 // migration 0017. Validate before insert so we surface a friendly error
14 // rather than a 500 from the constraint.
15 const (
16 MaxNameLen = 100
17 MaxDescriptionLen = 350
18 )
19
20 // nameRE matches the name shape we accept: lowercase letters, digits,
21 // dots, hyphens, underscores. Edges can't be a separator. Length is
22 // bounded separately to give a more specific error message.
23 var nameRE = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9._-]{0,98}[a-z0-9_])?$`)
24
25 // reservedRepoNames is the set of names a repo may NOT take. Most are
26 // special filenames git itself uses (or that would break our routing).
27 // The list is kept short on purpose; it's all-lowercase because we
28 // always lowercase the name before comparing.
29 var reservedRepoNames = map[string]struct{}{
30 ".git": {},
31 ".gitignore": {},
32 ".gitmodules": {},
33 ".gitattributes": {},
34 ".well-known": {},
35 ".github": {},
36 "head": {},
37 "refs": {},
38 "objects": {},
39 "info": {},
40 "hooks": {},
41 "branches": {},
42 }
43
44 // IsReservedRepoName reports whether name (case-insensitive) is on the
45 // reserved list. Callers MUST also reject leading dots and the empty
46 // string via ValidateName.
47 func IsReservedRepoName(name string) bool {
48 _, ok := reservedRepoNames[strings.ToLower(name)]
49 return ok
50 }
51
52 // NormalizeName lowercases and trims. Repos are case-preserved in the
53 // DB (citext does case-insensitive uniqueness), but disk paths are
54 // lowercased. We lowercase up-front so the same string drives both.
55 func NormalizeName(name string) string {
56 return strings.ToLower(strings.TrimSpace(name))
57 }
58
59 // ValidateName enforces the shape whitelist. Returns ErrInvalidName /
60 // ErrReservedName wrapped with a precise reason on failure.
61 //
62 // Caller is expected to have lowercased the name first; uppercase input
63 // is rejected as a defensive check in case a future caller forgets.
64 func ValidateName(name string) error {
65 if name == "" {
66 return fmt.Errorf("%w: empty", ErrInvalidName)
67 }
68 if utf8.RuneCountInString(name) > MaxNameLen {
69 return fmt.Errorf("%w: too long (max %d)", ErrInvalidName, MaxNameLen)
70 }
71 if name != strings.ToLower(name) {
72 return fmt.Errorf("%w: must be lowercase", ErrInvalidName)
73 }
74 if strings.Contains(name, "..") {
75 return fmt.Errorf("%w: contains dot-dot", ErrInvalidName)
76 }
77 if strings.HasPrefix(name, ".") {
78 return fmt.Errorf("%w: starts with dot", ErrInvalidName)
79 }
80 if strings.HasSuffix(name, ".") || strings.HasSuffix(name, "-") {
81 return fmt.Errorf("%w: ends with separator", ErrInvalidName)
82 }
83 if !nameRE.MatchString(name) {
84 return fmt.Errorf("%w: must match [a-z0-9._-] with non-separator edges", ErrInvalidName)
85 }
86 if IsReservedRepoName(name) {
87 return fmt.Errorf("%w: %q", ErrReservedName, name)
88 }
89 return nil
90 }
91
92 // ValidateDescription enforces the DB CHECK length cap.
93 func ValidateDescription(desc string) error {
94 if utf8.RuneCountInString(desc) > MaxDescriptionLen {
95 return fmt.Errorf("%w: max %d characters", ErrDescriptionTooLong, MaxDescriptionLen)
96 }
97 return nil
98 }
99