| 1 |
|
package core |
| 2 |
|
|
| 3 |
|
import "strings" |
| 4 |
|
|
| 5 |
|
// maxRepoNameLen bounds repository names. Owners are bounded by meta.sr.ht at |
| 6 |
|
// registration time, so ValidOwner enforces only structure, not length. |
| 7 |
|
const maxRepoNameLen = 100 |
| 8 |
|
|
| 9 |
|
// isNameByte reports whether c is allowed in a sourcehut owner or repo name: |
| 10 |
|
// lowercase alphanumerics plus '_', '-', and '.'. Note '/' is deliberately |
| 11 |
|
// excluded, so a name can never span path components. |
| 12 |
182 |
func isNameByte(c byte) bool { |
| 13 |
182 |
switch { |
| 14 |
165 |
case c >= 'a' && c <= 'z': |
| 15 |
165 |
return true |
| 16 |
4 |
case c >= '0' && c <= '9': |
| 17 |
4 |
return true |
| 18 |
6 |
case c == '_' || c == '-' || c == '.': |
| 19 |
6 |
return true |
| 20 |
7 |
default: |
| 21 |
7 |
return false |
| 22 |
|
} |
| 23 |
|
} |
| 24 |
|
|
| 25 |
|
// validName holds the rules shared by owners and repos: non-empty, drawn from |
| 26 |
|
// the allowed byte set, not starting with '-' (which would look like a git or |
| 27 |
|
// shell option), and containing no ".." (path traversal). '/' is rejected |
| 28 |
|
// implicitly because it is not an allowed byte. |
| 29 |
23 |
func validName(s string) bool { |
| 30 |
23 |
if s == "" { |
| 31 |
2 |
return false |
| 32 |
2 |
} |
| 33 |
21 |
if s[0] == '-' { |
| 34 |
2 |
return false |
| 35 |
2 |
} |
| 36 |
19 |
if strings.Contains(s, "..") { |
| 37 |
2 |
return false |
| 38 |
2 |
} |
| 39 |
182 |
for i := 0; i < len(s); i++ { |
| 40 |
182 |
if !isNameByte(s[i]) { |
| 41 |
7 |
return false |
| 42 |
7 |
} |
| 43 |
|
} |
| 44 |
10 |
return true |
| 45 |
|
} |
| 46 |
|
|
| 47 |
|
// ValidOwner reports whether s is a well-formed sourcehut owner name (the part |
| 48 |
|
// after '~' in a URL). Callers must strip the leading '~' first. |
| 49 |
14 |
func ValidOwner(s string) bool { |
| 50 |
14 |
return validName(s) |
| 51 |
14 |
} |
| 52 |
|
|
| 53 |
|
// ValidRepoName reports whether s is a well-formed repository name (same |
| 54 |
|
// character family as an owner, capped at maxRepoNameLen). |
| 55 |
10 |
func ValidRepoName(s string) bool { |
| 56 |
10 |
return len(s) <= maxRepoNameLen && validName(s) |
| 57 |
10 |
} |
| 58 |
|
|
| 59 |
|
// refForbiddenByte reports whether c is a byte git-check-ref-format forbids |
| 60 |
|
// anywhere in a ref: ASCII control characters, space, DEL, and the special |
| 61 |
|
// set ~ ^ : ? * [ \. High bytes (>= 0x80) are allowed so UTF-8 refs pass. |
| 62 |
372 |
func refForbiddenByte(c byte) bool { |
| 63 |
372 |
if c <= 0x20 || c == 0x7f { |
| 64 |
5 |
return true |
| 65 |
5 |
} |
| 66 |
367 |
switch c { |
| 67 |
10 |
case '~', '^', ':', '?', '*', '[', '\\': |
| 68 |
10 |
return true |
| 69 |
357 |
default: |
| 70 |
357 |
return false |
| 71 |
|
} |
| 72 |
|
} |
| 73 |
|
|
| 74 |
|
// isHexSHA reports whether s is a bare 40-char (SHA-1) or 64-char (SHA-256) |
| 75 |
|
// hexadecimal object id. These are accepted as refs directly. |
| 76 |
81 |
func isHexSHA(s string) bool { |
| 77 |
81 |
if len(s) != 40 && len(s) != 64 { |
| 78 |
76 |
return false |
| 79 |
76 |
} |
| 80 |
248 |
for i := 0; i < len(s); i++ { |
| 81 |
248 |
c := s[i] |
| 82 |
248 |
if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { |
| 83 |
0 |
return false |
| 84 |
0 |
} |
| 85 |
|
} |
| 86 |
5 |
return true |
| 87 |
|
} |
| 88 |
|
|
| 89 |
|
// ValidRef reports whether s is acceptable as a git ref or revision to hand to |
| 90 |
|
// git. The rules follow git-check-ref-format(1) closely enough to keep hostile |
| 91 |
|
// input (option injection, path traversal, revision-syntax tricks) away from |
| 92 |
|
// the git command line, while still accepting ordinary multi-level branch and |
| 93 |
|
// tag names such as "feature/foo" and bare object ids. |
| 94 |
|
// |
| 95 |
|
// git invocations additionally use "--end-of-options"/"--" as defense in |
| 96 |
|
// depth; this function is the first line. |
| 97 |
85 |
func ValidRef(s string) bool { |
| 98 |
85 |
if s == "" { |
| 99 |
4 |
return false |
| 100 |
4 |
} |
| 101 |
|
// Bare object ids are always fine and skip the component rules. |
| 102 |
81 |
if isHexSHA(s) { |
| 103 |
5 |
return true |
| 104 |
5 |
} |
| 105 |
|
// "@" alone is a git shorthand for HEAD and is not a valid ref name. |
| 106 |
76 |
if s == "@" { |
| 107 |
2 |
return false |
| 108 |
2 |
} |
| 109 |
74 |
if s[0] == '-' || s[0] == '.' || s[0] == '/' { |
| 110 |
9 |
return false |
| 111 |
9 |
} |
| 112 |
65 |
if strings.HasSuffix(s, "/") || strings.HasSuffix(s, ".") { |
| 113 |
2 |
return false |
| 114 |
2 |
} |
| 115 |
63 |
if strings.Contains(s, "..") || strings.Contains(s, "@{") || strings.Contains(s, "//") { |
| 116 |
5 |
return false |
| 117 |
5 |
} |
| 118 |
372 |
for i := 0; i < len(s); i++ { |
| 119 |
372 |
if refForbiddenByte(s[i]) { |
| 120 |
15 |
return false |
| 121 |
15 |
} |
| 122 |
|
} |
| 123 |
|
// Per-component rules: no component may start with '.' or end with |
| 124 |
|
// ".lock". Empty components are already excluded by the '//', leading |
| 125 |
|
// '/', and trailing '/' checks above. |
| 126 |
57 |
for _, comp := range strings.Split(s, "/") { |
| 127 |
57 |
if strings.HasPrefix(comp, ".") { |
| 128 |
1 |
return false |
| 129 |
1 |
} |
| 130 |
56 |
if strings.HasSuffix(comp, ".lock") { |
| 131 |
3 |
return false |
| 132 |
3 |
} |
| 133 |
|
} |
| 134 |
39 |
return true |
| 135 |
|
} |