coverage~bigbes/sr-ht-spec3cb1c03dcore/names.go

Coverage
100.0% 93/93 statements
Δ
Blob
4bc45f0
Uncovered nothing — every instrumented line ran
1 package core
2
3 import (
4 "fmt"
5 "strings"
6 "unicode/utf8"
7 )
8
9 const (
10 // MaxOwnerLen bounds owner names. meta.sr.ht already bounds them at
11 // registration, so this is a sanity cap rather than the authority.
12 MaxOwnerLen = 64
13
14 // MaxSpaceNameLen bounds space names. A space name becomes a directory
15 // under the repos root and a URL segment, so it stays short.
16 MaxSpaceNameLen = 100
17
18 // MaxProjectNameLen bounds project names. A project is a saved filter, not
19 // a directory, so nothing on disk is named after it — but it is a URL
20 // segment, so it stays as short as a space name.
21 MaxProjectNameLen = 100
22
23 // MaxPathLen bounds a document or attachment path within a space. Well
24 // under any filesystem limit; the point is to keep a hostile path out of
25 // the index and the render cache, not to be permissive.
26 MaxPathLen = 512
27 )
28
29 const (
30 // DocExt is the only extension a document may carry. Matched
31 // case-sensitively: git trees are case-sensitive, so accepting ".MD" would
32 // create documents that the renderer and indexer disagree about.
33 DocExt = ".md"
34
35 // PolicyFile is the space policy, versioned in the space itself so that
36 // policy changes are reviewable like any other change.
37 PolicyFile = ".spec.yml"
38 )
39
40 // SpaceRef identifies a space: one bare git repo, owned by one user. Both
41 // fields are stored without decoration — Owner never carries the leading '~'.
42 type SpaceRef struct {
43 Owner string
44 Name string
45 }
46
47 // String renders the canonical URL and on-disk form, "~owner/name".
48 8 func (r SpaceRef) String() string { return "~" + r.Owner + "/" + r.Name }
49
50 // isNameByte reports whether c is allowed in an owner or space name: lowercase
51 // alphanumerics plus '_', '-' and '.'. '/' is deliberately excluded, so a name
52 // can never span path components.
53 629 func isNameByte(c byte) bool {
54 629 switch {
55 593 case c >= 'a' && c <= 'z':
56 593 return true
57 7 case c >= '0' && c <= '9':
58 7 return true
59 9 case c == '_' || c == '-' || c == '.':
60 9 return true
61 20 default:
62 20 return false
63 }
64 }
65
66 // validateName holds the rules shared by owners and spaces: non-empty, within
67 // the allowed byte set, not starting with '-' (which would read as an option to
68 // anything shelling out) and containing no ".." (path traversal, since both
69 // kinds of name become a path segment under the repos root).
70 94 func validateName(kind, s string, maxLen int) error {
71 94 if s == "" {
72 7 return fmt.Errorf("%w: empty %s", ErrInvalidName, kind)
73 7 }
74 87 if len(s) > maxLen {
75 3 return fmt.Errorf("%w: %s %q is too long (%d > %d)", ErrInvalidName, kind, s, len(s), maxLen)
76 3 }
77 84 if s[0] == '-' {
78 3 return fmt.Errorf("%w: %s %q must not start with '-'", ErrInvalidName, kind, s)
79 3 }
80 81 if strings.Contains(s, "..") {
81 8 return fmt.Errorf("%w: %s %q must not contain '..'", ErrInvalidName, kind, s)
82 8 }
83 // '.' is in the allowed byte set, so the bare current-directory name has to
84 // be excluded by hand: a space named "." would resolve to the repos root.
85 73 if s == "." {
86 3 return fmt.Errorf("%w: %s %q is not allowed", ErrInvalidName, kind, s)
87 3 }
88 629 for i := 0; i < len(s); i++ {
89 629 if !isNameByte(s[i]) {
90 20 return fmt.Errorf("%w: %s %q contains disallowed byte %q", ErrInvalidName, kind, s, s[i])
91 20 }
92 }
93 50 return nil
94 }
95
96 // ValidateOwner reports whether s is a well-formed sourcehut owner name (the
97 // part after '~' in a URL). Callers must strip the leading '~' first.
98 46 func ValidateOwner(s string) error { return validateName("owner", s, MaxOwnerLen) }
99
100 // ValidateSpaceName reports whether s is a well-formed space name — the same
101 // character family as an owner, capped at MaxSpaceNameLen.
102 22 func ValidateSpaceName(s string) error { return validateName("space", s, MaxSpaceNameLen) }
103
104 // ParseSpaceRef parses "~owner/name" (or "owner/name") into a validated
105 // SpaceRef. Surrounding slashes are tolerated because the same string arrives
106 // both as a URL path and as a config value, but anything else that does not
107 // split into exactly two non-empty segments is rejected rather than repaired.
108 15 func ParseSpaceRef(s string) (SpaceRef, error) {
109 15 trimmed := strings.Trim(s, "/")
110 15 if trimmed == "" {
111 2 return SpaceRef{}, fmt.Errorf("%w: empty space reference", ErrInvalidName)
112 2 }
113 13 segs := strings.Split(trimmed, "/")
114 13 if len(segs) != 2 {
115 3 return SpaceRef{}, fmt.Errorf("%w: space reference %q must have exactly 2 segments, got %d",
116 3 ErrInvalidName, s, len(segs))
117 3 }
118 10 ref := SpaceRef{Owner: strings.TrimPrefix(segs[0], "~"), Name: segs[1]}
119 10 if err := ValidateOwner(ref.Owner); err != nil {
120 3 return SpaceRef{}, err
121 3 }
122 7 if err := ValidateSpaceName(ref.Name); err != nil {
123 3 return SpaceRef{}, err
124 3 }
125 4 return ref, nil
126 }
127
128 const (
129 // ProjectSigil distinguishes a project from a space in one character:
130 // "~owner/space" is a repository, "~owner/+project" is a saved filter over
131 // a set of them. Like the '~' on an owner, it is decoration on the address
132 // and is never part of the stored name.
133 ProjectSigil = "+"
134
135 // MetaProjectName is the reserved name of the meta-project,
136 // "~owner/+everything" — the project whose membership is everything.
137 //
138 // It is an address, not a row. The design's meta-project is "a filter that
139 // excludes nothing", with "no separate aggregate entity, no copying, and no
140 // sync job"; a stored row would need one, because every new space would
141 // have to be added to it and forgetting once would silently make the
142 // meta-project incomplete. So this name resolves without touching storage,
143 // and ValidateProjectName refuses it as a stored name — a row claiming it
144 // could only shadow an address that already resolves.
145 MetaProjectName = "everything"
146 )
147
148 // ProjectRef identifies a project: a named set of spaces owned by one user.
149 // Like SpaceRef, both fields are stored undecorated — Owner carries no '~' and
150 // Name carries no '+'.
151 type ProjectRef struct {
152 Owner string
153 Name string
154 }
155
156 // String renders the canonical URL form, "~owner/+name".
157 10 func (r ProjectRef) String() string { return "~" + r.Owner + "/" + ProjectSigil + r.Name }
158
159 // IsMeta reports whether r addresses the meta-project — the degenerate filter
160 // that excludes nothing. It is the one project reference that resolves without
161 // a stored row.
162 2 func (r ProjectRef) IsMeta() bool { return r.Name == MetaProjectName }
163
164 // ValidateProjectName reports whether s is a well-formed *storable* project
165 // name: the same character family as a space name, capped at
166 // MaxProjectNameLen, and not the reserved meta-project name.
167 //
168 // The reserved name fails with ErrReservedName rather than ErrInvalidName,
169 // because it is well-formed and the caller is being told "that one is not
170 // yours to create", not "that is not a name". Parsing a reference is a
171 // different question — ParseProjectRef accepts "~bigbes/+everything", since it
172 // is a perfectly good address; only storing it is refused.
173 15 func ValidateProjectName(s string) error {
174 15 if err := validateName("project", s, MaxProjectNameLen); err != nil {
175 9 return err
176 9 }
177 6 if s == MetaProjectName {
178 1 return fmt.Errorf("%w: project %q is the meta-project, which is a filter rather than a row",
179 1 ErrReservedName, s)
180 1 }
181 5 return nil
182 }
183
184 // ParseProjectRef parses "~owner/+name" (or "owner/+name") into a validated
185 // ProjectRef. The '+' is required: it is what keeps projects and spaces in one
186 // URL namespace without either being able to shadow the other.
187 //
188 // Unlike ValidateProjectName this accepts the meta-project, which is an address
189 // that resolves to a filter rather than to a row.
190 17 func ParseProjectRef(s string) (ProjectRef, error) {
191 17 trimmed := strings.Trim(s, "/")
192 17 if trimmed == "" {
193 1 return ProjectRef{}, fmt.Errorf("%w: empty project reference", ErrInvalidName)
194 1 }
195 16 segs := strings.Split(trimmed, "/")
196 16 if len(segs) != 2 {
197 2 return ProjectRef{}, fmt.Errorf("%w: project reference %q must have exactly 2 segments, got %d",
198 2 ErrInvalidName, s, len(segs))
199 2 }
200 14 if !strings.HasPrefix(segs[1], ProjectSigil) {
201 2 return ProjectRef{}, fmt.Errorf("%w: project reference %q must name the project as %q",
202 2 ErrInvalidName, s, ProjectSigil+"name")
203 2 }
204 12 ref := ProjectRef{
205 12 Owner: strings.TrimPrefix(segs[0], "~"),
206 12 Name: strings.TrimPrefix(segs[1], ProjectSigil),
207 12 }
208 12 if err := ValidateOwner(ref.Owner); err != nil {
209 1 return ProjectRef{}, err
210 1 }
211 11 if err := validateName("project", ref.Name, MaxProjectNameLen); err != nil {
212 3 return ProjectRef{}, err
213 3 }
214 8 return ref, nil
215 }
216
217 // badPathRune reports whether r must never appear in a path. Two families:
218 // control characters, which git tolerates in a tree entry but which corrupt
219 // logs, JSON and the index; and the Unicode bidirectional overrides, which can
220 // make a path render in the review UI as something other than what will be
221 // committed. Reviewing agent output is the product, so a path that lies about
222 // itself on screen is a correctness bug, not a nicety.
223 2121 func badPathRune(r rune) bool {
224 2121 if r < 0x20 || r == 0x7f {
225 7 return true
226 7 }
227 2114 switch r {
228 case 0x200e, 0x200f, // LRM, RLM
229 0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // LRE, RLE, PDF, LRO, RLO
230 4 0x2066, 0x2067, 0x2068, 0x2069: // LRI, RLI, FSI, PDI
231 4 return true
232 }
233 2110 return false
234 }
235
236 // ValidatePath reports whether p is a safe relative path inside a space, usable
237 // as a git tree path for a document or an attachment. The rules:
238 //
239 // - non-empty and no longer than MaxPathLen;
240 // - valid UTF-8, no control characters, no bidi overrides (see badPathRune);
241 // - relative: no leading '/', no trailing '/';
242 // - no backslashes — on a git tree a '\' is an ordinary filename byte, so
243 // accepting it produces paths that mean different things to different
244 // clients;
245 // - no empty, "." or ".." components: traversal, and the whole point of this
246 // function;
247 // - no ".git" component, which git refuses to track and which is the classic
248 // checkout-escape vector;
249 // - no component ending in '.' or ' ', which are invisible on screen and
250 // therefore an easy way to shadow an existing document.
251 //
252 // ValidatePath deliberately allows dotfiles (".spec.yml" is one) and non-ASCII
253 // letters (specs here are written in Russian as well as English).
254 63 func ValidatePath(p string) error {
255 63 if p == "" {
256 3 return fmt.Errorf("%w: empty path", ErrInvalidPath)
257 3 }
258 60 if len(p) > MaxPathLen {
259 1 return fmt.Errorf("%w: path is too long (%d > %d)", ErrInvalidPath, len(p), MaxPathLen)
260 1 }
261 59 if !utf8.ValidString(p) {
262 1 return fmt.Errorf("%w: path is not valid UTF-8", ErrInvalidPath)
263 1 }
264 1233 for _, r := range p {
265 1233 if badPathRune(r) {
266 8 return fmt.Errorf("%w: path %q contains disallowed rune %U", ErrInvalidPath, p, r)
267 8 }
268 }
269 50 if strings.HasPrefix(p, "/") {
270 2 return fmt.Errorf("%w: path %q must be relative", ErrInvalidPath, p)
271 2 }
272 48 if strings.HasSuffix(p, "/") {
273 1 return fmt.Errorf("%w: path %q must not end in '/'", ErrInvalidPath, p)
274 1 }
275 47 if strings.Contains(p, `\`) {
276 2 return fmt.Errorf("%w: path %q must not contain a backslash", ErrInvalidPath, p)
277 2 }
278 82 for _, comp := range strings.Split(p, "/") {
279 82 switch comp {
280 1 case "":
281 1 return fmt.Errorf("%w: path %q has an empty component", ErrInvalidPath, p)
282 8 case ".", "..":
283 8 return fmt.Errorf("%w: path %q has a traversal component %q", ErrInvalidPath, p, comp)
284 2 case ".git":
285 2 return fmt.Errorf("%w: path %q has a %q component", ErrInvalidPath, p, comp)
286 }
287 71 if strings.HasSuffix(comp, ".") || strings.HasSuffix(comp, " ") {
288 3 return fmt.Errorf("%w: path %q component %q ends in '.' or a space", ErrInvalidPath, p, comp)
289 3 }
290 }
291 31 return nil
292 }
293
294 // ValidateDocPath reports whether p is a valid path for a markdown document:
295 // everything ValidatePath requires, plus a ".md" extension on a non-empty base
296 // name. The extension carries meaning here — it is what tells the indexer and
297 // the renderer that a blob is a document rather than an attachment — so a
298 // document named exactly ".md" is rejected as having no name at all.
299 12 func ValidateDocPath(p string) error {
300 12 if err := ValidatePath(p); err != nil {
301 2 return err
302 2 }
303 10 if !strings.HasSuffix(p, DocExt) {
304 4 return fmt.Errorf("%w: document path %q must end in %q", ErrInvalidPath, p, DocExt)
305 4 }
306 6 base := p
307 6 if i := strings.LastIndex(p, "/"); i >= 0 {
308 4 base = p[i+1:]
309 4 }
310 6 if base == DocExt {
311 2 return fmt.Errorf("%w: document path %q has an empty base name", ErrInvalidPath, p)
312 2 }
313 4 return nil
314 }