// 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.
// legacy_code carries the project code that hash was derived under —
// the derivation is SHA1(project-code/login/password), so the hash is
// meaningless without it, and quarry accounts are forge-global while
// fossil's are per-repository.
var accountMigrations = []string{
`ALTER TABLE account ADD COLUMN legacy_pw TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE account ADD COLUMN legacy_code TEXT NOT NULL DEFAULT ''`,
}
// ImportedUser is one row arriving from a fossil peer or file.
type ImportedUser struct {
Login string
Caps string
Info string
PWHash string
}
// ImportUsers writes a peer's user table into this forge (RFC 0004 §6).
//
// The collision rule is deliberately conservative: an account that
// already exists here keeps ITS credential — an import must never be a
// way to take over a login — while its capabilities on this repository
// are replaced by the peer's, because that is the fact being imported.
// A login quarry has never seen is created with the peer's §6.3 hash
// so its owner can sign in with the password they already have.
//
// Category rows (nobody/anonymous/reader/developer) carry the peer's
// category capabilities; fossil only sends rows it has recorded, so a
// category the peer omits keeps quarry's stock default rather than
// being blanked into a lockout.
func (d *DB) ImportUsers(repo, projectCode string, users []ImportedUser) (created, updated int, err error) {
categories := map[string]bool{
"nobody": true, "anonymous": true, "reader": true, "developer": true,
}
for _, u := range users {
if u.Login == "" {
continue
}
if categories[u.Login] {
if err := d.SetCategoryCaps(repo, u.Login, u.Caps); err != nil {
return created, updated, err
}
updated++
continue
}
_, exists := d.AccountByName(u.Login)
if !exists {
if _, err := d.db.Exec(`INSERT INTO account (name, pwhash, admin, created, legacy_pw, legacy_code)
VALUES (?, '', 0, ?, ?, ?)`,
u.Login, now(), u.PWHash, projectCode); err != nil {
return created, updated, err
}
created++
} else {
updated++
}
if err := d.SetCaps(u.Login, repo, u.Caps); err != nil {
return created, updated, err
}
}
return created, updated, nil
}
// LegacyCredential returns an account's imported §6.3 hash and the
// project code it was derived under; ok is false once the account has
// a native credential or never had an imported one.
func (d *DB) LegacyCredential(account string) (hash, projectCode string, ok bool) {
err := d.db.QueryRow(`SELECT legacy_pw, legacy_code FROM account WHERE name = ?`,
account).Scan(&hash, &projectCode)
return hash, projectCode, err == nil && hash != ""
}
// ClearLegacyCredential drops the imported hash once the account has
// been given a native one — the imported secret should not outlive its
// usefulness.
func (d *DB) ClearLegacyCredential(account string) error {
_, err := d.db.Exec(`UPDATE account SET legacy_pw = '', legacy_code = '' WHERE name = ?`, account)
return err
}
// 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)
if err != nil {
return err
}
if category == "nobody" || category == "anonymous" {
return d.syncVisibilityColumn(repo)
}
return nil
}
// CategoryCaps returns a repo's category string: its own row, else the
// fleet default's, else fossil's stock value.
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
}
if repo != fleetRepo {
err = d.db.QueryRow(`SELECT caps FROM repo_category_caps WHERE repo = '' AND category = ?`,
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 {
own, _ := d.CapsOf(account, repo)
return d.effective(account, repo, own)
}
// effective is EffectiveCaps with the account's own string supplied,
// so a guard can ask what a string WOULD grant before writing it.
func (d *DB) effective(account, repo, own string) CapSet {
eff := d.CategoryCaps(repo, "nobody")
if account != "" {
eff = capUnion(eff, d.CategoryCaps(repo, "anonymous"))
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)
}
// CapHolder is one account's stored caps on a repository.
type CapHolder struct {
Account string
Caps string
}
// CapHolders lists every account with explicit caps on a repo, plus
// every account at all — the settings editor needs both the ones who
// hold letters and the ones who could.
func (d *DB) CapHolders(repo string) ([]CapHolder, error) {
rows, err := d.db.Query(`SELECT a.name, COALESCE(c.caps, '')
FROM account a LEFT JOIN repo_caps c ON c.account = a.name AND c.repo = ?
ORDER BY a.name`, repo)
if err != nil {
return nil, err
}
defer rows.Close()
var out []CapHolder
for rows.Next() {
var h CapHolder
if err := rows.Scan(&h.Account, &h.Caps); err != nil {
return nil, err
}
out = append(out, h)
}
return out, rows.Err()
}
// 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 {
var n int
if err := d.db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'repo_role'`).Scan(&n); err != nil {
return err
}
if n == 0 {
return nil // already gone: the ordinary case after the first open
}
// The legacy strings, not the RFC 0008 presets: a row written as
// "write" meant uv when it was written, and a migration that
// widened it to Developer would grant forum and chat behind an
// administrator's back. Widening is the People page's job, in the
// open, one person at a time.
if _, 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')`); err != nil {
return err
}
_, err := d.db.Exec(`DROP TABLE repo_role`)
return err
}