// 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", "reads, asks, discusses — most accounts on a public project", "u3bBC7"},
{"Developer", "checks in, on any branch that is not protected", "uv3bBC7"},
{"Maintainer", "a developer who also keeps the place tidy", "uv45bBlqCA7"},
{"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},
}
// 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)
}
func symDiff(a, b string) int {
n := 0
for _, r := range a {
if !strings.ContainsRune(b, r) {
n++
}
}
for _, r := range b {
if !strings.ContainsRune(a, r) {
n++
}
}
return n
}
// 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.
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", "mnc3C"},
{"Members only", "nothing is visible until you sign in; then it is a public project", "", "gjorzh2mnc3C"},
{"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},
}
// 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"
}