Skip to content
quarry Artifact [8b21027ef8]
Login

Artifact [8b21027ef8]

Artifact 8b21027ef8fc2afa61bc596adf649b71ac3c3d424a2f85ffa3defdfc5234def0:


// 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
}