Commit 936ce1198d

936ce1198d634a620526e3c460f36e8b4661cefa

parent: b924e88c83

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 00:07 UTC

policy: a repository name may start with a dot

Owner names still may not: an owner is a top-level route.

Ref #236
internal/policy/names.go +14 −4
@@ -34,15 +34,23 @@ var reservedNames = map[string]bool{
3434}
3535
3636// namePat matches valid user, org, and repo names: lowercase alphanumerics,
37// dot, dash, underscore; must start with an alphanumeric. Dots are further
38// restricted by ValidateName to avoid "." / ".." and ".git" suffixes.
39var namePat = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
37// dot, dash, underscore; must start with an alphanumeric, or with a single
38// dot before one. A leading dot marks a repository as infrastructure rather
39// than a project — .gitbay holds an owner's profile content — and is refused
40// for owners by ValidateOwnerName. Dots are further restricted by
41// ValidateName to avoid "." / ".." and ".git" suffixes.
42var namePat = regexp.MustCompile(`^\.?[a-z0-9][a-z0-9._-]{0,61}$`)
4043
4144// ValidateOwnerName checks a username or org name.
4245func ValidateOwnerName(name string) error {
4346 if err := ValidateName(name); err != nil {
4447 return err
4548 }
49 // The leading dot is a repository affordance. An owner is a top-level
50 // route, and /.gitbay is not one.
51 if strings.HasPrefix(name, ".") {
52 return fmt.Errorf("invalid name %q: must start with a letter or digit", name)
53 }
4654 if reservedNames[name] {
4755 return fmt.Errorf("name %q is reserved", name)
4856 }
@@ -58,7 +66,9 @@ func ValidateName(name string) error {
5866 if name == "." || name == ".." {
5967 return fmt.Errorf("invalid name %q", name)
6068 }
61 if len(name) > 4 && name[len(name)-4:] == ".git" {
69 // HasSuffix covers "repo.git" and the bare ".git" the leading-dot rule
70 // would otherwise let through.
71 if strings.HasSuffix(name, ".git") {
6272 return fmt.Errorf("invalid name %q: must not end in .git", name)
6373 }
6474 // /{owner}/activity.atom is the owner's feed; a repository by that
internal/policy/names_test.go +35 −1
@@ -1,6 +1,9 @@
11package policy
22
3import "testing"
3import (
4 "strings"
5 "testing"
6)
47
58func TestValidateOwnerName(t *testing.T) {
69 valid := []string{"alice", "krz", "a", "user-1", "a.b_c", "0day"}
@@ -43,3 +46,34 @@ func TestRepoNameAllowsReservedWords(t *testing.T) {
4346 t.Error("ValidateName(\"activity.atom\") = nil, want error")
4447 }
4548}
49
50func TestRepoNameAllowsLeadingDot(t *testing.T) {
51 // .gitbay holds an owner's profile content; a leading dot marks a
52 // repository as infrastructure rather than a project.
53 for _, n := range []string{".gitbay", ".dotfiles", ".a"} {
54 if err := ValidateName(n); err != nil {
55 t.Errorf("ValidateName(%q) = %v, want nil", n, err)
56 }
57 }
58 for _, n := range []string{".", "..", ".git", "repo.git", "..a", ".-a"} {
59 if err := ValidateName(n); err == nil {
60 t.Errorf("ValidateName(%q) = nil, want error", n)
61 }
62 }
63 // The ceiling is 63 characters, the dot included.
64 if err := ValidateName("." + strings.Repeat("a", 62)); err != nil {
65 t.Errorf("63-character dotted name rejected: %v", err)
66 }
67 if err := ValidateName("." + strings.Repeat("a", 63)); err == nil {
68 t.Error("64-character dotted name accepted")
69 }
70}
71
72func TestOwnerNameRefusesLeadingDot(t *testing.T) {
73 // The dot is a repository affordance. An owner is a top-level route.
74 for _, n := range []string{".gitbay", ".hidden", ".a"} {
75 if err := ValidateOwnerName(n); err == nil {
76 t.Errorf("ValidateOwnerName(%q) = nil, want error", n)
77 }
78 }
79}