package main import ( "fmt" "regexp" "strings" ) // Repo is one entry in repos.json, keyed by {section}/{repo} or {repo}. type Repo struct { Desc string `json:"desc"` Visibility string `json:"visibility"` Issues string `json:"issues"` Wiki string `json:"wiki"` Labels []string `json:"labels"` Grants map[string]string `json:"grants"` } // visibility governs reading everything; issues and wiki each govern writing // to that feature. const ( visPublic = "public" visPrivate = "private" featDisabled = "disabled" featMembers = "members" featUsers = "users" grantRead = "read" grantWrite = "write" grantAdmin = "admin" ) // orphanRepo is how a directory with no entry is served: private, both // features off. func orphanRepo() Repo { return Repo{ Visibility: visPrivate, Issues: featDisabled, Wiki: featDisabled, Labels: []string{}, Grants: map[string]string{}, } } // clone deep copies, so a writer rebuilding grants is never seen half done. func (r Repo) clone() Repo { r.Labels = append([]string(nil), r.Labels...) grants := make(map[string]string, len(r.Grants)) for who, level := range r.Grants { grants[who] = level } r.Grants = grants return r } // A section or repository name. The first character rules out . and .., so a // path built from these cannot traverse. var repoNameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) // The forge's own route names, refused in both components: /infra/settings is // the settings of infra. var reservedNames = map[string]bool{ "login": true, "logout": true, "settings": true, "admin": true, "setup": true, "healthz": true, "set-password": true, "cgit.css": true, "cgit.png": true, "favicon.ico": true, "logo": true, "avatar": true, "reset": true, "confirm-email": true, "oidc": true, "issues": true, "wiki": true, } // cgit's routes, refused in the second component: /infra/log is the log of // infra. cgit's command table, less info and objects, which it refuses itself. var cgitNames = map[string]bool{ "about": true, "atom": true, "blame": true, "blob": true, "commit": true, "diff": true, "log": true, "ls_cache": true, "patch": true, "plain": true, "rawdiff": true, "refs": true, "repolist": true, "snapshot": true, "stats": true, "summary": true, "tag": true, "tree": true, } // Suffixes the tree gives meaning to. var reservedSuffixes = []string{".git", ".issues", ".wiki"} // validRepoPath checks a path from a URL or repos.json: one or two components. func validRepoPath(path string) bool { parts := strings.Split(path, "/") if len(parts) > 2 { return false } for i, part := range parts { if !repoNameRE.MatchString(part) || reservedNames[part] { return false } if i == 1 && cgitNames[part] { return false } for _, suffix := range reservedSuffixes { if strings.HasSuffix(part, suffix) { return false } } } return true } // Viewer is whoever is asking. The zero value is an anonymous visitor. type Viewer struct { Name string // empty when signed out Admin bool // the global admin flag } func (v Viewer) SignedIn() bool { return v.Name != "" } // Grant is the viewer's level on the repository, or "" for none. A global // admin holds admin everywhere. func (v Viewer) Grant(repo Repo) string { if v.Admin { return grantAdmin } if v.Name == "" { return "" } return repo.Grants[v.Name] } // CanRead covers code, browsing, cloning, issues, and wiki alike. func (v Viewer) CanRead(repo Repo) bool { return repo.Visibility == visPublic || v.Grant(repo) != "" } // CanWrite is push to {repo}.git alone; issues and wiki have their own knobs. func (v Viewer) CanWrite(repo Repo) bool { g := v.Grant(repo) return g == grantWrite || g == grantAdmin } // CanAdmin is this repository's settings and grants. func (v Viewer) CanAdmin(repo Repo) bool { return v.Grant(repo) == grantAdmin } // CanIssues and CanWiki are writing; reading is visibility's alone. func (v Viewer) CanIssues(repo Repo) bool { return v.canFeature(repo, repo.Issues) } func (v Viewer) CanWiki(repo Repo) bool { return v.canFeature(repo, repo.Wiki) } func (v Viewer) canFeature(repo Repo, knob string) bool { if !v.CanRead(repo) { return false } switch knob { case featMembers: return v.Grant(repo) != "" case featUsers: return v.SignedIn() } return false } // validate checks one repos.json record. Refused rather than coerced: a // coerced permission is one nobody knows they have. func (r Repo) validate(path string) []error { var errs []error fail := func(format string, a ...any) { errs = append(errs, fmt.Errorf(format, a...)) } if !validRepoPath(path) { fail("%q is not a usable repository path", path) } if r.Visibility != visPublic && r.Visibility != visPrivate { fail("%s: visibility %q is neither public nor private", path, r.Visibility) } for _, knob := range []struct{ name, value string }{{"issues", r.Issues}, {"wiki", r.Wiki}} { switch knob.value { case featDisabled, featMembers: case featUsers: if r.Visibility == visPrivate { fail("%s: %s is users on a private repository, so an account that cannot read it could write to it", path, knob.name) } default: fail("%s: %s %q is not disabled, members, or users", path, knob.name, knob.value) } } for name, level := range r.Grants { if !accountNameRE.MatchString(name) { fail("%s: %q is not a usable account name", path, name) } if level != grantRead && level != grantWrite && level != grantAdmin { fail("%s: grant %q for %s is not read, write, or admin", path, level, name) } } // Every label is a checkbox on every issue form. if len(r.Labels) > maxLabels { fail("%s: %d labels, and %d is all a repository may have", path, len(r.Labels), maxLabels) } for _, label := range r.Labels { if !validLabel(label) { fail("%s: label %q is empty, too long, or has a line break in it", path, label) } } return errs } const maxLabels = 64 func validLabel(label string) bool { return label != "" && len(label) <= 32 && !strings.ContainsAny(label, "\r\n") }