// 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 (
"database/sql"
"errors"
"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 {
return d.SetProtectionBy(repo, branch, owners, "")
}
// SetProtectionBy is SetProtection naming who made the change, which
// the settings log records (field protect:<branch>, owners before and
// after): who protected a branch is the first question when a push is
// refused.
func (d *DB) SetProtectionBy(repo, branch string, owners []string, actor string) error {
branch = strings.TrimSpace(branch)
if branch == "" {
return nil
}
names := normalizeOwners(owners)
tx, err := d.w.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var was string
if err := tx.QueryRow(`SELECT owners FROM branch_protection WHERE repo = ? AND branch = ?`, repo, branch).Scan(&was); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
now := strings.Join(names, " ")
if was == now {
return tx.Commit()
}
if len(names) == 0 {
if _, err := tx.Exec(`DELETE FROM branch_protection WHERE repo = ? AND branch = ?`, repo, branch); err != nil {
return err
}
} else if _, err := tx.Exec(`INSERT INTO branch_protection (repo, branch, owners)
VALUES (?, ?, ?)
ON CONFLICT (repo, branch) DO UPDATE SET owners = excluded.owners`,
repo, branch, now); err != nil {
return err
}
if was == "" {
was = "(not protected)"
}
if now == "" {
now = "(not protected)"
}
if err := logSetting(tx, repo, actor, "protect:"+branch, was, now); err != nil {
return err
}
return tx.Commit()
}
// 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.
//
// Exported because the settings page answers "which rules govern this
// branch?" and that answer has to be the gate's, not a second
// implementation of the same glob that agrees by luck.
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
}