~bigbes/sr-ht-spec

ref: 1f4a8edab72379e44a1e25c116e35a2c877d6045 sr-ht-spec/core/names.go -rw-r--r-- 8.0 KiB
1f4a8eda — bigbes docs: record the Phase 0 verdict and its Phase 4 requirement 27 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
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

	// 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
}

// 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
}