Files
act_runner/pkg/schema/schema.go
silverwind b0ec3fa4fc fmt
2026-02-24 08:17:17 +01:00

736 lines
18 KiB
Go

package schema
import (
_ "embed"
"encoding/json"
"errors"
"fmt"
"math"
"regexp"
"slices"
"strconv"
"strings"
exprparser "gitea.com/gitea/act_runner/internal/expr"
"gopkg.in/yaml.v3"
)
//go:embed workflow_schema.json
var workflowSchema string
//go:embed action_schema.json
var actionSchema string
var functions = regexp.MustCompile(`^([a-zA-Z0-9_]+)\(([0-9]+),([0-9]+|MAX)\)$`)
type ValidationKind int
const (
ValidationKindFatal ValidationKind = iota
ValidationKindWarning
ValidationKindInvalidProperty
ValidationKindMismatched
ValidationKindMissingProperty
)
type Location struct {
Line int
Column int
}
type ValidationError struct {
Kind ValidationKind
Location
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("Line: %d Column %d: %s", e.Line, e.Column, e.Message)
}
type ValidationErrorCollection struct {
Errors []ValidationError
Collections []ValidationErrorCollection
}
func indent(builder *strings.Builder, in string) {
for v := range strings.SplitSeq(in, "\n") {
if v != "" {
builder.WriteString(" ")
builder.WriteString(v)
}
builder.WriteString("\n")
}
}
func (c ValidationErrorCollection) Error() string {
var builder strings.Builder
for _, e := range c.Errors {
if builder.Len() > 0 {
builder.WriteString("\n")
}
builder.WriteString(e.Error())
}
for _, e := range c.Collections {
if builder.Len() > 0 {
builder.WriteString("\n")
}
indent(&builder, e.Error())
}
return builder.String()
}
func (c *ValidationErrorCollection) AddError(err ValidationError) {
c.Errors = append(c.Errors, err)
}
func AsValidationErrorCollection(err error) *ValidationErrorCollection {
if col, ok := err.(ValidationErrorCollection); ok {
return &col
}
if col, ok := err.(*ValidationErrorCollection); ok {
return col
}
if e, ok := err.(ValidationError); ok {
return &ValidationErrorCollection{
Errors: []ValidationError{e},
}
}
if e, ok := err.(*ValidationError); ok {
return &ValidationErrorCollection{
Errors: []ValidationError{*e},
}
}
return nil
}
type Schema struct {
Definitions map[string]Definition
}
func (s *Schema) GetDefinition(name string) Definition {
def, ok := s.Definitions[name]
if !ok {
switch name {
case "any":
return Definition{OneOf: &[]string{"sequence", "mapping", "number", "boolean", "string", "null"}}
case "sequence":
return Definition{Sequence: &SequenceDefinition{ItemType: "any"}}
case "mapping":
return Definition{Mapping: &MappingDefinition{LooseKeyType: "any", LooseValueType: "any"}}
case "number":
return Definition{Number: &NumberDefinition{}}
case "string":
return Definition{String: &StringDefinition{}}
case "boolean":
return Definition{Boolean: &BooleanDefinition{}}
case "null":
return Definition{Null: &NullDefinition{}}
}
}
return def
}
type Definition struct {
Context []string `json:"context,omitempty"`
Mapping *MappingDefinition `json:"mapping,omitempty"`
Sequence *SequenceDefinition `json:"sequence,omitempty"`
OneOf *[]string `json:"one-of,omitempty"`
AllowedValues *[]string `json:"allowed-values,omitempty"`
String *StringDefinition `json:"string,omitempty"`
Number *NumberDefinition `json:"number,omitempty"`
Boolean *BooleanDefinition `json:"boolean,omitempty"`
Null *NullDefinition `json:"null,omitempty"`
}
type MappingDefinition struct {
Properties map[string]MappingProperty `json:"properties,omitempty"`
LooseKeyType string `json:"loose-key-type,omitempty"`
LooseValueType string `json:"loose-value-type,omitempty"`
}
type MappingProperty struct {
Type string `json:"type,omitempty"`
Required bool `json:"required,omitempty"`
}
func (s *MappingProperty) UnmarshalJSON(data []byte) error {
if json.Unmarshal(data, &s.Type) != nil {
type MProp MappingProperty
return json.Unmarshal(data, (*MProp)(s))
}
return nil
}
type SequenceDefinition struct {
ItemType string `json:"item-type"`
}
type StringDefinition struct {
Constant string `json:"constant,omitempty"`
IsExpression bool `json:"is-expression,omitempty"`
}
type NumberDefinition struct{}
type BooleanDefinition struct{}
type NullDefinition struct{}
func GetWorkflowSchema() *Schema {
sh := &Schema{}
_ = json.Unmarshal([]byte(workflowSchema), sh)
return sh
}
func GetActionSchema() *Schema {
sh := &Schema{}
_ = json.Unmarshal([]byte(actionSchema), sh)
return sh
}
type Node struct {
RestrictEval bool
Definition string
Schema *Schema
Context []string
}
type FunctionInfo interface {
GetName() string
Check(args []exprparser.Node) error
}
type BasicFunctionInfo struct {
Name string
Min int
Max int
}
func (f BasicFunctionInfo) GetName() string {
return f.Name
}
func (f BasicFunctionInfo) Check(args []exprparser.Node) error {
var err error
if f.Min > len(args) {
err = errors.Join(err, fmt.Errorf("missing parameters for %s expected >= %v got %v", f.Name, f.Min, len(args)))
}
if f.Max < len(args) {
err = errors.Join(err, fmt.Errorf("too many parameters for %s expected <= %v got %v", f.Name, f.Max, len(args)))
}
return err
}
type OddFunctionInfo struct {
BasicFunctionInfo
}
func (f OddFunctionInfo) Check(args []exprparser.Node) error {
var err error
if len(args)%2 == 0 {
err = errors.Join(err, fmt.Errorf("expected odd number of parameters for %s got %v", f.Name, len(args)))
}
return errors.Join(err, f.BasicFunctionInfo.Check(args))
}
func (s *Node) checkSingleExpression(exprNode exprparser.Node) error {
if len(s.Context) == 0 {
switch exprNode.(type) {
case *exprparser.ValueNode:
return nil
default:
return errors.New("expressions are not allowed here")
}
}
funcs := s.GetFunctions()
var err error
exprparser.VisitNode(exprNode, func(node exprparser.Node) {
if funcCallNode, ok := node.(*exprparser.FunctionNode); ok {
for _, v := range funcs {
if strings.EqualFold(funcCallNode.Name, v.GetName()) {
err = v.Check(funcCallNode.Args)
return
}
}
err = errors.Join(err, fmt.Errorf("unknown Function Call %s", funcCallNode.Name))
}
if varNode, ok := node.(*exprparser.ValueNode); ok && varNode.Kind == exprparser.TokenKindNamedValue {
if str, ok := varNode.Value.(string); ok {
for _, v := range s.Context {
if strings.EqualFold(str, v) {
return
}
}
}
err = errors.Join(err, fmt.Errorf("unknown Variable Access %v", varNode.Value))
}
})
return err
}
func (s *Node) GetFunctions() []FunctionInfo {
funcs := []FunctionInfo{}
AddFunction(&funcs, "contains", 2, 2)
AddFunction(&funcs, "endsWith", 2, 2)
AddFunction(&funcs, "format", 1, 255)
AddFunction(&funcs, "join", 1, 2)
AddFunction(&funcs, "startsWith", 2, 2)
AddFunction(&funcs, "toJson", 1, 1)
AddFunction(&funcs, "fromJson", 1, 1)
funcs = append(funcs, &OddFunctionInfo{
BasicFunctionInfo: BasicFunctionInfo{
Name: "case",
Min: 3,
Max: 255,
},
})
for _, v := range s.Context {
found := strings.Contains(v, "(")
if !found {
continue
}
smatch := functions.FindStringSubmatch(v)
if len(smatch) > 0 {
functionName := smatch[1]
minParameters, _ := strconv.ParseInt(smatch[2], 10, 32)
maxParametersRaw := smatch[3]
var maxParameters int64
if strings.EqualFold(maxParametersRaw, "MAX") {
maxParameters = math.MaxInt32
} else {
maxParameters, _ = strconv.ParseInt(maxParametersRaw, 10, 32)
}
funcs = append(funcs, &BasicFunctionInfo{
Name: functionName,
Min: int(minParameters),
Max: int(maxParameters),
})
}
}
return funcs
}
func exprEnd(expr string) int {
var inQuotes bool
for i, v := range expr {
if v == '\'' {
inQuotes = !inQuotes
} else if !inQuotes && i+1 < len(expr) && expr[i:i+2] == "}}" {
return i
}
}
return -1
}
func (s *Node) checkExpression(node *yaml.Node) (bool, error) {
if s.RestrictEval {
return false, nil
}
val := node.Value
hadExpr := false
var err error
for {
if i := strings.Index(val, "${{"); i != -1 {
val = val[i+3:]
} else {
return hadExpr, err
}
hadExpr = true
j := exprEnd(val)
exprNode, parseErr := exprparser.Parse(val[:j])
if parseErr != nil {
err = errors.Join(err, ValidationError{
Location: toLocation(node),
Message: "failed to parse: " + parseErr.Error(),
})
continue
}
val = val[j+2:]
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
err = errors.Join(err, ValidationError{
Location: toLocation(node),
Message: cerr.Error(),
})
}
}
}
func AddFunction(funcs *[]FunctionInfo, s string, i1, i2 int) {
*funcs = append(*funcs, &BasicFunctionInfo{
Name: s,
Min: i1,
Max: i2,
})
}
func (s *Node) UnmarshalYAML(node *yaml.Node) error {
if node != nil && node.Kind == yaml.DocumentNode {
return s.UnmarshalYAML(node.Content[0])
}
def := s.Schema.GetDefinition(s.Definition)
if s.Context == nil {
s.Context = def.Context
}
isExpr, err := s.checkExpression(node)
if err != nil {
return err
}
if isExpr {
return nil
}
if def.Mapping != nil {
return s.checkMapping(node, def)
} else if def.Sequence != nil {
return s.checkSequence(node, def)
} else if def.OneOf != nil {
return s.checkOneOf(def, node)
}
if err := assertKind(node, yaml.ScalarNode); err != nil {
return err
}
if def.String != nil {
return s.checkString(node, def)
} else if def.Number != nil {
var num float64
return node.Decode(&num)
} else if def.Boolean != nil {
var b bool
return node.Decode(&b)
} else if def.AllowedValues != nil {
s := node.Value
if slices.Contains(*def.AllowedValues, s) {
return nil
}
return ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("expected one of %s got %s", strings.Join(*def.AllowedValues, ","), s),
}
} else if def.Null != nil {
var myNull *byte
if err := node.Decode(&myNull); err != nil {
return err
}
if myNull != nil {
return ValidationError{
Location: toLocation(node),
Message: "invalid Null",
}
}
return nil
}
return errors.ErrUnsupported
}
func (s *Node) checkString(node *yaml.Node, def Definition) error {
// caller checks node type
val := node.Value
if def.String.Constant != "" && def.String.Constant != val {
return ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("expected %s got %s", def.String.Constant, val),
}
}
if def.String.IsExpression && !s.RestrictEval {
exprNode, parseErr := exprparser.Parse(node.Value)
if parseErr != nil {
return ValidationError{
Location: toLocation(node),
Message: "failed to parse: " + parseErr.Error(),
}
}
cerr := s.checkSingleExpression(exprNode)
if cerr != nil {
return ValidationError{
Location: toLocation(node),
Message: cerr.Error(),
}
}
}
return nil
}
func (s *Node) checkOneOf(def Definition, node *yaml.Node) error {
invalidProps := math.MaxInt
var bestMatches ValidationErrorCollection
for _, v := range *def.OneOf {
// Use helper to create child node
sub := s.childNode(v)
err := sub.UnmarshalYAML(node)
if err == nil {
return nil
}
if col := AsValidationErrorCollection(err); col != nil {
var matched int
for _, e := range col.Errors {
if e.Kind == ValidationKindInvalidProperty {
matched++
}
if e.Kind == ValidationKindMismatched {
if math.MaxInt == invalidProps {
bestMatches.Collections = append(bestMatches.Collections, *col)
continue
}
}
}
if matched <= invalidProps {
if matched < invalidProps {
// clear, we have better matching ones
bestMatches.Collections = nil
}
bestMatches.Collections = append(bestMatches.Collections, *col)
invalidProps = matched
}
continue
}
bestMatches.Errors = append(bestMatches.Errors, ValidationError{
Location: toLocation(node),
Message: fmt.Sprintf("failed to match %s: %s", v, err.Error()),
})
}
if len(bestMatches.Errors) > 0 || len(bestMatches.Collections) > 0 {
return bestMatches
}
return nil
}
func getStringKind(k yaml.Kind) string {
switch k {
case yaml.DocumentNode:
return "document"
case yaml.SequenceNode:
return "sequence"
case yaml.MappingNode:
return "mapping"
case yaml.ScalarNode:
return "scalar"
case yaml.AliasNode:
return "alias"
default:
return "unknown"
}
}
func (s *Node) checkSequence(node *yaml.Node, def Definition) error {
if err := assertKind(node, yaml.SequenceNode); err != nil {
return err
}
var allErrors error
for _, v := range node.Content {
// Use helper to create child node
child := s.childNode(def.Sequence.ItemType)
allErrors = errors.Join(allErrors, child.UnmarshalYAML(v))
}
return allErrors
}
func toLocation(node *yaml.Node) Location {
return Location{Line: node.Line, Column: node.Column}
}
func assertKind(node *yaml.Node, kind yaml.Kind) error {
if node.Kind != kind {
return ValidationError{
Location: toLocation(node),
Kind: ValidationKindMismatched,
Message: fmt.Sprintf("expected a %s got %s", getStringKind(kind), getStringKind(node.Kind)),
}
}
return nil
}
func (s *Node) GetNestedNode(path ...string) *Node {
if len(path) == 0 {
return s
}
def := s.Schema.GetDefinition(s.Definition)
if def.Mapping != nil {
prop, ok := def.Mapping.Properties[path[0]]
if !ok {
if def.Mapping.LooseValueType == "" {
return nil
}
return s.childNode(def.Mapping.LooseValueType).GetNestedNode(path[1:]...)
}
return s.childNode(prop.Type).GetNestedNode(path[1:]...)
}
if def.Sequence != nil {
// OneOf Branching
if path[0] != "*" {
return nil
}
return s.childNode(def.Sequence.ItemType).GetNestedNode(path[1:]...)
}
if def.OneOf != nil {
for _, one := range *def.OneOf {
opt := s.childNode(one).GetNestedNode(path...)
if opt != nil {
return opt
}
}
return nil
}
return nil
}
func (s *Node) checkMapping(node *yaml.Node, def Definition) error {
if err := assertKind(node, yaml.MappingNode); err != nil {
return err
}
insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`)
var allErrors ValidationErrorCollection
var hasKeyExpr bool
usedProperties := map[string]string{}
for i, k := range node.Content {
if i%2 == 0 {
if insertDirective.MatchString(k.Value) {
if len(s.Context) == 0 {
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: "insert is not allowed here",
})
}
hasKeyExpr = true
continue
}
isExpr, err := s.checkExpression(k)
if err != nil {
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: err.Error(),
})
hasKeyExpr = true
continue
}
if isExpr {
hasKeyExpr = true
continue
}
if org, ok := usedProperties[strings.ToLower(k.Value)]; !ok {
// duplicate check case insensitive
usedProperties[strings.ToLower(k.Value)] = k.Value
// schema check case sensitive
usedProperties[k.Value] = k.Value
} else {
allErrors.AddError(ValidationError{
// Kind: ValidationKindInvalidProperty,
Location: toLocation(node),
Message: fmt.Sprintf("duplicate property %v of %v", k.Value, org),
})
}
vdef, ok := def.Mapping.Properties[k.Value]
if !ok {
if def.Mapping.LooseValueType == "" {
allErrors.AddError(ValidationError{
Kind: ValidationKindInvalidProperty,
Location: toLocation(node),
Message: fmt.Sprintf("unknown property %v", k.Value),
})
continue
}
vdef = MappingProperty{Type: def.Mapping.LooseValueType}
}
// Use helper to create child node
child := s.childNode(vdef.Type)
if err := child.UnmarshalYAML(node.Content[i+1]); err != nil {
if col := AsValidationErrorCollection(err); col != nil {
allErrors.AddError(ValidationError{
Location: toLocation(node.Content[i+1]),
Message: "error found in value of key " + k.Value,
})
allErrors.Collections = append(allErrors.Collections, *col)
continue
}
allErrors.AddError(ValidationError{
Location: toLocation(node),
Message: err.Error(),
})
continue
}
}
}
if !hasKeyExpr {
for k, v := range def.Mapping.Properties {
if _, ok := usedProperties[k]; !ok && v.Required {
allErrors.AddError(ValidationError{
Location: toLocation(node),
Kind: ValidationKindMissingProperty,
Message: "missing property " + k,
})
}
}
}
if len(allErrors.Errors) == 0 && len(allErrors.Collections) == 0 {
return nil
}
return allErrors
}
func (s *Node) childNode(defName string) *Node {
return &Node{
RestrictEval: s.RestrictEval,
Definition: defName,
Schema: s.Schema,
Context: append(append([]string{}, s.Context...), s.Schema.GetDefinition(defName).Context...),
}
}
func (s *Node) GetVariables() []string {
// Return only variable names (exclude function signatures)
vars := []string{}
for _, v := range s.Context {
if !strings.Contains(v, "(") {
vars = append(vars, v)
}
}
return vars
}
// ValidateExpression checks whether all variables and functions used in the expressions
// inside the provided yaml.Node are present in the allowed sets. It returns false
// if any variable or function is missing.
func (s *Node) ValidateExpression(node *yaml.Node, allowedVars map[string]struct{}, allowedFuncs map[string]struct{}) bool {
val := node.Value
for {
i := strings.Index(val, "${{")
if i == -1 {
break
}
val = val[i+3:]
j := exprEnd(val)
exprNode, parseErr := exprparser.Parse(val[:j])
if parseErr != nil {
return false
}
val = val[j+2:]
// walk expression tree
exprparser.VisitNode(exprNode, func(n exprparser.Node) {
switch el := n.(type) {
case *exprparser.FunctionNode:
if _, ok := allowedFuncs[el.Name]; !ok {
// missing function
// use a panic to break out
panic("missing function")
}
case *exprparser.ValueNode:
if el.Kind == exprparser.TokenKindNamedValue {
if _, ok := allowedVars[el.Value.(string)]; !ok {
panic("missing variable")
}
}
}
})
}
return true
}