Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

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")