Artifact 8b21027ef8fc2afa61bc596adf649b71ac3c3d424a2f85ffa3defdfc5234def0:
- File
internal/oltp/protect.go
— part of check-in
[70d043b2e7]
at
2026-08-26 22:54:53
on branch protect-1
— RFC 0007 phase 1: branch_protection and MayJoin
Forge policy beside repo_caps: a repository names protected branches (name or glob) and their owners. No trace in the repository; edited under 'a'; empty by default so an imported store behaves as it did.
MayJoin answers may-this-account-change-this-branch's-membership. Every matching rule applies, so a glob and a narrower name on one branch can only narrow. Refused lists the globs an account does not own — the per-request rule the sync gate will hand its child. A store that cannot answer says no: silence is not consent. (user: dmestas, size: 4942) [annotate] [blame] [check-ins using] [more...]
// protect.go — protected branches (RFC 0007). A repository may declare // that a branch is protected and name its owners; a check-in joins a // protected branch only by an owner's hand. // // Forge policy in forge clothes: the rows live here beside repo_caps, // are edited under `a`, and leave no trace in the repository. A clone // of a protected repository is byte-identical to a clone of the same // repository unprotected. This is not a capability letter and must // never be presented as one — the effect is `i` minus the protected // branches an account does not own, and the settings page says so. package oltp import ( "path" "sort" "strings" ) const protectSchema = ` CREATE TABLE IF NOT EXISTS branch_protection ( repo TEXT NOT NULL, branch TEXT NOT NULL, -- a name or a glob: trunk, release-* owners TEXT NOT NULL, -- account logins, space-separated PRIMARY KEY (repo, branch) );` // Protection is one rule: which branches, and whose hand. type Protection struct { Branch string `json:"branch"` Owners []string `json:"owners"` } // Protections lists a repository's rules, branch-ordered. Empty for an // unprotected repository, which is every repository until an // administrator adds a row. func (d *DB) Protections(repo string) ([]Protection, error) { rows, err := d.db.Query(`SELECT branch, owners FROM branch_protection WHERE repo = ? ORDER BY branch`, repo) if err != nil { return nil, err } defer rows.Close() var out []Protection for rows.Next() { var p Protection var owners string if err := rows.Scan(&p.Branch, &owners); err != nil { return nil, err } p.Owners = strings.Fields(owners) out = append(out, p) } return out, rows.Err() } // SetProtection writes one rule, or removes it when owners is empty. A // protected branch with no owners would be one nobody could land on — // a lock with no key — so an empty owner list means "not protected", // which is also how the settings form clears a row. func (d *DB) SetProtection(repo, branch string, owners []string) error { branch = strings.TrimSpace(branch) if branch == "" { return nil } names := normalizeOwners(owners) if len(names) == 0 { _, err := d.db.Exec(`DELETE FROM branch_protection WHERE repo = ? AND branch = ?`, repo, branch) return err } _, err := d.db.Exec(`INSERT INTO branch_protection (repo, branch, owners) VALUES (?, ?, ?) ON CONFLICT (repo, branch) DO UPDATE SET owners = excluded.owners`, repo, branch, strings.Join(names, " ")) return err } // MayJoin is the decision function: may this account change the // membership of this branch. True when no rule matches the branch, or // the account is among the owners of every rule that does. // // Every matching rule, not the first: `release-*` owned by one team // and `release-1.0` by another is two rules on one branch, and the // stricter reading — you need both — is the one that cannot be // widened by adding a row. // // An empty user is nobody, and nobody owns anything. func (d *DB) MayJoin(repo, user, branch string) bool { if branch == "" { return true } rules, err := d.Protections(repo) if err != nil { // A store that cannot answer must not answer "yes": the whole // point of the rule is that silence is not consent. return false } for _, p := range rules { if !branchMatches(p.Branch, branch) { continue } if user == "" || !contains(p.Owners, user) { return false } } return true } // Refused lists the protected-branch globs this account does NOT own — // the per-request rule the sync gate hands its child (RFC 0007 // §Enforcement). Empty when the account owns everything, including the // ordinary case of a repository with no rules at all. // // Globs, not resolved names: the gate does not know which branches // exist, and the child evaluates the glob against what a pushed // check-in resolves to, which only it can answer. func (d *DB) Refused(repo, user string) ([]string, error) { rules, err := d.Protections(repo) if err != nil { return nil, err } var out []string for _, p := range rules { if user == "" || !contains(p.Owners, user) { out = append(out, p.Branch) } } return out, nil } // branchMatches evaluates a rule's pattern against a branch name. // path.Match gives glob semantics with no dependency and no separators // to worry about — a fossil branch name has none. func branchMatches(pattern, branch string) bool { if pattern == branch { return true } ok, err := path.Match(pattern, branch) return err == nil && ok } func normalizeOwners(owners []string) []string { seen := map[string]bool{} var out []string for _, o := range owners { for _, name := range strings.Fields(o) { if !seen[name] { seen[name] = true out = append(out, name) } } } sort.Strings(out) return out } func contains(xs []string, x string) bool { for _, v := range xs { if v == x { return true } } return false }