Skip to content
quarry Artifact [e9fc544525]
Login

Artifact [e9fc544525]

Artifact e9fc5445252b2dc06e7e218b139caf7ca678f8ad036a3bd175f8440ed66992dc:


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