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