vole
Clone
Read only, no credentials:
https://rodent.software/vole.git
Write over SSH with a registered key:
git clone ssh://git@rodent.software:2222/vole.git
A free and open-source forge that lives in your Git repo.
Refuse a protected-ref pattern that can never match
93c7cae8ebb7d6d853dca476851e2bd0cbe10079 · Keenan Nemetz · 1d ago · parent 2c988ecd
Refuse a protected-ref pattern that can never match acl protect stored any string: refs/head/main read like a protected branch in acl show while the hook compared it against refs/heads/main and matched nothing, so approvals and required checks were off on the branch everything lands on. Validate the pattern in forge/access, where the rule is stored, and call it from both acl protect add|set and the repository settings form. Only the two shapes IsProtected understands are accepted, exact ref and one trailing wildcard, the ref part must be a name git could store, and a singular category is named as the typo it is.
forge/access/acl_test.go +74−1
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package access | |
| 2 | 2 | ||
| 3 | import "testing" | ||
| 3 | import ( | ||
| 4 | "strings" | ||
| 5 | "testing" | ||
| 6 | ) | ||
| 4 | 7 | ||
| 5 | 8 | func TestRoleOrdering(t *testing.T) { | |
| 6 | 9 | t.Parallel() | |
| @@ -247,6 +250,76 @@ func TestIsProtected(t *testing.T) { | |||
| 247 | 250 | } | |
| 248 | 251 | } | |
| 249 | 252 | } | |
| 253 | |||
| 254 | // A pattern IsProtected cannot match must be refused where it is written: today | ||
| 255 | // the repository reads as protected while the branch is open. | ||
| 256 | func TestValidateProtectedPattern(t *testing.T) { | ||
| 257 | t.Parallel() | ||
| 258 | valid := []string{ | ||
| 259 | "refs/heads/main", | ||
| 260 | "refs/heads/release/*", | ||
| 261 | "refs/heads/release", | ||
| 262 | "refs/tags/v1.*", | ||
| 263 | "refs/*", | ||
| 264 | "refs/heads/*", | ||
| 265 | } | ||
| 266 | for _, pattern := range valid { | ||
| 267 | if err := ValidateProtectedPattern(pattern); err != nil { | ||
| 268 | t.Errorf("ValidateProtectedPattern(%q) = %v, want it accepted", pattern, err) | ||
| 269 | } | ||
| 270 | } | ||
| 271 | invalid := map[string]string{ | ||
| 272 | "": "empty", | ||
| 273 | "refs/head/main": "refs/heads/main", | ||
| 274 | "refs/tag/v1.0": "refs/tags/v1.0", | ||
| 275 | "heads/main": "refs/heads/main", | ||
| 276 | "main": "refs/", | ||
| 277 | " refs/heads/main": "whitespace", | ||
| 278 | "refs/heads/ma in": "space", | ||
| 279 | "refs/heads/": "refs/heads/*", | ||
| 280 | "refs/heads": "refs/heads/*", | ||
| 281 | "refs": "refs/", | ||
| 282 | "refs/heads/ma*in": `"*"`, | ||
| 283 | "refs/heads/ma**": `"*"`, | ||
| 284 | "refs/heads/../main": `".."`, | ||
| 285 | "refs/heads/main.lock": ".lock", | ||
| 286 | "refs/heads/main.": "dot", | ||
| 287 | "refs/heads//main": "empty path component", | ||
| 288 | } | ||
| 289 | for pattern, want := range invalid { | ||
| 290 | err := ValidateProtectedPattern(pattern) | ||
| 291 | if err == nil { | ||
| 292 | t.Errorf("ValidateProtectedPattern(%q) was accepted", pattern) | ||
| 293 | continue | ||
| 294 | } | ||
| 295 | if !strings.Contains(err.Error(), want) { | ||
| 296 | t.Errorf("ValidateProtectedPattern(%q) = %q, want it to mention %q", pattern, err, want) | ||
| 297 | } | ||
| 298 | } | ||
| 299 | } | ||
| 300 | |||
| 301 | // Every accepted pattern must round-trip unchanged and still protect what the | ||
| 302 | // operator asked for, or validation has only moved the bug. | ||
| 303 | func TestAcceptedPatternsRoundTrip(t *testing.T) { | ||
| 304 | t.Parallel() | ||
| 305 | patterns := []string{"refs/heads/main", "refs/heads/release/*", "refs/tags/v1.*"} | ||
| 306 | if err := ValidateProtectedPatterns(patterns); err != nil { | ||
| 307 | t.Fatal(err) | ||
| 308 | } | ||
| 309 | a := &ACL{Protected: append([]string(nil), patterns...)} | ||
| 310 | if got := strings.Join(a.Protected, ","); got != strings.Join(patterns, ",") { | ||
| 311 | t.Fatalf("patterns = %q", got) | ||
| 312 | } | ||
| 313 | for _, ref := range []string{"refs/heads/main", "refs/heads/release/1.0", "refs/tags/v1.2"} { | ||
| 314 | if !a.IsProtected(ref) { | ||
| 315 | t.Errorf("%q is not protected", ref) | ||
| 316 | } | ||
| 317 | } | ||
| 318 | if a.IsProtected("refs/heads/mainline") { | ||
| 319 | t.Error("an accepted exact pattern protects a longer ref") | ||
| 320 | } | ||
| 321 | } | ||
| 322 | |||
| 250 | 323 | func TestJSONRoundTrip(t *testing.T) { | |
| 251 | 324 | t.Parallel() | |
| 252 | 325 | a := Empty("keenan") | |