Skip to content
quarry Artifact [8e316fc1a2]
Login

Artifact [8e316fc1a2]

Artifact 8e316fc1a2622770b4c54e75d3755650bee4cecac7ebbea5c695412b9ff398e3:


// caps.go — fossil's capability model, quarry-native (RFC 0004). An
// account's authority on a repository is an unordered, case-sensitive
// string of fossil capability letters, expanded through fossil's four
// user categories. The expansion rules are fossil's exactly, including
// the deliberate trap: v (developer) does NOT imply u (reader).
//
// Letters quarry has no surface for yet are stored and inert: imports
// stay lossless, and a letter starts working the day its surface ships,
// with no migration.
package oltp

import "strings"

const capsSchema = `
CREATE TABLE IF NOT EXISTS repo_caps (
	account TEXT NOT NULL,
	repo    TEXT NOT NULL,
	caps    TEXT NOT NULL, -- fossil cap string, verbatim
	PRIMARY KEY (account, repo)
);
CREATE TABLE IF NOT EXISTS repo_category_caps (
	repo     TEXT NOT NULL,
	category TEXT NOT NULL, -- nobody | anonymous | reader | developer
	caps     TEXT NOT NULL,
	PRIMARY KEY (repo, category)
);`

// accountMigrations: legacy_pw carries an imported fossil user's RFC
// §6.3 hash until their first successful login rehashes to argon2.
var accountMigrations = []string{
	`ALTER TABLE account ADD COLUMN legacy_pw TEXT NOT NULL DEFAULT ''`,
}

// Stock category defaults — fossil's shipped values (caps-index §ucat).
const (
	StockNobody    = "gjorz"
	StockAnonymous = "hmnc"
	StockReader    = "kptw"
	StockDeveloper = "ei"
)

// allCaps is the closed fossil vocabulary, for s-expansion display; the
// letters d, u, v, L are meta/legacy and excluded from concrete grants.
const allCaps = "abcefghijklmnopqrstwxyz234567ABCD"

// CapSet is an expanded, concrete capability set. Always produced by
// EffectiveCaps — never construct one from raw stored strings, which
// are unexpanded.
type CapSet string

// HasAny reports whether the set holds any of the asked letters. The
// two umbrella caps resolve here: s grants everything; a grants
// everything except s, x, y (fossil's Admin-vs-Setup rule).
func (c CapSet) HasAny(letters string) bool {
	for _, l := range letters {
		if strings.ContainsRune(string(c), l) {
			return true
		}
		if strings.ContainsRune(string(c), 's') {
			return true
		}
		if strings.ContainsRune(string(c), 'a') && !strings.ContainsRune("sxy", l) {
			return true
		}
	}
	return false
}

// capUnion merges cap strings, deduplicated, order-free.
func capUnion(sets ...string) string {
	var b strings.Builder
	seen := map[rune]bool{}
	for _, s := range sets {
		for _, r := range s {
			if !seen[r] {
				seen[r] = true
				b.WriteRune(r)
			}
		}
	}
	return b.String()
}

// SetCaps stores (replacing) one account's explicit caps on a repo.
func (d *DB) SetCaps(account, repo, caps string) error {
	if caps == "" {
		_, err := d.db.Exec(`DELETE FROM repo_caps WHERE account = ? AND repo = ?`, account, repo)
		return err
	}
	_, err := d.db.Exec(`INSERT INTO repo_caps (account, repo, caps) VALUES (?, ?, ?)
		ON CONFLICT (account, repo) DO UPDATE SET caps = excluded.caps`, account, repo, caps)
	return err
}

// CapsOf returns the stored, UNEXPANDED cap string ("" when none).
func (d *DB) CapsOf(account, repo string) (string, error) {
	var caps string
	err := d.db.QueryRow(`SELECT caps FROM repo_caps WHERE account = ? AND repo = ?`,
		account, repo).Scan(&caps)
	if err != nil {
		return "", nil
	}
	return caps, nil
}

// SetCategoryCaps overrides one category's caps for one repo. An empty
// string is a REAL value — fossil "takes a repo private" by blanking
// nobody and anonymous, so absence and emptiness must differ.
func (d *DB) SetCategoryCaps(repo, category, caps string) error {
	_, err := d.db.Exec(`INSERT INTO repo_category_caps (repo, category, caps) VALUES (?, ?, ?)
		ON CONFLICT (repo, category) DO UPDATE SET caps = excluded.caps`, repo, category, caps)
	return err
}

// CategoryCaps returns a repo's category string, stock when unset.
func (d *DB) CategoryCaps(repo, category string) string {
	var caps string
	err := d.db.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = ? AND category = ?`,
		repo, category).Scan(&caps)
	if err == nil {
		return caps
	}
	switch category {
	case "nobody":
		return StockNobody
	case "anonymous":
		return StockAnonymous
	case "reader":
		return StockReader
	case "developer":
		return StockDeveloper
	}
	return ""
}

// EffectiveCaps expands an account's authority on a repo (RFC 0004 §3):
// every visitor holds nobody; a signed-in account adds anonymous and
// its explicit caps; u pulls in the reader category, v the developer
// category (v never implies u); a site admin holds s everywhere.
func (d *DB) EffectiveCaps(account, repo string) CapSet {
	eff := d.CategoryCaps(repo, "nobody")
	if account != "" {
		eff = capUnion(eff, d.CategoryCaps(repo, "anonymous"))
		own, _ := d.CapsOf(account, repo)
		eff = capUnion(eff, own)
		if acct, found := d.AccountByName(account); found && acct.Admin {
			eff = capUnion(eff, "s")
		}
	}
	if strings.ContainsRune(eff, 'u') {
		eff = capUnion(eff, d.CategoryCaps(repo, "reader"))
	}
	if strings.ContainsRune(eff, 'v') {
		eff = capUnion(eff, d.CategoryCaps(repo, "developer"))
	}
	return CapSet(eff)
}

// MigrateRolesToCaps translates legacy repo_role rows into cap strings:
// read→u, write→uv, admin→a. INSERT OR IGNORE is the idempotency —
// explicit caps are never clobbered, and reruns are free. repo_role
// itself stays readable for one release (RFC 0004 §5).
func (d *DB) MigrateRolesToCaps() error {
	_, err := d.db.Exec(`INSERT OR IGNORE INTO repo_caps (account, repo, caps)
		SELECT account, repo,
			CASE role WHEN 'read' THEN 'u' WHEN 'write' THEN 'uv' WHEN 'admin' THEN 'a' ELSE '' END
		FROM repo_role WHERE role IN ('read','write','admin')`)
	return err
}