vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
|
c5902451
Keenan Nemetz
16d ago
|
1 | // The role model below is used by both |
|
653e0cfc
Keenan Nemetz
18d ago
|
2 | // the pre-receive hook and the web handlers so the two can never disagree. |
|
c5902451
Keenan Nemetz
16d ago
|
3 | package access |
|
653e0cfc
Keenan Nemetz
18d ago
|
4 | |
| 5 | import ( | |
| 6 | "encoding/json" | |
|
93c7cae8
Keenan Nemetz
19h ago
|
7 | "errors" |
|
653e0cfc
Keenan Nemetz
18d ago
|
8 | "fmt" |
| 9 | "sort" | |
| 10 | "strings" | |
|
93c7cae8
Keenan Nemetz
19h ago
|
11 | "unicode" |
|
727df386
Keenan Nemetz
8d ago
|
12 | |
|
d37d301f
Keenan Nemetz
17h ago
|
13 | "rodent.software/vole/core/role" |
|
653e0cfc
Keenan Nemetz
18d ago
|
14 | ) |
| 15 | ||
|
727df386
Keenan Nemetz
8d ago
|
16 | // Role is the ordered access role. The vocabulary lives in core/role so a |
| 17 | // workflow can validate an author role without importing the access store. | |
| 18 | type Role = role.Role | |
|
653e0cfc
Keenan Nemetz
18d ago
|
19 | |
| 20 | const ( | |
|
727df386
Keenan Nemetz
8d ago
|
21 | None = role.None |
| 22 | Read = role.Read | |
| 23 | Contribute = role.Contribute | |
| 24 | Maintain = role.Maintain | |
| 25 | Admin = role.Admin | |
|
653e0cfc
Keenan Nemetz
18d ago
|
26 | ) |
| 27 | ||
|
727df386
Keenan Nemetz
8d ago
|
28 | // ParseRole keeps the historical call shape. |
| 29 | func ParseRole(s string) (Role, error) { return role.Parse(s) } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
30 | |
|
6c84d387
Keenan Nemetz
15d ago
|
31 | // Write is the historical name for Maintain. It stays so external code using the |
| 32 | // access package, and ACL rows written before the rename, keep compiling and | |
| 33 | // parsing. New code should say Maintain. | |
| 34 | const Write = Maintain | |
| 35 | ||
|
5b814e99
Keenan Nemetz
14d ago
|
36 | // Contributor is the historical spelling of Contribute. It stays so external |
| 37 | // code and ACL rows written before the rename keep compiling and parsing. New | |
| 38 | // code should say Contribute. | |
| 39 | const Contributor = Contribute | |
| 40 | ||
|
ec5c519c
Keenan Nemetz
16d ago
|
41 | // ACL is one repository's rules. They live in the instance database beside the roster |
| 42 | // (see the access package), not inside the repository: they are configuration the | |
|
62576fa8
Keenan Nemetz
14d ago
|
43 | // instance owns, and they are keyed by account name, so a copy in a clone would mean |
|
ec5c519c
Keenan Nemetz
16d ago
|
44 | // nothing without the roster that gives those ids names. |
|
653e0cfc
Keenan Nemetz
18d ago
|
45 | type ACL struct { |
| 46 | Version int `json:"version"` | |
| 47 | Default string `json:"default"` | |
| 48 | Entries map[string]string `json:"entries"` | |
| 49 | Protected []string `json:"protected,omitempty"` | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
50 | // ApprovalsRequired is how many approvals a change request needs before it |
| 51 | // may be pushed to a protected branch. Nil means the default of 1; an | |
| 52 | // explicit 0 means only admins may push, which is the pre-reviews behaviour. | |
| 53 | ApprovalsRequired *int `json:"approvals_required,omitempty"` | |
|
35eaeccf
Keenan Nemetz
17d ago
|
54 | // ChecksRequired gates a protected branch on CI having passed for the commit |
| 55 | // being landed. Nil means true: a repository with tests is expected to run | |
| 56 | // them before merging. A repository with no workflows is unaffected, because | |
| 57 | // there is nothing to run. | |
| 58 | ChecksRequired *bool `json:"checks_required,omitempty"` | |
|
0789f22e
Keenan Nemetz
9d ago
|
59 | // RequireLinkedIssue makes opening a new change request require at least one |
| 60 | // linked issue in its closes list. It is about how a team files work, not who | |
| 61 | // may write: existing requests keep their history and can still be edited, | |
| 62 | // approved, and landed after the setting is turned on. | |
| 63 | RequireLinkedIssue bool `json:"require_linked_issue,omitempty"` | |
|
852c067d
Keenan Nemetz
17d ago
|
64 | // Runners are the accounts whose CI results count as checks here. It is an |
| 65 | // allowlist rather than a role because it answers a different question from | |
| 66 | // read/write: not "may this account touch the repository" but "may this | |
| 67 | // account's build be believed about it". Naming somebody a runner also lets | |
| 68 | // them lease jobs for this repository, which means executing its code. | |
| 69 | // | |
| 70 | // Admins are implicit: they can rewrite this list, and a protected branch | |
| 71 | // already lets them land anything with no checks at all, so restricting them | |
|
6c84d387
Keenan Nemetz
15d ago
|
72 | // here would be theatre. Everyone else — a maintainer who runs CI on their laptop |
|
852c067d
Keenan Nemetz
17d ago
|
73 | // included — gets their result recorded but not counted. |
| 74 | Runners []string `json:"runners,omitempty"` | |
|
5b814e99
Keenan Nemetz
14d ago
|
75 | |
| 76 | // instanceRole is not part of the repository document. The access store | |
| 77 | // attaches the roster's instance-wide role when it loads the ACL, so every | |
| 78 | // capability check below sees a global grant without every caller having to | |
| 79 | // pass it in. It is deliberately unexported: instance roles belong to the | |
| 80 | // account, and writing one into repo_acl would freeze it at that moment. | |
| 81 | instanceRole func(string) Role | |
|
62b59a76
Keenan Nemetz
12d ago
|
82 | // active is the roster's answer to "is this account still active", attached by |
| 83 | // the store beside instanceRole. An explicit entry or runners-list membership | |
| 84 | // outlives retirement otherwise: the row stays after the account is retired, | |
| 85 | // and a retired account that still counts as an approver or a runner is a | |
| 86 | // permission nobody revoked. Nil means the caller has no roster (a test). | |
| 87 | active func(string) bool | |
| 88 | // revision is the version of the row this rule set was read from, attached by | |
| 89 | // the store. It is what makes a write compare-and-swap: a rule set read before | |
| 90 | // somebody else's change carries the old revision and is refused. Zero means a | |
| 91 | // rule set built from scratch for a repository that has no row yet. | |
| 92 | revision int64 | |
| 93 | } | |
| 94 | ||
| 95 | // Active reports whether the account is still active. A rule set with no roster | |
| 96 | // attached treats every name as active, which is only true in a test: every | |
| 97 | // instance wires the lookup. | |
| 98 | func (a *ACL) Active(name string) bool { | |
| 99 | if a == nil || a.active == nil { | |
| 100 | return true | |
| 101 | } | |
| 102 | return a.active(name) | |
| 103 | } | |
| 104 | ||
| 105 | // EligibleApprover reports whether this account's approval still counts. An | |
| 106 | // approval says "I read this change and I am answerable for it"; that statement is | |
| 107 | // worth nothing if the account has been retired or no longer maintains the | |
| 108 | // repository. The gate and the page count approvals through this rule, so they | |
| 109 | // cannot disagree about how many there are. | |
| 110 | func (a *ACL) EligibleApprover(name string) bool { | |
| 111 | if a == nil || name == "" || !a.Active(name) { | |
| 112 | return false | |
| 113 | } | |
| 114 | return a.RoleFor(name).AtLeast(Maintain) | |
|
852c067d
Keenan Nemetz
17d ago
|
115 | } |
| 116 | ||
| 117 | // CanReport says whether a CI result reported by this account counts as a check | |
| 118 | // for this repository. An empty account never counts: an unattributed result is not | |
|
62b59a76
Keenan Nemetz
12d ago
|
119 | // a result anybody is answerable for. Neither does a retired account: the runners |
| 120 | // list is edited by hand and the row stays after retirement, so membership alone | |
| 121 | // would let a revoked account keep vouching for builds — and keep leasing jobs, | |
| 122 | // which means executing repository code. | |
| 123 | // | |
| 124 | // A repository role is deliberately not required of an explicit runner. The | |
| 125 | // runners list answers a different question from read/write ("may this account's | |
| 126 | // build be believed about it"), and a machine that only builds may hold no role. | |
|
852c067d
Keenan Nemetz
17d ago
|
127 | func (a *ACL) CanReport(accountID string) bool { |
|
62b59a76
Keenan Nemetz
12d ago
|
128 | if accountID == "" || !a.Active(accountID) { |
|
852c067d
Keenan Nemetz
17d ago
|
129 | return false |
| 130 | } | |
| 131 | if a.RoleFor(accountID).AtLeast(Admin) { | |
| 132 | return true | |
| 133 | } | |
| 134 | for _, r := range a.Runners { | |
| 135 | if r == accountID { | |
| 136 | return true | |
| 137 | } | |
| 138 | } | |
| 139 | return false | |
| 140 | } | |
| 141 | ||
| 142 | // SetRunners replaces the allowlist, dropping blanks and duplicates so the file | |
| 143 | // does not depend on how an operator typed it. | |
| 144 | func (a *ACL) SetRunners(ids []string) { | |
| 145 | seen := map[string]bool{} | |
| 146 | out := a.Runners[:0] | |
| 147 | for _, id := range ids { | |
| 148 | id = strings.TrimSpace(id) | |
| 149 | if id == "" || seen[id] { | |
| 150 | continue | |
| 151 | } | |
| 152 | seen[id] = true | |
| 153 | out = append(out, id) | |
| 154 | } | |
| 155 | sort.Strings(out) | |
| 156 | a.Runners = out | |
|
35eaeccf
Keenan Nemetz
17d ago
|
157 | } |
| 158 | ||
| 159 | // RequiresChecks resolves the nil-means-true rule. | |
| 160 | func (a *ACL) RequiresChecks() bool { | |
| 161 | if a.ChecksRequired == nil { | |
| 162 | return true | |
| 163 | } | |
| 164 | return *a.ChecksRequired | |
|
1eeca4e6
Keenan Nemetz
17d ago
|
165 | } |
| 166 | ||
|
0789f22e
Keenan Nemetz
9d ago
|
167 | // RequiresLinkedIssue reports whether new change requests must name at least |
| 168 | // one issue they close. | |
| 169 | func (a *ACL) RequiresLinkedIssue() bool { | |
| 170 | return a != nil && a.RequireLinkedIssue | |
| 171 | } | |
| 172 | ||
|
1eeca4e6
Keenan Nemetz
17d ago
|
173 | // RequiredApprovals resolves the nil-means-default rule. |
| 174 | func (a *ACL) RequiredApprovals() int { | |
| 175 | if a.ApprovalsRequired == nil { | |
| 176 | return 1 | |
| 177 | } | |
| 178 | return *a.ApprovalsRequired | |
|
653e0cfc
Keenan Nemetz
18d ago
|
179 | } |
| 180 | func Empty(owner string) *ACL { | |
| 181 | a := &ACL{Version: 1, Default: "none", Entries: map[string]string{}} | |
| 182 | if owner != "" { | |
| 183 | a.Entries[owner] = "admin" | |
| 184 | } | |
| 185 | return a | |
| 186 | } | |
| 187 | func Parse(data []byte) (*ACL, error) { | |
| 188 | a := &ACL{} | |
| 189 | if err := json.Unmarshal(data, a); err != nil { | |
| 190 | return nil, fmt.Errorf("acl: %w", err) | |
| 191 | } | |
| 192 | if a.Version == 0 { | |
| 193 | a.Version = 1 | |
| 194 | } | |
| 195 | if a.Entries == nil { | |
| 196 | a.Entries = map[string]string{} | |
| 197 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
198 | def, err := ParseRole(a.Default) |
| 199 | if err != nil { | |
|
653e0cfc
Keenan Nemetz
18d ago
|
200 | return nil, err |
| 201 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
202 | a.Default = def.String() |
|
1eeca4e6
Keenan Nemetz
17d ago
|
203 | if a.ApprovalsRequired != nil && *a.ApprovalsRequired < 0 { |
| 204 | return nil, fmt.Errorf("approvals_required cannot be negative") | |
| 205 | } | |
|
653e0cfc
Keenan Nemetz
18d ago
|
206 | for k, v := range a.Entries { |
|
6c84d387
Keenan Nemetz
15d ago
|
207 | role, err := ParseRole(v) |
| 208 | if err != nil { | |
|
653e0cfc
Keenan Nemetz
18d ago
|
209 | return nil, fmt.Errorf("acl entry %q: %w", k, err) |
| 210 | } | |
|
6c84d387
Keenan Nemetz
15d ago
|
211 | a.Entries[k] = role.String() |
|
653e0cfc
Keenan Nemetz
18d ago
|
212 | } |
|
852c067d
Keenan Nemetz
17d ago
|
213 | a.SetRunners(a.Runners) |
|
653e0cfc
Keenan Nemetz
18d ago
|
214 | return a, nil |
| 215 | } | |
| 216 | func (a *ACL) Bytes() ([]byte, error) { | |
| 217 | b, err := json.MarshalIndent(a, "", " ") | |
| 218 | if err != nil { | |
| 219 | return nil, err | |
| 220 | } | |
| 221 | return append(b, '\n'), nil | |
| 222 | } | |
| 223 | ||
|
5b814e99
Keenan Nemetz
14d ago
|
224 | // RoleFor resolves a principal's effective role. |
| 225 | // | |
| 226 | // A repository grant or the repository default is the base. An instance-wide | |
| 227 | // role raises that base, but only on a public repository: a private repository | |
| 228 | // is not made readable by an instance grant, because "contribute on everything" | |
| 229 | // should not mean "reader of every draft". The exception is the instance Admin | |
| 230 | // role: an instance administrator may govern every repository, private ones | |
| 231 | // included, so they are Admin everywhere. | |
|
653e0cfc
Keenan Nemetz
18d ago
|
232 | func (a *ACL) RoleFor(user string) Role { |
|
5b814e99
Keenan Nemetz
14d ago
|
233 | role := a.RepositoryRoleFor(user) |
| 234 | if user == "" || a.instanceRole == nil { | |
| 235 | return role | |
| 236 | } | |
| 237 | instance := a.instanceRole(user) | |
| 238 | if instance.AtLeast(Admin) { | |
| 239 | return Admin | |
| 240 | } | |
| 241 | if a.Public() && instance > role { | |
| 242 | return instance | |
| 243 | } | |
| 244 | return role | |
| 245 | } | |
| 246 | ||
| 247 | // RepositoryRoleFor is RoleFor without the instance-wide overlay. Repository | |
| 248 | // settings use it so an inherited instance role is never mistaken for a | |
| 249 | // repository-specific entry. | |
| 250 | func (a *ACL) RepositoryRoleFor(user string) Role { | |
|
653e0cfc
Keenan Nemetz
18d ago
|
251 | if user != "" { |
| 252 | if r, ok := a.Entries[user]; ok { | |
| 253 | if role, err := ParseRole(r); err == nil { | |
| 254 | return role | |
| 255 | } | |
| 256 | } | |
| 257 | } | |
| 258 | def, _ := ParseRole(a.Default) | |
| 259 | return def | |
| 260 | } | |
|
5b814e99
Keenan Nemetz
14d ago
|
261 | |
| 262 | // Public reports whether anonymous readers may read the repository. Instance | |
| 263 | // roles only apply where this is true. | |
| 264 | func (a *ACL) Public() bool { | |
| 265 | def, _ := ParseRole(a.Default) | |
| 266 | return def.AtLeast(Read) | |
| 267 | } | |
| 268 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
269 | func (a *ACL) Can(user string, need Role) bool { return a.RoleFor(user).AtLeast(need) } |
| 270 | ||
|
5b814e99
Keenan Nemetz
14d ago
|
271 | // EffectiveRole keeps the old call shape. The bool is retained for callers that |
| 272 | // already know the account is an instance administrator; the role itself is | |
| 273 | // already part of RoleFor when the access store is wired to the roster. The | |
| 274 | // explicit check makes the rule true even for a bare ACL in a test. | |
|
381d6aac
Keenan Nemetz
14d ago
|
275 | func (a *ACL) EffectiveRole(user string, instanceAdmin bool) Role { |
|
5b814e99
Keenan Nemetz
14d ago
|
276 | if user != "" && instanceAdmin { |
| 277 | return Admin | |
|
381d6aac
Keenan Nemetz
14d ago
|
278 | } |
|
5b814e99
Keenan Nemetz
14d ago
|
279 | return a.RoleFor(user) |
|
381d6aac
Keenan Nemetz
14d ago
|
280 | } |
| 281 | ||
|
6c84d387
Keenan Nemetz
15d ago
|
282 | // ContributorBranchPrefix is the only branch namespace a contributor may push. |
|
474a812d
Keenan Nemetz
15d ago
|
283 | // The account name is in the path: it is chosen by the person, fixed at creation |
| 284 | // and never reissued, so one contributor cannot move another's branch and a | |
| 285 | // maintainer can see whose work a ref belongs to without a join. No rename can | |
| 286 | // strand a branch, because there is nothing to rename. | |
| 287 | func ContributorBranchPrefix(name string) string { | |
| 288 | return "refs/heads/contrib/" + name + "/" | |
|
6c84d387
Keenan Nemetz
15d ago
|
289 | } |
| 290 | ||
|
474a812d
Keenan Nemetz
15d ago
|
291 | // IsContributorRef reports whether ref is a non-empty branch inside name's |
|
62576fa8
Keenan Nemetz
14d ago
|
292 | // namespace. name is the account name. |
|
474a812d
Keenan Nemetz
15d ago
|
293 | func IsContributorRef(name, ref string) bool { |
| 294 | if name == "" { | |
|
6c84d387
Keenan Nemetz
15d ago
|
295 | return false |
| 296 | } | |
|
474a812d
Keenan Nemetz
15d ago
|
297 | prefix := ContributorBranchPrefix(name) |
|
6c84d387
Keenan Nemetz
15d ago
|
298 | return strings.HasPrefix(ref, prefix) && len(ref) > len(prefix) |
| 299 | } | |
| 300 | ||
|
474a812d
Keenan Nemetz
15d ago
|
301 | // ContributorRefName returns the account name embedded in a contributor ref. |
|
6c84d387
Keenan Nemetz
15d ago
|
302 | // It is the reverse of ContributorBranchPrefix, used by the CI gate before it |
|
474a812d
Keenan Nemetz
15d ago
|
303 | // has a principal in hand. The name is a path component, so it is returned as |
| 304 | // written; resolving it to an account is the roster's job. | |
| 305 | func ContributorRefName(ref string) (name string, ok bool) { | |
|
6c84d387
Keenan Nemetz
15d ago
|
306 | const prefix = "refs/heads/contrib/" |
| 307 | if !strings.HasPrefix(ref, prefix) { | |
| 308 | return "", false | |
| 309 | } | |
| 310 | rest := strings.TrimPrefix(ref, prefix) | |
| 311 | i := strings.IndexByte(rest, '/') | |
| 312 | if i <= 0 || i == len(rest)-1 { | |
| 313 | return "", false | |
| 314 | } | |
| 315 | return rest[:i], true | |
| 316 | } | |
| 317 | ||
|
e5478085
Keenan Nemetz
11d ago
|
318 | // CandidateRoot is the server-only namespace a combined result is published under. |
| 319 | // It lives here rather than in forge/landing so the runner can read the trust of a | |
| 320 | // ref without importing the landing rules. | |
| 321 | const CandidateRoot = "refs/vole/candidates/" | |
| 322 | ||
| 323 | // TrustNamespace names the cache namespace a run's ref belongs to. A contributor | |
| 324 | // branch and a combined result derived from one are both untrusted code, and a | |
| 325 | // cache is shared mutable state: they may not seed the cache a protected branch | |
| 326 | // builds into. Everything else is direct. | |
| 327 | func TrustNamespace(ref string) string { | |
| 328 | if name, ok := ContributorRefName(ref); ok { | |
| 329 | return "contrib-" + name | |
| 330 | } | |
| 331 | if rest, ok := strings.CutPrefix(ref, CandidateRoot+"contrib/"); ok { | |
| 332 | if i := strings.IndexByte(rest, '/'); i > 0 { | |
| 333 | return "contrib-" + rest[:i] | |
| 334 | } | |
| 335 | } | |
| 336 | return "direct" | |
| 337 | } | |
| 338 | ||
|
5b814e99
Keenan Nemetz
14d ago
|
339 | // CanDiscuss covers filing issues and commenting. The Contribute role may do |
| 340 | // this without being able to move a shared branch. | |
| 341 | func (a *ACL) CanDiscuss(user string) bool { return a.RoleFor(user).AtLeast(Contribute) } | |
|
6c84d387
Keenan Nemetz
15d ago
|
342 | |
| 343 | // CanOpenRequest covers opening a change request. The head must still be a | |
| 344 | // branch the caller may push: the web checks the contributor namespace. | |
|
5b814e99
Keenan Nemetz
14d ago
|
345 | func (a *ACL) CanOpenRequest(user string) bool { return a.RoleFor(user).AtLeast(Contribute) } |
|
6c84d387
Keenan Nemetz
15d ago
|
346 | |
|
5b814e99
Keenan Nemetz
14d ago
|
347 | // CanPush says the account may push somewhere. Which refs the Contribute role |
| 348 | // may write is a namespace question and needs the account *name*, not the id the ACL | |
|
474a812d
Keenan Nemetz
15d ago
|
349 | // is keyed by, so that half of the rule lives where the push is gate-kept: the |
| 350 | // pre-receive hook, which has the name the SSH key resolved to. | |
|
5b814e99
Keenan Nemetz
14d ago
|
351 | func (a *ACL) CanPush(user string) bool { return a.RoleFor(user).AtLeast(Contribute) } |
|
6c84d387
Keenan Nemetz
15d ago
|
352 | |
| 353 | // CanTriage covers changing somebody else's issue: labels, assignees, state. | |
| 354 | func (a *ACL) CanTriage(user string) bool { return a.RoleFor(user).AtLeast(Maintain) } | |
| 355 | ||
| 356 | // CanApprove covers review approvals. Self-approval is a separate check against | |
| 357 | // the request's author id. | |
| 358 | func (a *ACL) CanApprove(user string) bool { return a.RoleFor(user).AtLeast(Maintain) } | |
| 359 | ||
|
93c7cae8
Keenan Nemetz
19h ago
|
360 | // ValidateProtectedPattern checks a pattern where an operator writes it, because |
| 361 | // a pattern IsProtected can never match is worse than none at all: the repository | |
| 362 | // reads as protected while the branch is open. Only the two shapes IsProtected | |
| 363 | // understands are accepted, an exact ref and a ref prefix ending in one "*", and | |
| 364 | // the ref part must be a name Git could actually store. A prefix for a namespace | |
| 365 | // with nothing in it yet is allowed: "refs/heads/release/*" is a legitimate rule | |
| 366 | // for the first release branch. | |
| 367 | // | |
| 368 | // The rule lives here, not in the CLI, so the browser form and any other writer | |
| 369 | // cannot store what the CLI refuses. | |
| 370 | func ValidateProtectedPattern(pattern string) error { | |
| 371 | if pattern == "" { | |
| 372 | return errors.New(`a protected pattern cannot be empty: name a ref, as in "refs/heads/main"`) | |
| 373 | } | |
| 374 | if pattern != strings.TrimSpace(pattern) || strings.ContainsFunc(pattern, unicode.IsControl) { | |
| 375 | return fmt.Errorf("protected pattern %q cannot match any ref: it carries whitespace or control characters", pattern) | |
| 376 | } | |
| 377 | if !strings.HasPrefix(pattern, "refs/") { | |
| 378 | if fixed, ok := likelyRef(pattern); ok { | |
| 379 | return fmt.Errorf("protected pattern %q cannot match any ref: did you mean %q?", pattern, fixed) | |
| 380 | } | |
| 381 | return fmt.Errorf(`protected pattern %q cannot match any ref: it must start with "refs/", like "refs/heads/main"`, pattern) | |
| 382 | } | |
| 383 | if fixed, ok := likelyRef(pattern); ok { | |
| 384 | return fmt.Errorf("protected pattern %q cannot match any ref: did you mean %q?", pattern, fixed) | |
| 385 | } | |
| 386 | prefix := strings.HasSuffix(pattern, "*") | |
| 387 | if n := strings.Count(pattern, "*"); n > 1 || (n == 1 && !prefix) { | |
| 388 | return fmt.Errorf(`protected pattern %q cannot match any ref: the only wildcard understood is one trailing "*", as in "refs/heads/release/*"`, pattern) | |
| 389 | } | |
| 390 | base := strings.TrimSuffix(pattern, "*") | |
| 391 | // "refs/*" is the one base with no name left after the wildcard; it matches | |
| 392 | // every ref by design and there is nothing further to check. | |
| 393 | if prefix && base == "refs/" { | |
| 394 | return nil | |
| 395 | } | |
| 396 | // A prefix names the namespace refs live in, so its trailing slash is the | |
| 397 | // boundary the wildcard stands in for, and the name before it may end in a | |
| 398 | // dot or ".lock" the way a real ref may not. | |
| 399 | name := base | |
| 400 | if prefix { | |
| 401 | name = strings.TrimSuffix(base, "/") | |
| 402 | } | |
| 403 | if reason := refNameProblem(name, !prefix); reason != "" { | |
| 404 | if !prefix && strings.HasSuffix(base, "/") { | |
| 405 | return fmt.Errorf("protected pattern %q cannot match any ref: %s; use %q to protect everything beneath it", pattern, reason, base+"*") | |
| 406 | } | |
| 407 | return fmt.Errorf("protected pattern %q cannot match any ref: %s", pattern, reason) | |
| 408 | } | |
| 409 | // A ref is at least a category and a name (refs/heads/main), so a base that | |
| 410 | // stops after one slash names a category the repository does not store. | |
| 411 | if !prefix && strings.Count(base, "/") < 2 { | |
| 412 | return fmt.Errorf("protected pattern %q names a ref category, not a ref: use %q to protect everything in it", pattern, base+"/*") | |
| 413 | } | |
| 414 | return nil | |
| 415 | } | |
| 416 | ||
| 417 | // ValidateProtectedPatterns is ValidateProtectedPattern for a whole list, so a | |
| 418 | // surface that accepts several patterns rejects the first bad one before it | |
| 419 | // writes any of them. | |
| 420 | func ValidateProtectedPatterns(patterns []string) error { | |
| 421 | for _, p := range patterns { | |
| 422 | if err := ValidateProtectedPattern(p); err != nil { | |
| 423 | return err | |
| 424 | } | |
| 425 | } | |
| 426 | return nil | |
| 427 | } | |
| 428 | ||
| 429 | // likelyRef turns the singular category names Git does not use into the ref the | |
| 430 | // operator probably meant. A one-letter slip is the whole difference between a | |
| 431 | // protected branch and a rule that never fires. | |
| 432 | func likelyRef(pattern string) (string, bool) { | |
| 433 | for _, fix := range []struct{ wrong, right string }{ | |
| 434 | {"refs/head/", "refs/heads/"}, | |
| 435 | {"refs/tag/", "refs/tags/"}, | |
| 436 | {"heads/", "refs/heads/"}, | |
| 437 | {"tags/", "refs/tags/"}, | |
| 438 | } { | |
| 439 | if rest, ok := strings.CutPrefix(pattern, fix.wrong); ok { | |
| 440 | return fix.right + rest, true | |
| 441 | } | |
| 442 | } | |
| 443 | return "", false | |
| 444 | } | |
| 445 | ||
| 446 | // refNameProblem reports why a string cannot be a ref name, using git's own | |
| 447 | // ref-format rules, or "" when it can. exact says the whole string is the ref; | |
| 448 | // without it the string is a prefix a wildcard stands after, so its last element | |
| 449 | // is incomplete and the rules about how a name ends do not apply. | |
| 450 | func refNameProblem(name string, exact bool) string { | |
| 451 | switch { | |
| 452 | case strings.Contains(name, "//"): | |
| 453 | return "a ref never contains an empty path component" | |
| 454 | case strings.Contains(name, ".."): | |
| 455 | return `a ref never contains ".."` | |
| 456 | case strings.Contains(name, "@{"): | |
| 457 | return `a ref never contains "@{"` | |
| 458 | case strings.ContainsAny(name, " ~^:?*[\\"): | |
| 459 | return `a ref never contains a space or one of ~ ^ : ? * [ \` | |
| 460 | case name == "refs": | |
| 461 | return `a ref has a category and a name, not "refs" alone` | |
| 462 | case strings.HasSuffix(name, "/"): | |
| 463 | return "a ref never ends in a slash" | |
| 464 | } | |
| 465 | if exact { | |
| 466 | switch { | |
| 467 | case strings.HasSuffix(name, "."): | |
| 468 | return "a ref never ends in a dot" | |
| 469 | case strings.HasSuffix(name, ".lock"): | |
| 470 | return `a ref never ends in ".lock"` | |
| 471 | } | |
| 472 | } | |
| 473 | return "" | |
| 474 | } | |
| 475 | ||
|
653e0cfc
Keenan Nemetz
18d ago
|
476 | // IsProtected reports whether ref matches a protected pattern. Patterns are an |
|
93c7cae8
Keenan Nemetz
19h ago
|
477 | // exact ref or a trailing "*" prefix such as "refs/heads/release/*". Every |
| 478 | // pattern stored through ValidateProtectedPattern is one of the two. | |
|
653e0cfc
Keenan Nemetz
18d ago
|
479 | func (a *ACL) IsProtected(ref string) bool { |
| 480 | for _, p := range a.Protected { | |
| 481 | if strings.HasSuffix(p, "*") { | |
| 482 | if strings.HasPrefix(ref, strings.TrimSuffix(p, "*")) { | |
| 483 | return true | |
| 484 | } | |
| 485 | continue | |
| 486 | } | |
| 487 | if p == ref { | |
| 488 | return true | |
| 489 | } | |
| 490 | } | |
| 491 | return false | |
| 492 | } | |
| 493 | ||
| 494 | // SetEntry updates a principal's role. An empty role deletes the entry. | |
| 495 | func (a *ACL) SetEntry(user, role string) error { | |
| 496 | r, err := ParseRole(role) | |
| 497 | if err != nil { | |
| 498 | return err | |
| 499 | } | |
| 500 | if r == None { | |
| 501 | delete(a.Entries, user) | |
| 502 | return nil | |
| 503 | } | |
| 504 | a.Entries[user] = r.String() | |
| 505 | return nil | |
| 506 | } | |
| 507 | func (a *ACL) Principals() []string { | |
| 508 | out := make([]string, 0, len(a.Entries)) | |
| 509 | for k := range a.Entries { | |
| 510 | out = append(out, k) | |
| 511 | } | |
| 512 | sort.Strings(out) | |
| 513 | return out | |
| 514 | } |