feat(domain): add branch prefixes to tokens
- commit
- e0bea3d9ab59466be48fffde9c145e02d5631f4c
- author
- brice · · 2026-10-12 14:48 UTC
- parent
- fc4d564
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +193 -15
M internal/domain/principal.go +46 -15
| @@ -4,6 +4,7 @@ package domain | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "slices" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| @@ -22,12 +23,14 @@ const ( | |||
| 22 | 23 | // token, the owner signed in on the web, or a stored token with its scopes | |
| 23 | 24 | // and repositories. | |
| 24 | 25 | type Principal struct { | |
| 25 | - | kind principalKind | |
| 26 | - | tokenID string | |
| 27 | - | scopes []Scope | |
| 28 | - | repos []RepoName | |
| 29 | - | expiresAt time.Time | |
| 30 | - | label string | |
| 26 | + | kind principalKind | |
| 27 | + | tokenID string | |
| 28 | + | scopes []Scope | |
| 29 | + | repos []RepoName | |
| 30 | + | // branchPrefixes limits the branches a token may write; empty = all. | |
| 31 | + | branchPrefixes []string | |
| 32 | + | expiresAt time.Time | |
| 33 | + | label string | |
| 31 | 34 | } | |
| 32 | 35 | ||
| 33 | 36 | // Anonymous returns the principal of a caller without credentials. | |
| @@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} } | |||
| 44 | 47 | // TokenPrincipal returns the principal of the holder of a stored token. | |
| 45 | 48 | func TokenPrincipal(t Token) Principal { | |
| 46 | 49 | return Principal{ | |
| 47 | - | kind: kindToken, | |
| 48 | - | tokenID: t.ID, | |
| 49 | - | scopes: slices.Clone(t.Scopes), | |
| 50 | - | repos: slices.Clone(t.Repos), | |
| 51 | - | expiresAt: t.ExpiresAt, | |
| 52 | - | label: t.Label, | |
| 50 | + | kind: kindToken, | |
| 51 | + | tokenID: t.ID, | |
| 52 | + | scopes: slices.Clone(t.Scopes), | |
| 53 | + | repos: slices.Clone(t.Repos), | |
| 54 | + | branchPrefixes: slices.Clone(t.BranchPrefixes), | |
| 55 | + | expiresAt: t.ExpiresAt, | |
| 56 | + | label: t.Label, | |
| 53 | 57 | } | |
| 54 | 58 | } | |
| 55 | 59 | ||
| @@ -72,13 +76,13 @@ func (p Principal) Label() string { | |||
| 72 | 76 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | |
| 73 | 77 | ||
| 74 | 78 | // HasOwnerRights reports whether p is the owner: root, the owner's web | |
| 75 | - | // session, or a stored token that holds every scope and is not limited to a | |
| 76 | - | // list of repositories. It decides who may manage browser sessions. | |
| 79 | + | // session, or a stored token that holds every scope and is limited neither to | |
| 80 | + | // a list of repositories nor to branch prefixes. It decides who may manage browser sessions. | |
| 77 | 81 | func (p Principal) HasOwnerRights() bool { | |
| 78 | 82 | if p.IsRoot() { | |
| 79 | 83 | return true | |
| 80 | 84 | } | |
| 81 | - | if p.kind != kindToken || len(p.repos) > 0 { | |
| 85 | + | if p.kind != kindToken || len(p.repos) > 0 || len(p.branchPrefixes) > 0 { | |
| 82 | 86 | return false | |
| 83 | 87 | } | |
| 84 | 88 | for _, s := range AllScopes() { | |
| @@ -105,6 +109,33 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) } | |||
| 105 | 109 | // repository. | |
| 106 | 110 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | |
| 107 | 111 | ||
| 112 | + | // BranchPrefixes returns the branch prefixes of a stored token; empty means | |
| 113 | + | // every branch. | |
| 114 | + | func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) } | |
| 115 | + | ||
| 116 | + | // AllowsRef reports whether p may write the ref (create, update or delete | |
| 117 | + | // it). Without branch prefixes every ref is allowed. With them, only a branch | |
| 118 | + | // "refs/heads/<name>" under one of the prefixes is: tags, every other ref and | |
| 119 | + | // a branch outside the prefixes are not. | |
| 120 | + | func (p Principal) AllowsRef(ref string) bool { | |
| 121 | + | if len(p.branchPrefixes) == 0 { | |
| 122 | + | return true | |
| 123 | + | } | |
| 124 | + | name, ok := strings.CutPrefix(ref, "refs/heads/") | |
| 125 | + | if !ok { | |
| 126 | + | return false | |
| 127 | + | } | |
| 128 | + | if _, err := ParseBranchName(name); err != nil { | |
| 129 | + | return false | |
| 130 | + | } | |
| 131 | + | for _, prefix := range p.branchPrefixes { | |
| 132 | + | if strings.HasPrefix(name, prefix) { | |
| 133 | + | return true | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | return false | |
| 137 | + | } | |
| 138 | + | ||
| 108 | 139 | // ExpiresAt returns the expiry of a stored token; zero means none. | |
| 109 | 140 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | |
| 110 | 141 | ||
| @@ -4,6 +4,7 @@ package domain | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "slices" | 6 | "slices" | ||
| 7 | + | "strings" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| @@ -22,12 +23,14 @@ const ( | |||||
| 22 | // token, the owner signed in on the web, or a stored token with its scopes | 23 | // token, the owner signed in on the web, or a stored token with its scopes | ||
| 23 | // and repositories. | 24 | // and repositories. | ||
| 24 | type Principal struct { | 25 | type Principal struct { | ||
| 25 | - | kind principalKind | 26 | + | kind principalKind |
| 26 | - | tokenID string | 27 | + | tokenID string |
| 27 | - | scopes []Scope | 28 | + | scopes []Scope |
| 28 | - | repos []RepoName | 29 | + | repos []RepoName |
| 29 | - | expiresAt time.Time | 30 | + | // branchPrefixes limits the branches a token may write; empty = all. |
| 30 | - | label string | 31 | + | branchPrefixes []string |
| 32 | + | expiresAt time.Time | |||
| 33 | + | label string | |||
| 31 | } | 34 | } | ||
| 32 | 35 | ||||
| 33 | // Anonymous returns the principal of a caller without credentials. | 36 | // Anonymous returns the principal of a caller without credentials. | ||
| @@ -44,12 +47,13 @@ func Owner() Principal { return Principal{kind: kindOwner} } | |||||
| 44 | // TokenPrincipal returns the principal of the holder of a stored token. | 47 | // TokenPrincipal returns the principal of the holder of a stored token. | ||
| 45 | func TokenPrincipal(t Token) Principal { | 48 | func TokenPrincipal(t Token) Principal { | ||
| 46 | return Principal{ | 49 | return Principal{ | ||
| 47 | - | kind: kindToken, | 50 | + | kind: kindToken, |
| 48 | - | tokenID: t.ID, | 51 | + | tokenID: t.ID, |
| 49 | - | scopes: slices.Clone(t.Scopes), | 52 | + | scopes: slices.Clone(t.Scopes), |
| 50 | - | repos: slices.Clone(t.Repos), | 53 | + | repos: slices.Clone(t.Repos), |
| 51 | - | expiresAt: t.ExpiresAt, | 54 | + | branchPrefixes: slices.Clone(t.BranchPrefixes), |
| 52 | - | label: t.Label, | 55 | + | expiresAt: t.ExpiresAt, |
| 56 | + | label: t.Label, | |||
| 53 | } | 57 | } | ||
| 54 | } | 58 | } | ||
| 55 | 59 | ||||
| @@ -72,13 +76,13 @@ func (p Principal) Label() string { | |||||
| 72 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | 76 | func (p Principal) IsRoot() bool { return p.kind == kindRoot || p.kind == kindOwner } | ||
| 73 | 77 | ||||
| 74 | // HasOwnerRights reports whether p is the owner: root, the owner's web | 78 | // HasOwnerRights reports whether p is the owner: root, the owner's web | ||
| 75 | - | // session, or a stored token that holds every scope and is not limited to a | 79 | + | // session, or a stored token that holds every scope and is limited neither to |
| 76 | - | // list of repositories. It decides who may manage browser sessions. | 80 | + | // a list of repositories nor to branch prefixes. It decides who may manage browser sessions. |
| 77 | func (p Principal) HasOwnerRights() bool { | 81 | func (p Principal) HasOwnerRights() bool { | ||
| 78 | if p.IsRoot() { | 82 | if p.IsRoot() { | ||
| 79 | return true | 83 | return true | ||
| 80 | } | 84 | } | ||
| 81 | - | if p.kind != kindToken || len(p.repos) > 0 { | 85 | + | if p.kind != kindToken || len(p.repos) > 0 || len(p.branchPrefixes) > 0 { |
| 82 | return false | 86 | return false | ||
| 83 | } | 87 | } | ||
| 84 | for _, s := range AllScopes() { | 88 | for _, s := range AllScopes() { | ||
| @@ -105,6 +109,33 @@ func (p Principal) Scopes() []Scope { return slices.Clone(p.scopes) } | |||||
| 105 | // repository. | 109 | // repository. | ||
| 106 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | 110 | func (p Principal) Repos() []RepoName { return slices.Clone(p.repos) } | ||
| 107 | 111 | ||||
| 112 | + | // BranchPrefixes returns the branch prefixes of a stored token; empty means | |||
| 113 | + | // every branch. | |||
| 114 | + | func (p Principal) BranchPrefixes() []string { return slices.Clone(p.branchPrefixes) } | |||
| 115 | + | ||||
| 116 | + | // AllowsRef reports whether p may write the ref (create, update or delete | |||
| 117 | + | // it). Without branch prefixes every ref is allowed. With them, only a branch | |||
| 118 | + | // "refs/heads/<name>" under one of the prefixes is: tags, every other ref and | |||
| 119 | + | // a branch outside the prefixes are not. | |||
| 120 | + | func (p Principal) AllowsRef(ref string) bool { | |||
| 121 | + | if len(p.branchPrefixes) == 0 { | |||
| 122 | + | return true | |||
| 123 | + | } | |||
| 124 | + | name, ok := strings.CutPrefix(ref, "refs/heads/") | |||
| 125 | + | if !ok { | |||
| 126 | + | return false | |||
| 127 | + | } | |||
| 128 | + | if _, err := ParseBranchName(name); err != nil { | |||
| 129 | + | return false | |||
| 130 | + | } | |||
| 131 | + | for _, prefix := range p.branchPrefixes { | |||
| 132 | + | if strings.HasPrefix(name, prefix) { | |||
| 133 | + | return true | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | return false | |||
| 137 | + | } | |||
| 138 | + | ||||
| 108 | // ExpiresAt returns the expiry of a stored token; zero means none. | 139 | // ExpiresAt returns the expiry of a stored token; zero means none. | ||
| 109 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | 140 | func (p Principal) ExpiresAt() time.Time { return p.expiresAt } | ||
| 110 | 141 | ||||
M internal/domain/token.go +49 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "encoding/hex" | |
| 8 | 8 | "fmt" | |
| 9 | 9 | "slices" | |
| 10 | + | "strings" | |
| 10 | 11 | "time" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -80,6 +81,10 @@ type Token struct { | |||
| 80 | 81 | Scopes []Scope | |
| 81 | 82 | // Repos limits the token to these repositories; empty means every one. | |
| 82 | 83 | Repos []RepoName | |
| 84 | + | // BranchPrefixes limits the branches the token may write to those under | |
| 85 | + | // one of these prefixes (each ends with "/"); empty means every branch. | |
| 86 | + | // A limited token cannot write tags or other refs, nor create repositories. | |
| 87 | + | BranchPrefixes []string | |
| 83 | 88 | // ExpiresAt is the zero time for a token that never expires. | |
| 84 | 89 | ExpiresAt time.Time | |
| 85 | 90 | CreatedAt time.Time | |
| @@ -90,3 +95,47 @@ type Token struct { | |||
| 90 | 95 | func (t Token) Expired(now time.Time) bool { | |
| 91 | 96 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | |
| 92 | 97 | } | |
| 98 | + | ||
| 99 | + | const ( | |
| 100 | + | // MaxBranchPrefixLen is the longest branch prefix, in bytes. | |
| 101 | + | MaxBranchPrefixLen = 100 | |
| 102 | + | // MaxBranchPrefixes is the most branch prefixes of a token. | |
| 103 | + | MaxBranchPrefixes = 8 | |
| 104 | + | ) | |
| 105 | + | ||
| 106 | + | // ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends | |
| 107 | + | // with "/", the rest is a valid branch name, and it has at most | |
| 108 | + | // MaxBranchPrefixLen bytes. Errors match ErrInvalidToken. | |
| 109 | + | func ParseBranchPrefix(s string) (string, error) { | |
| 110 | + | if len(s) > MaxBranchPrefixLen { | |
| 111 | + | return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen) | |
| 112 | + | } | |
| 113 | + | name, ok := strings.CutSuffix(s, "/") | |
| 114 | + | if !ok { | |
| 115 | + | return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s) | |
| 116 | + | } | |
| 117 | + | if _, err := ParseBranchName(name); err != nil { | |
| 118 | + | return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err) | |
| 119 | + | } | |
| 120 | + | return s, nil | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // ParseBranchPrefixes validates a list of branch prefixes: at most | |
| 124 | + | // MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match | |
| 125 | + | // ErrInvalidToken. The result is nil for an empty list. | |
| 126 | + | func ParseBranchPrefixes(in []string) ([]string, error) { | |
| 127 | + | var out []string | |
| 128 | + | for _, s := range in { | |
| 129 | + | p, err := ParseBranchPrefix(s) | |
| 130 | + | if err != nil { | |
| 131 | + | return nil, err | |
| 132 | + | } | |
| 133 | + | if !slices.Contains(out, p) { | |
| 134 | + | out = append(out, p) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if len(out) > MaxBranchPrefixes { | |
| 138 | + | return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes) | |
| 139 | + | } | |
| 140 | + | return out, nil | |
| 141 | + | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "encoding/hex" | 7 | "encoding/hex" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | "slices" | 9 | "slices" | ||
| 10 | + | "strings" | |||
| 10 | "time" | 11 | "time" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -80,6 +81,10 @@ type Token struct { | |||||
| 80 | Scopes []Scope | 81 | Scopes []Scope | ||
| 81 | // Repos limits the token to these repositories; empty means every one. | 82 | // Repos limits the token to these repositories; empty means every one. | ||
| 82 | Repos []RepoName | 83 | Repos []RepoName | ||
| 84 | + | // BranchPrefixes limits the branches the token may write to those under | |||
| 85 | + | // one of these prefixes (each ends with "/"); empty means every branch. | |||
| 86 | + | // A limited token cannot write tags or other refs, nor create repositories. | |||
| 87 | + | BranchPrefixes []string | |||
| 83 | // ExpiresAt is the zero time for a token that never expires. | 88 | // ExpiresAt is the zero time for a token that never expires. | ||
| 84 | ExpiresAt time.Time | 89 | ExpiresAt time.Time | ||
| 85 | CreatedAt time.Time | 90 | CreatedAt time.Time | ||
| @@ -90,3 +95,47 @@ type Token struct { | |||||
| 90 | func (t Token) Expired(now time.Time) bool { | 95 | func (t Token) Expired(now time.Time) bool { | ||
| 91 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | 96 | return !t.ExpiresAt.IsZero() && !now.Before(t.ExpiresAt) | ||
| 92 | } | 97 | } | ||
| 98 | + | ||||
| 99 | + | const ( | |||
| 100 | + | // MaxBranchPrefixLen is the longest branch prefix, in bytes. | |||
| 101 | + | MaxBranchPrefixLen = 100 | |||
| 102 | + | // MaxBranchPrefixes is the most branch prefixes of a token. | |||
| 103 | + | MaxBranchPrefixes = 8 | |||
| 104 | + | ) | |||
| 105 | + | ||||
| 106 | + | // ParseBranchPrefix validates a branch prefix such as "agent/claude/": it ends | |||
| 107 | + | // with "/", the rest is a valid branch name, and it has at most | |||
| 108 | + | // MaxBranchPrefixLen bytes. Errors match ErrInvalidToken. | |||
| 109 | + | func ParseBranchPrefix(s string) (string, error) { | |||
| 110 | + | if len(s) > MaxBranchPrefixLen { | |||
| 111 | + | return "", fmt.Errorf("%w: branch prefix is longer than %d bytes", ErrInvalidToken, MaxBranchPrefixLen) | |||
| 112 | + | } | |||
| 113 | + | name, ok := strings.CutSuffix(s, "/") | |||
| 114 | + | if !ok { | |||
| 115 | + | return "", fmt.Errorf("%w: branch prefix %q must end with /", ErrInvalidToken, s) | |||
| 116 | + | } | |||
| 117 | + | if _, err := ParseBranchName(name); err != nil { | |||
| 118 | + | return "", fmt.Errorf("%w: branch prefix %q: %w", ErrInvalidToken, s, err) | |||
| 119 | + | } | |||
| 120 | + | return s, nil | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // ParseBranchPrefixes validates a list of branch prefixes: at most | |||
| 124 | + | // MaxBranchPrefixes, each valid, duplicates removed, order kept. Errors match | |||
| 125 | + | // ErrInvalidToken. The result is nil for an empty list. | |||
| 126 | + | func ParseBranchPrefixes(in []string) ([]string, error) { | |||
| 127 | + | var out []string | |||
| 128 | + | for _, s := range in { | |||
| 129 | + | p, err := ParseBranchPrefix(s) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return nil, err | |||
| 132 | + | } | |||
| 133 | + | if !slices.Contains(out, p) { | |||
| 134 | + | out = append(out, p) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if len(out) > MaxBranchPrefixes { | |||
| 138 | + | return nil, fmt.Errorf("%w: at most %d branch prefixes", ErrInvalidToken, MaxBranchPrefixes) | |||
| 139 | + | } | |||
| 140 | + | return out, nil | |||
| 141 | + | } | |||
M internal/domain/token_test.go +98 -0
| @@ -3,6 +3,7 @@ | |||
| 3 | 3 | package domain_test | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "strings" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -111,3 +112,100 @@ func TestHasOwnerRights(t *testing.T) { | |||
| 111 | 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | |
| 112 | 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | |
| 113 | 114 | } | |
| 115 | + | ||
| 116 | + | func TestParseBranchPrefix(t *testing.T) { | |
| 117 | + | t.Parallel() | |
| 118 | + | for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} { | |
| 119 | + | got, err := domain.ParseBranchPrefix(ok) | |
| 120 | + | require.NoError(t, err, ok) | |
| 121 | + | assert.Equal(t, ok, got) | |
| 122 | + | } | |
| 123 | + | for _, bad := range []string{ | |
| 124 | + | "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/", | |
| 125 | + | "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/", | |
| 126 | + | "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/", | |
| 127 | + | } { | |
| 128 | + | _, err := domain.ParseBranchPrefix(bad) | |
| 129 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestParseBranchPrefixes(t *testing.T) { | |
| 134 | + | t.Parallel() | |
| 135 | + | got, err := domain.ParseBranchPrefixes(nil) | |
| 136 | + | require.NoError(t, err) | |
| 137 | + | assert.Empty(t, got) | |
| 138 | + | ||
| 139 | + | got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"}) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept") | |
| 142 | + | ||
| 143 | + | eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"} | |
| 144 | + | got, err = domain.ParseBranchPrefixes(eight) | |
| 145 | + | require.NoError(t, err) | |
| 146 | + | assert.Len(t, got, 8) | |
| 147 | + | _, err = domain.ParseBranchPrefixes(append(eight, "i/")) | |
| 148 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 149 | + | got, err = domain.ParseBranchPrefixes(append(eight, "a/")) | |
| 150 | + | require.NoError(t, err, "duplicates do not count") | |
| 151 | + | assert.Len(t, got, 8) | |
| 152 | + | ||
| 153 | + | _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"}) | |
| 154 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestAllowsRef(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | limited := domain.TokenPrincipal(domain.Token{ | |
| 160 | + | Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |
| 161 | + | }) | |
| 162 | + | for ref, want := range map[string]bool{ | |
| 163 | + | "refs/heads/agent/claude/x": true, | |
| 164 | + | "refs/heads/agent/claude/a/b": true, | |
| 165 | + | "refs/heads/bot/y": true, | |
| 166 | + | "refs/heads/agent/claude": false, | |
| 167 | + | "refs/heads/agent/claude/": false, | |
| 168 | + | "refs/heads/agent/claudex/y": false, | |
| 169 | + | "refs/heads/main": false, | |
| 170 | + | "refs/heads/agent/other/x": false, | |
| 171 | + | "refs/heads/other/agent/claude/x": false, | |
| 172 | + | "refs/tags/v1": false, | |
| 173 | + | "refs/tags/agent/claude/x": false, | |
| 174 | + | "refs/notes/commits": false, | |
| 175 | + | "refs/remotes/agent/claude/x": false, | |
| 176 | + | "refs/changes/agent/claude/x": false, | |
| 177 | + | "agent/claude/x": false, | |
| 178 | + | "heads/agent/claude/x": false, | |
| 179 | + | "HEAD": false, | |
| 180 | + | "": false, | |
| 181 | + | "refs/heads/agent/claude/../main": false, | |
| 182 | + | "refs/heads/agent/claude/x.lock": false, | |
| 183 | + | "refs/heads/agent/claude/a b": false, | |
| 184 | + | "refs/heads//agent/claude/x": false, | |
| 185 | + | } { | |
| 186 | + | assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref) | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | // Without prefixes every ref is allowed. | |
| 190 | + | unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |
| 191 | + | assert.True(t, unlimited.AllowsRef("refs/tags/v1")) | |
| 192 | + | assert.True(t, unlimited.AllowsRef("refs/heads/main")) | |
| 193 | + | assert.True(t, domain.Root().AllowsRef("refs/tags/v1")) | |
| 194 | + | assert.True(t, domain.Owner().AllowsRef("refs/heads/main")) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestBranchPrefixesOfPrincipal(t *testing.T) { | |
| 198 | + | t.Parallel() | |
| 199 | + | tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}} | |
| 200 | + | p := domain.TokenPrincipal(tok) | |
| 201 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 202 | + | assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner") | |
| 203 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights()) | |
| 204 | + | ||
| 205 | + | // The principal owns copies. | |
| 206 | + | tok.BranchPrefixes[0] = "b/" | |
| 207 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 208 | + | p.BranchPrefixes()[0] = "c/" | |
| 209 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |
| 210 | + | assert.Empty(t, domain.Root().BranchPrefixes()) | |
| 211 | + | } | |
| @@ -3,6 +3,7 @@ | |||||
| 3 | package domain_test | 3 | package domain_test | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "strings" | |||
| 6 | "testing" | 7 | "testing" | ||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| @@ -111,3 +112,100 @@ func TestHasOwnerRights(t *testing.T) { | |||||
| 111 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all[1:]}).HasOwnerRights(), "a scope short") | ||
| 112 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | 113 | assert.False(t, domain.TokenPrincipal(domain.Token{Scopes: all, Repos: []domain.RepoName{a}}).HasOwnerRights(), "limited to a repository") | ||
| 113 | } | 114 | } | ||
| 115 | + | ||||
| 116 | + | func TestParseBranchPrefix(t *testing.T) { | |||
| 117 | + | t.Parallel() | |||
| 118 | + | for _, ok := range []string{"agent/", "agent/claude/", "a/b/c/", "feat-1/", strings.Repeat("a", 99) + "/"} { | |||
| 119 | + | got, err := domain.ParseBranchPrefix(ok) | |||
| 120 | + | require.NoError(t, err, ok) | |||
| 121 | + | assert.Equal(t, ok, got) | |||
| 122 | + | } | |||
| 123 | + | for _, bad := range []string{ | |||
| 124 | + | "", "/", "agent", "agent/claude", "//", "agent//", "/agent/", "-x/", ".x/", "a/.b/", "a..b/", | |||
| 125 | + | "a b/", "a~/", "a:/", "a?/", "a*/", "a[/", "a\\/", "a@{/", "x.lock/", "a\x00/", "a\n/", "\xff/", | |||
| 126 | + | "refs/heads/agent/", "agent.//", strings.Repeat("a", 100) + "/", | |||
| 127 | + | } { | |||
| 128 | + | _, err := domain.ParseBranchPrefix(bad) | |||
| 129 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken, "%q", bad) | |||
| 130 | + | } | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestParseBranchPrefixes(t *testing.T) { | |||
| 134 | + | t.Parallel() | |||
| 135 | + | got, err := domain.ParseBranchPrefixes(nil) | |||
| 136 | + | require.NoError(t, err) | |||
| 137 | + | assert.Empty(t, got) | |||
| 138 | + | ||||
| 139 | + | got, err = domain.ParseBranchPrefixes([]string{"b/", "a/", "b/"}) | |||
| 140 | + | require.NoError(t, err) | |||
| 141 | + | assert.Equal(t, []string{"b/", "a/"}, got, "duplicates removed, order kept") | |||
| 142 | + | ||||
| 143 | + | eight := []string{"a/", "b/", "c/", "d/", "e/", "f/", "g/", "h/"} | |||
| 144 | + | got, err = domain.ParseBranchPrefixes(eight) | |||
| 145 | + | require.NoError(t, err) | |||
| 146 | + | assert.Len(t, got, 8) | |||
| 147 | + | _, err = domain.ParseBranchPrefixes(append(eight, "i/")) | |||
| 148 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 149 | + | got, err = domain.ParseBranchPrefixes(append(eight, "a/")) | |||
| 150 | + | require.NoError(t, err, "duplicates do not count") | |||
| 151 | + | assert.Len(t, got, 8) | |||
| 152 | + | ||||
| 153 | + | _, err = domain.ParseBranchPrefixes([]string{"a/", "bad"}) | |||
| 154 | + | assert.ErrorIs(t, err, domain.ErrInvalidToken) | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestAllowsRef(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | limited := domain.TokenPrincipal(domain.Token{ | |||
| 160 | + | Scopes: []domain.Scope{domain.ScopeRepoWrite}, BranchPrefixes: []string{"agent/claude/", "bot/"}, | |||
| 161 | + | }) | |||
| 162 | + | for ref, want := range map[string]bool{ | |||
| 163 | + | "refs/heads/agent/claude/x": true, | |||
| 164 | + | "refs/heads/agent/claude/a/b": true, | |||
| 165 | + | "refs/heads/bot/y": true, | |||
| 166 | + | "refs/heads/agent/claude": false, | |||
| 167 | + | "refs/heads/agent/claude/": false, | |||
| 168 | + | "refs/heads/agent/claudex/y": false, | |||
| 169 | + | "refs/heads/main": false, | |||
| 170 | + | "refs/heads/agent/other/x": false, | |||
| 171 | + | "refs/heads/other/agent/claude/x": false, | |||
| 172 | + | "refs/tags/v1": false, | |||
| 173 | + | "refs/tags/agent/claude/x": false, | |||
| 174 | + | "refs/notes/commits": false, | |||
| 175 | + | "refs/remotes/agent/claude/x": false, | |||
| 176 | + | "refs/changes/agent/claude/x": false, | |||
| 177 | + | "agent/claude/x": false, | |||
| 178 | + | "heads/agent/claude/x": false, | |||
| 179 | + | "HEAD": false, | |||
| 180 | + | "": false, | |||
| 181 | + | "refs/heads/agent/claude/../main": false, | |||
| 182 | + | "refs/heads/agent/claude/x.lock": false, | |||
| 183 | + | "refs/heads/agent/claude/a b": false, | |||
| 184 | + | "refs/heads//agent/claude/x": false, | |||
| 185 | + | } { | |||
| 186 | + | assert.Equal(t, want, limited.AllowsRef(ref), "%q", ref) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | // Without prefixes every ref is allowed. | |||
| 190 | + | unlimited := domain.TokenPrincipal(domain.Token{Scopes: []domain.Scope{domain.ScopeRepoWrite}}) | |||
| 191 | + | assert.True(t, unlimited.AllowsRef("refs/tags/v1")) | |||
| 192 | + | assert.True(t, unlimited.AllowsRef("refs/heads/main")) | |||
| 193 | + | assert.True(t, domain.Root().AllowsRef("refs/tags/v1")) | |||
| 194 | + | assert.True(t, domain.Owner().AllowsRef("refs/heads/main")) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestBranchPrefixesOfPrincipal(t *testing.T) { | |||
| 198 | + | t.Parallel() | |||
| 199 | + | tok := domain.Token{Scopes: domain.AllScopes(), BranchPrefixes: []string{"a/"}} | |||
| 200 | + | p := domain.TokenPrincipal(tok) | |||
| 201 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 202 | + | assert.False(t, p.HasOwnerRights(), "a token with every scope but limited branches is not the owner") | |||
| 203 | + | assert.True(t, domain.TokenPrincipal(domain.Token{Scopes: domain.AllScopes()}).HasOwnerRights()) | |||
| 204 | + | ||||
| 205 | + | // The principal owns copies. | |||
| 206 | + | tok.BranchPrefixes[0] = "b/" | |||
| 207 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 208 | + | p.BranchPrefixes()[0] = "c/" | |||
| 209 | + | assert.Equal(t, []string{"a/"}, p.BranchPrefixes()) | |||
| 210 | + | assert.Empty(t, domain.Root().BranchPrefixes()) | |||
| 211 | + | } | |||