Skip to content
quarry Artifact [fa2d33f8ab]
Login

Artifact [fa2d33f8ab]

Artifact fa2d33f8abe7cb0b23ec4d49b22e242c049e7011246e62ea762bcb5184b552a0:


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"},
}

// PermissionNouns are permissions:'s nouns in the order a settings page
// lists them, so an environment's ceiling is set in the words a
// workflow declares rather than in letters. uv has no read level of its
// own: reading unversioned files is reading the repository (contents).
var PermissionNouns = []string{"contents", "uv", "tickets", "wiki", "forum", "checks"}

// LettersFor is the letters a noun at a level grants: level is "read",
// "write" or "none" ("" too). An unknown noun or level is an error.
func LettersFor(noun, level string) (string, error) {
	levels, ok := permissionWords[noun]
	if !ok {
		return "", fmt.Errorf("unknown permission %q", noun)
	}
	switch level {
	case "", "none":
		return "", nil
	case "read", "write":
		return levels[level], nil
	}
	return "", fmt.Errorf("permission %s: level %q is not none, read or write", noun, level)
}

// LevelsOf reads letters back in permissions:'s words: each noun at
// write when every write letter is present, else read when it has read
// letters and every one is present, else none. extra is the letters no
// noun's level accounts for, kept so a ceiling set as letters is shown
// whole rather than silently narrowed.
func LevelsOf(letters string) (levels map[string]string, extra string) {
	has := func(s string) bool {
		for _, c := range s {
			if !strings.ContainsRune(letters, c) {
				return false
			}
		}
		return true
	}
	levels = map[string]string{}
	covered := ""
	readSeen := map[string]bool{}
	for _, noun := range PermissionNouns {
		w := permissionWords[noun]
		// A read no different from an earlier noun's (uv's read is
		// contents' read) is that noun's, not a level of this one.
		ownRead := w["read"] != "" && !readSeen[w["read"]]
		readSeen[w["read"]] = true
		switch {
		case w["write"] != "" && has(w["write"]):
			levels[noun] = "write"
			covered += w["write"] + w["read"]
		case ownRead && has(w["read"]):
			levels[noun] = "read"
			covered += w["read"]
		default:
			levels[noun] = "none"
		}
	}
	for _, c := range letters {
		if !strings.ContainsRune(covered, c) && !strings.ContainsRune(extra, c) {
			extra += string(c)
		}
	}
	return levels, extra
}

// 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
	}
	if err := unstrippable(doc); err != nil {
		return nil, err
	}
	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 err := unstrippable(job); err != nil {
			return nil, err
		}
		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
}

// unstrippable refuses a permissions: that stripping by the line would
// miss: one in a flow mapping, which shares its line with other keys,
// and one a merge key brings in from elsewhere in the file.
func unstrippable(m *yaml.Node) error {
	if m == nil || m.Kind != yaml.MappingNode {
		return nil
	}
	if p := value(m, "permissions"); p != nil && m.Style&yaml.FlowStyle != 0 {
		return fmt.Errorf("line %d, column %d: permissions: inside a flow-style mapping is not supported; write it in block style",
			p.Line, p.Column)
	}
	for i := 0; i+1 < len(m.Content); i += 2 {
		if k := m.Content[i]; k.Value == "<<" && merges(m.Content[i+1], "permissions") {
			return fmt.Errorf("line %d, column %d: permissions: brought in by a merge key is not supported; write it in the job",
				k.Line, k.Column)
		}
	}
	return nil
}

// merges reports whether a merge key's value carries key.
func merges(n *yaml.Node, key string) bool {
	switch n.Kind {
	case yaml.AliasNode:
		return merges(n.Alias, key)
	case yaml.SequenceNode:
		for _, c := range n.Content {
			if merges(c, key) {
				return true
			}
		}
	case yaml.MappingNode:
		for i := 0; i+1 < len(n.Content); i += 2 {
			if n.Content[i].Value == key || (n.Content[i].Value == "<<" && merges(n.Content[i+1], key)) {
				return true
			}
		}
	}
	return false
}

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