Skip to content
quarry Artifact [7ac64da579]
Login

Artifact [7ac64da579]

Artifact 7ac64da579f68c900885d53c1889dab483887eb1359b0510a6e1096b97acc54a:


// roles.go — RFC 0008: permissions without a manual. Named roles and
// sentence-form abilities that compile losslessly to fossil capability
// letters, and decode back. Nothing here is stored; a role is recovered
// from a string by matching, so an imported fossil user table shows
// roles where it happens to hold them and Custom where it does not.
//
// The compiler owns fossil's implications (w ⇒ rcn, 5 ⇒ 4 ⇒ 3 ⇒ 2,
// k ⇒ jm, i ⇒ o) so the abilities layer cannot produce a string fossil
// reads one way and a person another.
package oltp

import (
	"sort"
	"strings"
)

// Preset is a named role: who it is for, and the string it writes.
type Preset struct {
	Name  string
	Blurb string
	Caps  string
}

// Presets are the roles, in the order the page offers them. Constants
// with a table-driven test asserting each letter set; a change here is a
// diff someone reviews, not a value someone edits.
var Presets = []Preset{
	// Reader spells out its letters instead of routing through the
	// reader CATEGORY, because `u` did not mean what the blurb says.
	//
	// `u` is fossil's reader category and effective() expands it, so on
	// a repository that has not overridden its categories it resolved
	// through fossil's shipped default kptw — k edit wiki pages, t
	// create ticket report formats, w edit tickets. "Reads, asks,
	// discusses" was handing over wiki editing and ticket editing, and
	// the person granting it could not see that happen. teamFloor
	// records the same finding as its reason for not being "Reader".
	//
	// `p` is kept deliberately: fossil calls it "change own password",
	// and quarry has also made it the signed-in marker on four
	// cross-repository ops (me.feed among them), so dropping it with
	// the rest of the category would have taken the API's dashboard
	// away from every Reader.
	//
	// `c` without `n`: comment on a ticket, do not mint one. Fossil's
	// stock ANONYMOUS category carries both, so this is narrower than
	// what fossil hands a passer-by — deliberately, because RFC 0017
	// makes `n` the store door and the local tier the place a reader
	// files into.
	//
	// Developer and Maintainer keep `u`. Their blurbs promise writing,
	// so the category expansion is not a surprise there, and keeping it
	// means a repository that deliberately WIDENS its reader category
	// still reaches the roles that are supposed to grow with it.
	{"Reader", "reads, asks, discusses — most accounts on a public project", "bcghjoprz2378BC"},
	{"Developer", "checks in, on any branch that is not protected", "uv3bBC78"},
	{"Maintainer", "a developer who also keeps the place tidy", "uv45bBlqCA78"},
	{"Admin", "changes this repository's settings", "a"},
}

// Ability is one sentence a person can do, and the letters it means.
type Ability struct {
	Group    string
	Sentence string
	Letters  string // the letters this ability sets; implications follow
	Advanced bool   // shown only under the disclosure
}

// Abilities in page order. Letters an ability sets are its own; what
// those imply is Closure's business.
var Abilities = []Ability{
	{"Code", "read the code, clone, and download archives", "ogzh", false},
	{"Code", "check in and push", "i", false},
	{"Code", "see private branches", "x", true},
	{"Code", "push unversioned files", "y", true},
	{"Tickets", "read tickets", "r", false},
	{"Tickets", "file tickets", "n", false},
	{"Tickets", "comment on tickets", "c", false},
	{"Tickets", "edit tickets", "w", false},
	{"Tickets", "moderate ticket comments", "q", false},
	{"Tickets", "create ticket report formats", "t", false},
	{"Wiki", "read the wiki and notes", "j", false},
	{"Wiki", "edit wiki pages", "k", false},
	{"Wiki", "create new wiki pages", "f", false},
	{"Wiki", "approve wiki appends", "l", false},
	{"Forum", "read the forum", "2", false},
	{"Forum", "post in the forum", "3", false},
	{"Forum", "post without moderation", "4", false},
	{"Forum", "moderate the forum", "5", false},
	{"Forum", "grant moderation exemptions", "6", false},
	{"Talk", "chat", "C", false},
	{"Talk", "send announcements", "A", false},
	{"Talk", "receive email alerts", "7", false},
	{"Files", "attach files to tickets and wiki pages", "b", false},
	{"Files", "attach files to forum posts", "B", false},
	{"People", "see people's email addresses", "e", true},
	{"Repository", "change this repository's settings", "a", false},
	// 8 is quarry's own: fossil has no CI, so no fossil letter names it,
	// and a digit fossil does not use cannot collide with an imported
	// user table. The nobody category never holds it: a forge's CI is
	// for the people who belong to it, not for whoever guessed the URL.
	{"CI", "see CI runs, checks and logs", "8", false},
}

// AsGated is the letter set a GATE treats this account as holding.
//
// The letters answer two different questions and the answers had
// drifted apart. CapSet.HasAny — which every gate goes through — reads
// `a` as a wildcard for everything except s, x and y, so an account
// holding only `a` really can check in and really can edit a wiki
// page. Closure, which powers Decode and Infer, has no entry for `a`
// at all, so it reads `a` as naming nothing but itself.
//
// account.explain then answered one question with each: its `can`
// sentence came from Explain (HasAny, so `a` expanded) and its letter
// list came from Decode (Closure, so `a` did not). For a repository
// admin the sentence said they could check in and edit tickets while
// the letters beside it showed no i, k, w or f, and the page
// contradicted itself in one answer (8ea1e46d, G-84).
//
// So both halves derive from HERE now. This is the gate's rule stated
// once, in the same place the letters live, rather than inferred twice.
// It does NOT change what any gate permits: HasAny is unchanged and
// remains the thing that decides. It changes what we SAY we permit, so
// that it matches.
//
// s, x and y are excluded deliberately, exactly as HasAny excludes
// them: site admin is not a repository letter, and fossil keeps
// private-branch and unversioned-write out of `a` on purpose.
func AsGated(caps string) string {
	site := strings.ContainsRune(caps, 's')
	admin := strings.ContainsRune(caps, 'a')
	if !site && !admin {
		return Closure(caps)
	}
	set := map[rune]bool{}
	for _, r := range Closure(caps) {
		set[r] = true
	}
	for _, ab := range Abilities {
		for _, r := range ab.Letters {
			// s grants EVERYTHING, x and y included — HasAny returns
			// true for any letter when s is held. a stops short of
			// exactly those three.
			//
			// The first version of this function handled only a,
			// because a was what jva's report named. Measuring the
			// deployed fix caught the rest: zeitgeist holds s, and
			// explain told them they "can do everything" above a list
			// of seven read-ish abilities — the same contradiction, one
			// umbrella cap over.
			if site || !strings.ContainsRune("sxy", r) {
				set[r] = true
			}
		}
	}
	if site {
		set['s'] = true
	}
	return sortedLetters(set)
}

// implies is fossil's own reading of the letters (caps/ref): holding the
// key means holding the values.
var implies = map[rune]string{
	'w': "rcn", 'k': "jm", 'i': "o",
	'3': "2", '4': "3", '5': "4", '6': "5",
}

// Closure adds every letter a string implies, so two strings that fossil
// treats alike compare alike. Category letters u and v are left as they
// are: their expansion is per repository and belongs to EffectiveCaps.
func Closure(caps string) string {
	set := map[rune]bool{}
	var walk func(r rune)
	walk = func(r rune) {
		if set[r] {
			return
		}
		set[r] = true
		for _, i := range implies[r] {
			walk(i)
		}
	}
	for _, r := range caps {
		if r != ' ' {
			walk(r)
		}
	}
	return sortedLetters(set)
}

func sortedLetters(set map[rune]bool) string {
	rs := make([]rune, 0, len(set))
	for r := range set {
		rs = append(rs, r)
	}
	sort.Slice(rs, func(i, j int) bool { return letterOrder(rs[i]) < letterOrder(rs[j]) })
	return string(rs)
}

// letterOrder keeps fossil's habit: lowercase, then digits, then capitals.
func letterOrder(r rune) int {
	switch {
	case r >= 'a' && r <= 'z':
		return int(r - 'a')
	case r >= '0' && r <= '9':
		return 100 + int(r-'0')
	default:
		return 200 + int(r)
	}
}

// Normalize is Closure with duplicates and whitespace gone: the form of
// a string the page writes.
func Normalize(caps string) string { return Closure(caps) }

// PresetOf names the role a string is exactly, or "" for none.
func PresetOf(caps string) string {
	n := Closure(caps)
	for _, p := range Presets {
		if Closure(p.Caps) == n {
			return p.Name
		}
	}
	return ""
}

// Reading is what a string means: the nearest role, the abilities it has
// beyond that role, and the ones it lacks from it. Nearest by symmetric
// difference, ties to the smaller role, so "Developer plus moderate" is
// preferred to "Maintainer minus five things".
type Reading struct {
	Role  string    // exact or nearest preset; "Custom" when nothing is near
	Exact bool      // the string IS the role
	Plus  []Ability // abilities beyond the role
	Minus []Ability // abilities the role has that this string lacks
}

// Infer reads a string as a role and a difference. A role is near only
// when the difference is at most two abilities either way: "Developer,
// plus moderate" is a reading; "Admin, plus check in, plus unversioned,
// minus settings" is a bot with two letters and must say Custom. Admin's
// single letter is compared as everything it grants, so "a3" is Admin,
// exactly, not Admin plus a letter it already covers.
func Infer(caps string) Reading {
	n := expandAdmin(Closure(caps))
	best, bestRd, bestCount := "", Reading{}, -1
	for _, p := range Presets {
		base := expandAdmin(Closure(p.Caps))
		rd := Reading{Role: p.Name}
		for _, a := range Abilities {
			has := strings.ContainsRune(n, rune(a.Letters[0]))
			had := strings.ContainsRune(base, rune(a.Letters[0]))
			switch {
			case has && !had:
				rd.Plus = append(rd.Plus, a)
			case had && !has:
				rd.Minus = append(rd.Minus, a)
			}
		}
		// An ability implied by another in the same list is the same
		// difference said twice: moderating the forum implies posting
		// without moderation, and a reading that says both is noise.
		rd.Plus, rd.Minus = collapse(rd.Plus), collapse(rd.Minus)
		count := len(rd.Plus) + len(rd.Minus)
		// Letters no ability names (u, v) still separate roles.
		if strings.ContainsRune(n, 'v') != strings.ContainsRune(base, 'v') ||
			strings.ContainsRune(n, 'u') != strings.ContainsRune(base, 'u') {
			count += 2
		}
		if bestCount < 0 || count < bestCount {
			best, bestRd, bestCount = p.Name, rd, count
		}
	}
	bestRd.Exact = bestCount == 0
	if bestCount > 2 {
		return Reading{Role: "Custom"}
	}
	bestRd.Role = best
	return bestRd
}

// collapse drops abilities whose letters another ability in the list
// already implies.
func collapse(as []Ability) []Ability {
	var out []Ability
	for _, a := range as {
		implied := false
		for _, b := range as {
			if b.Letters != a.Letters && strings.ContainsRune(Closure(b.Letters), rune(a.Letters[0])) {
				implied = true
			}
		}
		if !implied {
			out = append(out, a)
		}
	}
	return out
}

// expandAdmin spells out what a grants, for comparison only: every letter
// except setup, private and unversioned (fossil's Admin-vs-Setup rule).
func expandAdmin(caps string) string {
	if !strings.ContainsRune(caps, 'a') {
		return caps
	}
	set := map[rune]bool{}
	for _, r := range caps {
		set[r] = true
	}
	for _, r := range allCaps {
		if !strings.ContainsRune("sxy", r) {
			set[r] = true
		}
	}
	return sortedLetters(set)
}

// Compile writes the string for a role plus and minus abilities — what
// the editor's radio and checkboxes mean. Unknown role names compile to
// the empty string, which the caller must refuse.
func Compile(role string, plus, minus []string) string {
	var base string
	for _, p := range Presets {
		if p.Name == role {
			base = p.Caps
		}
	}
	if base == "" && role != "Custom" {
		return ""
	}
	set := map[rune]bool{}
	for _, r := range Closure(base) {
		set[r] = true
	}
	for _, name := range plus {
		if a, ok := abilityNamed(name); ok {
			for _, r := range Closure(a.Letters) {
				set[r] = true
			}
		}
	}
	for _, name := range minus {
		if a, ok := abilityNamed(name); ok {
			for _, r := range a.Letters {
				delete(set, r)
			}
		}
	}
	return sortedLetters(set)
}

func abilityNamed(sentence string) (Ability, bool) {
	for _, a := range Abilities {
		if a.Sentence == sentence {
			return a, true
		}
	}
	return Ability{}, false
}

// Decode lists the sentences a string grants, in page order. Category
// letters are named as such; letters no ability covers are named by
// letter so nothing typed is silently dropped from the explanation.
// Wanted names what a capability letter buys, in the words the
// settings page uses. "" for a letter no ability claims.
//
// It exists so a refusal can say what it wanted. "This account does not
// have the capability this request needs" is true and useless: it names
// no letter, no ability and no remedy, so a reader cannot tell whether
// they asked for the wrong thing or hold the wrong role (ticket
// f44e0c66, and jva hit it from all three vantages).
func Wanted(letters string) string {
	for _, want := range letters {
		for _, a := range Abilities {
			if strings.ContainsRune(a.Letters, want) {
				return a.Sentence
			}
		}
	}
	return ""
}

func Decode(caps string) []string {
	n := Closure(caps)
	var out []string
	covered := map[rune]bool{}
	for _, a := range Abilities {
		if strings.ContainsRune(n, rune(a.Letters[0])) {
			out = append(out, a.Sentence)
			for _, r := range Closure(a.Letters) {
				covered[r] = true
			}
		}
	}
	for _, r := range n {
		if covered[r] {
			continue
		}
		switch r {
		case 'u':
			out = append(out, "everything the reader category grants")
		case 'v':
			out = append(out, "everything the developer category grants")
		case 's':
			out = append(out, "everything — site administrator")
		case 'd', 'D', 'L', 'p', 'm':
			// p and m are implied by categories in practice and mean
			// little on their own; d is fossil's unused legacy.
		default:
			out = append(out, "letter "+string(r))
		}
	}
	return out
}

// Explain turns an EFFECTIVE set — categories expanded — into one
// sentence for a person: what they can do, then what they notably
// cannot. Written for the People table, where a reader wants the
// answer, not the list.
func Explain(name string, eff CapSet) string {
	e := string(eff)
	can := func(letters string) bool { return eff.HasAny(letters) }
	var yes, no []string
	add := func(ok bool, phrase string) {
		if ok {
			yes = append(yes, phrase)
		} else {
			no = append(no, phrase)
		}
	}
	add(can("o"), "read the code")
	add(can("i"), "check in")
	add(can("n"), "file tickets")
	add(can("w"), "edit tickets")
	add(can("k"), "edit the wiki")
	add(can("3"), "post in the forum")
	add(can("C"), "chat")
	add(can("5"), "moderate")
	add(can("a"), "change settings")
	if strings.ContainsRune(e, 's') {
		return name + " is a site administrator and can do everything."
	}
	s := name + " can " + joinAnd(yes)
	if len(yes) == 0 {
		s = name + " can see the repository"
	}
	if len(no) > 0 {
		s += ", but not " + joinOr(no)
	}
	return s + "."
}

func joinAnd(xs []string) string {
	switch len(xs) {
	case 0:
		return ""
	case 1:
		return xs[0]
	}
	return strings.Join(xs[:len(xs)-1], ", ") + ", and " + xs[len(xs)-1]
}

func joinOr(xs []string) string {
	switch len(xs) {
	case 0:
		return ""
	case 1:
		return xs[0]
	}
	return strings.Join(xs[:len(xs)-1], ", ") + " or " + xs[len(xs)-1]
}

// Visibility is a preset over the two category strings.
type Visibility struct {
	Name      string
	Blurb     string
	Nobody    string
	Anonymous string
}

// Visibilities in page order. Public differs from fossil's stock by h
// and 2 for visitors and 3 and C for the signed-in — the reason the
// forum and chat work for a new account without anyone typing a letter.
var Visibilities = []Visibility{
	{"Public project", "anyone can read, clone, and browse tickets and the forum", "gjorzh2", "mnc3C8"},
	{"Members only", "nothing is visible until you sign in; then it is a public project", "", "gjorzh2mnc3C8"},
	{"Private", "only the people listed; the repository does not exist for anyone else", "", ""},
}

// Participation is what a signed-in account WITHOUT a role may do
// beyond reading: the letters the "anyone signed in may also" boxes
// on Settings control. A visibility preset says who can see; these
// say who can speak. Naming a preset ignores them, so clearing a box
// does not turn "Public project" into "Custom", and nobody has to
// know that a rejection on a landing is the forum letter.
var Participation = []Ability{
	{"Tickets", "file and comment on tickets", "nc", false},
	{"Forum", "post in the forum, including remarks on landings", "3", false},
	{"Talk", "chat", "C", false},
	{"CI", "see CI runs, checks and logs", "8", false},
}

// StripParticipation removes every participation letter from a string.
func StripParticipation(caps string) string {
	for _, a := range Participation {
		for _, r := range a.Letters {
			caps = strings.ReplaceAll(caps, string(r), "")
		}
	}
	return caps
}

// WithParticipation is the anonymous string with exactly the chosen
// participation letters: the rest of the string is kept as it was.
func WithParticipation(anonymous, chosen string) string {
	out := StripParticipation(anonymous)
	for _, a := range Participation {
		if strings.ContainsRune(chosen, rune(a.Letters[0])) {
			out += a.Letters
		}
	}
	return out
}

// VisibilityOf names the preset a pair of category strings is, or
// "Custom". Participation letters are stripped before the closure is
// taken, or a letter's implications would survive the strip.
func VisibilityOf(nobody, anonymous string) string {
	n, a := Closure(nobody), Closure(StripParticipation(anonymous))
	for _, v := range Visibilities {
		if Closure(v.Nobody) == n && Closure(StripParticipation(v.Anonymous)) == a {
			return v.Name
		}
	}
	return "Custom"
}