package ci
import (
"bytes"
"errors"
"fmt"
"io"
"sort"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// permissions: is GitHub Actions' key, read by quarry and stripped
// before the engine compiles the workflow (RFC 0021 §runs): its words
// are what a run token for the job may at most carry, and the engine
// has no use for them. The words map onto the letters the gates speak.
//
// write implies read for the same noun. uv: write is y alone here; the
// read letters a private repository needs before a uv push are added
// where the token is minted, after the environment's allowed has cut
// the declaration, so an environment allowing y alone still yields a
// token that can reach the repository it publishes to.
var permissionWords = map[string]map[string]string{
"contents": {"read": "og", "write": "gio"},
"uv": {"read": "og", "write": "y"},
"tickets": {"read": "r", "write": "crw"},
"wiki": {"read": "j", "write": "jk"},
"forum": {"read": "2", "write": "23"},
"checks": {"read": "", "write": "8"},
}
// defaultPermissions is a job that declares nothing: contents: read.
const defaultPermissions = "go"
// JobGrant is what one job of a workflow declared, as the forge keeps
// it beside the run: the environment it names and the letters its
// permissions: map to, before any environment's allowed is applied.
// Timeout is its timeout-minutes, 0 when it states none.
type JobGrant struct {
Environment string `json:"environment,omitempty"`
Letters string `json:"letters"`
Timeout int `json:"timeout,omitempty"`
}
// RunStart is what the forge records about a run as it starts: the
// event that started it, in on:'s words, the person whose act did, and
// each job's declaration. A run token is minted from these and the
// forge's own settings, never from what a runner says.
type RunStart struct {
Event string
Starter string
Jobs map[string]JobGrant
}
// permissionsOf reads a ci.yml's permissions: at the workflow level and
// per job. A job's narrows the workflow's and never widens it; with no
// key at either level a job gets contents: read. A word this table
// lacks is an error with a line and a column, named as unsupported.
func permissionsOf(spec []byte) (map[string]JobGrant, error) {
doc, ok := yamlDoc(spec)
if !ok {
return nil, nil // the engine says what is wrong with it
}
top, topSet, err := permissionLetters(value(doc, "permissions"))
if err != nil {
return nil, err
}
jobs := value(doc, "jobs")
if jobs == nil || jobs.Kind != yaml.MappingNode {
return nil, nil
}
out := map[string]JobGrant{}
for i := 0; i+1 < len(jobs.Content); i += 2 {
id, job := jobs.Content[i].Value, jobs.Content[i+1]
if p := value(job, "permissions"); p != nil && job.Style&yaml.FlowStyle != 0 {
// A flow mapping shares its line with the job's other keys,
// so it cannot be stripped by the line: refused, not guessed.
return nil, fmt.Errorf("line %d, column %d: permissions: inside a flow-style job is not supported; write the job in block style",
p.Line, p.Column)
}
own, ownSet, err := permissionLetters(value(job, "permissions"))
if err != nil {
return nil, err
}
letters := defaultPermissions
switch {
case ownSet && topSet:
letters = intersectLetters(own, top)
case ownSet:
letters = own
case topSet:
letters = top
}
g := JobGrant{Letters: letters}
if env := value(job, "environment"); env != nil && env.Kind == yaml.ScalarNode && env.Tag != "!!null" {
g.Environment = env.Value
}
if env := value(job, "environment"); env != nil && env.Kind == yaml.MappingNode {
if n := value(env, "name"); n != nil {
g.Environment = n.Value
}
}
if tm := value(job, "timeout-minutes"); tm != nil {
g.Timeout, _ = strconv.Atoi(tm.Value)
}
out[id] = g
}
return out, nil
}
// permissionLetters is one permissions: value's letters, and whether
// the key was there at all.
func permissionLetters(n *yaml.Node) (string, bool, error) {
if n == nil {
return "", false, nil
}
if n.Kind != yaml.MappingNode {
return "", false, fmt.Errorf("line %d, column %d: permissions: %q is not supported; name each noun (contents, uv, tickets, wiki, forum, checks) with read, write or none",
n.Line, n.Column, n.Value)
}
var letters string
for i := 0; i+1 < len(n.Content); i += 2 {
k, v := n.Content[i], n.Content[i+1]
words, ok := permissionWords[k.Value]
if !ok {
return "", false, fmt.Errorf("line %d, column %d: permissions: %q is not supported; the nouns are contents, uv, tickets, wiki, forum and checks",
k.Line, k.Column, k.Value)
}
if v.Value == "none" {
continue
}
l, ok := words[v.Value]
if !ok || v.Kind != yaml.ScalarNode {
return "", false, fmt.Errorf("line %d, column %d: permissions: %s: %q is not supported; it may be read, write or none",
v.Line, v.Column, k.Value, v.Value)
}
letters += l
}
return sortLetters(letters), true, nil
}
func sortLetters(s string) string {
seen := map[rune]bool{}
var rs []rune
for _, r := range s {
if !seen[r] {
seen[r] = true
rs = append(rs, r)
}
}
sort.Slice(rs, func(i, j int) bool { return rs[i] < rs[j] })
return string(rs)
}
func intersectLetters(a, b string) string {
var out strings.Builder
for _, r := range a {
if strings.ContainsRune(b, r) {
out.WriteRune(r)
}
}
return out.String()
}
// yamlDoc is a single-document ci.yml's top-level mapping.
func yamlDoc(spec []byte) (*yaml.Node, bool) {
dec := yaml.NewDecoder(bytes.NewReader(spec))
var doc, more yaml.Node
if err := dec.Decode(&doc); err != nil {
return nil, false
}
if !errors.Is(dec.Decode(&more), io.EOF) || len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
return nil, false
}
return doc.Content[0], true
}
// withoutQuarryKeys is a ci.yml as the engine compiles it: quarry's own
// keys (inherit:, permissions:) blanked, every line kept so the
// compiler's line numbers still point at the author's lines.
func withoutQuarryKeys(spec []byte) []byte {
return withoutPermissions(withoutInherit(spec))
}
// withoutPermissions blanks the lines of every permissions: key, at the
// workflow level and on each job. A key inside a flow mapping shares
// its line with others and is left for the compiler to name.
func withoutPermissions(spec []byte) []byte {
doc, ok := yamlDoc(spec)
if !ok {
return spec
}
total := bytes.Count(spec, []byte("\n")) + 1
var spans [][2]int
// A key's lines run to the line before the next key of its mapping,
// or to the end of the region the mapping owns: a block scalar's
// lines are not nodes of their own, and lastLine alone would leave
// them behind for the compiler to read.
blank := func(m *yaml.Node, limit int) {
if m == nil || m.Kind != yaml.MappingNode || m.Style&yaml.FlowStyle != 0 {
return
}
for i := 0; i+1 < len(m.Content); i += 2 {
if m.Content[i].Value != "permissions" {
continue
}
end := limit
if i+2 < len(m.Content) {
end = m.Content[i+2].Line - 1
}
spans = append(spans, [2]int{m.Content[i].Line, max(end, lastLine(m.Content[i+1]))})
}
}
blank(doc, total)
for i := 0; i+1 < len(doc.Content); i += 2 {
jobs := doc.Content[i+1]
if doc.Content[i].Value != "jobs" || jobs.Kind != yaml.MappingNode {
continue
}
limit := total
if i+2 < len(doc.Content) {
limit = doc.Content[i+2].Line - 1
}
for j := 1; j < len(jobs.Content); j += 2 {
own := limit
if j+1 < len(jobs.Content) {
own = jobs.Content[j+1].Line - 1
}
blank(jobs.Content[j], own)
}
}
if len(spans) == 0 {
return spec
}
lines := bytes.SplitAfter(spec, []byte("\n"))
for _, s := range spans {
for l := s[0]; l <= s[1] && l-1 < len(lines); l++ {
if bytes.HasSuffix(lines[l-1], []byte("\n")) {
lines[l-1] = []byte("\n")
} else {
lines[l-1] = nil
}
}
}
return bytes.Join(lines, nil)
}
// lastLine is the last line a node's value reaches.
func lastLine(n *yaml.Node) int {
last := n.Line
for _, c := range n.Content {
if l := lastLine(c); l > last {
last = l
}
}
return last
}