~bigbes/sr-ht-spec

ref: 3d811988f9960cf9057e09f30b59ceb71a7623c2 sr-ht-spec/core/names.go -rw-r--r-- 11.9 KiB
3d811988 — Eugene Blikh service: wrap the merge and reject failures with culpa 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
package core

import (
	"fmt"
	"strings"
	"unicode/utf8"
)

const (
	// MaxOwnerLen bounds owner names. meta.sr.ht already bounds them at
	// registration, so this is a sanity cap rather than the authority.
	MaxOwnerLen = 64

	// MaxSpaceNameLen bounds space names. A space name becomes a directory
	// under the repos root and a URL segment, so it stays short.
	MaxSpaceNameLen = 100

	// MaxProjectNameLen bounds project names. A project is a saved filter, not
	// a directory, so nothing on disk is named after it — but it is a URL
	// segment, so it stays as short as a space name.
	MaxProjectNameLen = 100

	// MaxPathLen bounds a document or attachment path within a space. Well
	// under any filesystem limit; the point is to keep a hostile path out of
	// the index and the render cache, not to be permissive.
	MaxPathLen = 512
)

const (
	// DocExt is the only extension a document may carry. Matched
	// case-sensitively: git trees are case-sensitive, so accepting ".MD" would
	// create documents that the renderer and indexer disagree about.
	DocExt = ".md"

	// PolicyFile is the space policy, versioned in the space itself so that
	// policy changes are reviewable like any other change.
	PolicyFile = ".spec.yml"
)

// SpaceRef identifies a space: one bare git repo, owned by one user. Both
// fields are stored without decoration — Owner never carries the leading '~'.
type SpaceRef struct {
	Owner string
	Name  string
}

// String renders the canonical URL and on-disk form, "~owner/name".
func (r SpaceRef) String() string { return "~" + r.Owner + "/" + r.Name }

// isNameByte reports whether c is allowed in an owner or space name: lowercase
// alphanumerics plus '_', '-' and '.'. '/' is deliberately excluded, so a name
// can never span path components.
func isNameByte(c byte) bool {
	switch {
	case c >= 'a' && c <= 'z':
		return true
	case c >= '0' && c <= '9':
		return true
	case c == '_' || c == '-' || c == '.':
		return true
	default:
		return false
	}
}

// validateName holds the rules shared by owners and spaces: non-empty, within
// the allowed byte set, not starting with '-' (which would read as an option to
// anything shelling out) and containing no ".." (path traversal, since both
// kinds of name become a path segment under the repos root).
func validateName(kind, s string, maxLen int) error {
	if s == "" {
		return fmt.Errorf("%w: empty %s", ErrInvalidName, kind)
	}
	if len(s) > maxLen {
		return fmt.Errorf("%w: %s %q is too long (%d > %d)", ErrInvalidName, kind, s, len(s), maxLen)
	}
	if s[0] == '-' {
		return fmt.Errorf("%w: %s %q must not start with '-'", ErrInvalidName, kind, s)
	}
	if strings.Contains(s, "..") {
		return fmt.Errorf("%w: %s %q must not contain '..'", ErrInvalidName, kind, s)
	}
	// '.' is in the allowed byte set, so the bare current-directory name has to
	// be excluded by hand: a space named "." would resolve to the repos root.
	if s == "." {
		return fmt.Errorf("%w: %s %q is not allowed", ErrInvalidName, kind, s)
	}
	for i := 0; i < len(s); i++ {
		if !isNameByte(s[i]) {
			return fmt.Errorf("%w: %s %q contains disallowed byte %q", ErrInvalidName, kind, s, s[i])
		}
	}
	return nil
}

// ValidateOwner reports whether s is a well-formed sourcehut owner name (the
// part after '~' in a URL). Callers must strip the leading '~' first.
func ValidateOwner(s string) error { return validateName("owner", s, MaxOwnerLen) }

// ValidateSpaceName reports whether s is a well-formed space name — the same
// character family as an owner, capped at MaxSpaceNameLen.
func ValidateSpaceName(s string) error { return validateName("space", s, MaxSpaceNameLen) }

// ParseSpaceRef parses "~owner/name" (or "owner/name") into a validated
// SpaceRef. Surrounding slashes are tolerated because the same string arrives
// both as a URL path and as a config value, but anything else that does not
// split into exactly two non-empty segments is rejected rather than repaired.
func ParseSpaceRef(s string) (SpaceRef, error) {
	trimmed := strings.Trim(s, "/")
	if trimmed == "" {
		return SpaceRef{}, fmt.Errorf("%w: empty space reference", ErrInvalidName)
	}
	segs := strings.Split(trimmed, "/")
	if len(segs) != 2 {
		return SpaceRef{}, fmt.Errorf("%w: space reference %q must have exactly 2 segments, got %d",
			ErrInvalidName, s, len(segs))
	}
	ref := SpaceRef{Owner: strings.TrimPrefix(segs[0], "~"), Name: segs[1]}
	if err := ValidateOwner(ref.Owner); err != nil {
		return SpaceRef{}, err
	}
	if err := ValidateSpaceName(ref.Name); err != nil {
		return SpaceRef{}, err
	}
	return ref, nil
}

const (
	// ProjectSigil distinguishes a project from a space in one character:
	// "~owner/space" is a repository, "~owner/+project" is a saved filter over
	// a set of them. Like the '~' on an owner, it is decoration on the address
	// and is never part of the stored name.
	ProjectSigil = "+"

	// MetaProjectName is the reserved name of the meta-project,
	// "~owner/+everything" — the project whose membership is everything.
	//
	// It is an address, not a row. The design's meta-project is "a filter that
	// excludes nothing", with "no separate aggregate entity, no copying, and no
	// sync job"; a stored row would need one, because every new space would
	// have to be added to it and forgetting once would silently make the
	// meta-project incomplete. So this name resolves without touching storage,
	// and ValidateProjectName refuses it as a stored name — a row claiming it
	// could only shadow an address that already resolves.
	MetaProjectName = "everything"
)

// ProjectRef identifies a project: a named set of spaces owned by one user.
// Like SpaceRef, both fields are stored undecorated — Owner carries no '~' and
// Name carries no '+'.
type ProjectRef struct {
	Owner string
	Name  string
}

// String renders the canonical URL form, "~owner/+name".
func (r ProjectRef) String() string { return "~" + r.Owner + "/" + ProjectSigil + r.Name }

// IsMeta reports whether r addresses the meta-project — the degenerate filter
// that excludes nothing. It is the one project reference that resolves without
// a stored row.
func (r ProjectRef) IsMeta() bool { return r.Name == MetaProjectName }

// ValidateProjectName reports whether s is a well-formed *storable* project
// name: the same character family as a space name, capped at
// MaxProjectNameLen, and not the reserved meta-project name.
//
// The reserved name fails with ErrReservedName rather than ErrInvalidName,
// because it is well-formed and the caller is being told "that one is not
// yours to create", not "that is not a name". Parsing a reference is a
// different question — ParseProjectRef accepts "~bigbes/+everything", since it
// is a perfectly good address; only storing it is refused.
func ValidateProjectName(s string) error {
	if err := validateName("project", s, MaxProjectNameLen); err != nil {
		return err
	}
	if s == MetaProjectName {
		return fmt.Errorf("%w: project %q is the meta-project, which is a filter rather than a row",
			ErrReservedName, s)
	}
	return nil
}

// ParseProjectRef parses "~owner/+name" (or "owner/+name") into a validated
// ProjectRef. The '+' is required: it is what keeps projects and spaces in one
// URL namespace without either being able to shadow the other.
//
// Unlike ValidateProjectName this accepts the meta-project, which is an address
// that resolves to a filter rather than to a row.
func ParseProjectRef(s string) (ProjectRef, error) {
	trimmed := strings.Trim(s, "/")
	if trimmed == "" {
		return ProjectRef{}, fmt.Errorf("%w: empty project reference", ErrInvalidName)
	}
	segs := strings.Split(trimmed, "/")
	if len(segs) != 2 {
		return ProjectRef{}, fmt.Errorf("%w: project reference %q must have exactly 2 segments, got %d",
			ErrInvalidName, s, len(segs))
	}
	if !strings.HasPrefix(segs[1], ProjectSigil) {
		return ProjectRef{}, fmt.Errorf("%w: project reference %q must name the project as %q",
			ErrInvalidName, s, ProjectSigil+"name")
	}
	ref := ProjectRef{
		Owner: strings.TrimPrefix(segs[0], "~"),
		Name:  strings.TrimPrefix(segs[1], ProjectSigil),
	}
	if err := ValidateOwner(ref.Owner); err != nil {
		return ProjectRef{}, err
	}
	if err := validateName("project", ref.Name, MaxProjectNameLen); err != nil {
		return ProjectRef{}, err
	}
	return ref, nil
}

// badPathRune reports whether r must never appear in a path. Two families:
// control characters, which git tolerates in a tree entry but which corrupt
// logs, JSON and the index; and the Unicode bidirectional overrides, which can
// make a path render in the review UI as something other than what will be
// committed. Reviewing agent output is the product, so a path that lies about
// itself on screen is a correctness bug, not a nicety.
func badPathRune(r rune) bool {
	if r < 0x20 || r == 0x7f {
		return true
	}
	switch r {
	case 0x200e, 0x200f, // LRM, RLM
		0x202a, 0x202b, 0x202c, 0x202d, 0x202e, // LRE, RLE, PDF, LRO, RLO
		0x2066, 0x2067, 0x2068, 0x2069: // LRI, RLI, FSI, PDI
		return true
	}
	return false
}

// ValidatePath reports whether p is a safe relative path inside a space, usable
// as a git tree path for a document or an attachment. The rules:
//
//   - non-empty and no longer than MaxPathLen;
//   - valid UTF-8, no control characters, no bidi overrides (see badPathRune);
//   - relative: no leading '/', no trailing '/';
//   - no backslashes — on a git tree a '\' is an ordinary filename byte, so
//     accepting it produces paths that mean different things to different
//     clients;
//   - no empty, "." or ".." components: traversal, and the whole point of this
//     function;
//   - no ".git" component, which git refuses to track and which is the classic
//     checkout-escape vector;
//   - no component ending in '.' or ' ', which are invisible on screen and
//     therefore an easy way to shadow an existing document.
//
// ValidatePath deliberately allows dotfiles (".spec.yml" is one) and non-ASCII
// letters (specs here are written in Russian as well as English).
func ValidatePath(p string) error {
	if p == "" {
		return fmt.Errorf("%w: empty path", ErrInvalidPath)
	}
	if len(p) > MaxPathLen {
		return fmt.Errorf("%w: path is too long (%d > %d)", ErrInvalidPath, len(p), MaxPathLen)
	}
	if !utf8.ValidString(p) {
		return fmt.Errorf("%w: path is not valid UTF-8", ErrInvalidPath)
	}
	for _, r := range p {
		if badPathRune(r) {
			return fmt.Errorf("%w: path %q contains disallowed rune %U", ErrInvalidPath, p, r)
		}
	}
	if strings.HasPrefix(p, "/") {
		return fmt.Errorf("%w: path %q must be relative", ErrInvalidPath, p)
	}
	if strings.HasSuffix(p, "/") {
		return fmt.Errorf("%w: path %q must not end in '/'", ErrInvalidPath, p)
	}
	if strings.Contains(p, `\`) {
		return fmt.Errorf("%w: path %q must not contain a backslash", ErrInvalidPath, p)
	}
	for _, comp := range strings.Split(p, "/") {
		switch comp {
		case "":
			return fmt.Errorf("%w: path %q has an empty component", ErrInvalidPath, p)
		case ".", "..":
			return fmt.Errorf("%w: path %q has a traversal component %q", ErrInvalidPath, p, comp)
		case ".git":
			return fmt.Errorf("%w: path %q has a %q component", ErrInvalidPath, p, comp)
		}
		if strings.HasSuffix(comp, ".") || strings.HasSuffix(comp, " ") {
			return fmt.Errorf("%w: path %q component %q ends in '.' or a space", ErrInvalidPath, p, comp)
		}
	}
	return nil
}

// ValidateDocPath reports whether p is a valid path for a markdown document:
// everything ValidatePath requires, plus a ".md" extension on a non-empty base
// name. The extension carries meaning here — it is what tells the indexer and
// the renderer that a blob is a document rather than an attachment — so a
// document named exactly ".md" is rejected as having no name at all.
func ValidateDocPath(p string) error {
	if err := ValidatePath(p); err != nil {
		return err
	}
	if !strings.HasSuffix(p, DocExt) {
		return fmt.Errorf("%w: document path %q must end in %q", ErrInvalidPath, p, DocExt)
	}
	base := p
	if i := strings.LastIndex(p, "/"); i >= 0 {
		base = p[i+1:]
	}
	if base == DocExt {
		return fmt.Errorf("%w: document path %q has an empty base name", ErrInvalidPath, p)
	}
	return nil
}