mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-07 09:42:05 +00:00
231ba1da19
Every `${{ }}` part was spliced as raw text into a synthesized
`format('...', <raw>)` call and re-parsed, so unbalanced parentheses
restructured the whole expression:
```yaml
run-name: ${{ 1) && (2 }} # panicked, aborting workflow parsing for the push
if: x ${{ 1) && (2 }} y # silently evaluated to 2
```
One scanner shaped like GitHub's template reader now splits every value
and each part is evaluated on its own, so nothing builds an expression
out of text. A part that fails is an error instead of an empty string,
making a `run-name`, `runs-on` or job name that GitHub rejects invalid
here too.
Replaces https://github.com/go-gitea/gitea/pull/38736 as the actual root-cause fix.
Signed-off-by: silverwind <me@silverwind.io>
269 lines
7.1 KiB
Go
269 lines
7.1 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package jobparser
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"gitea.com/gitea/runner/act/exprparser"
|
|
"go.yaml.in/yaml/v4"
|
|
)
|
|
|
|
// ExpressionEvaluator is copied from runner.expressionEvaluator,
|
|
// to avoid unnecessary dependencies
|
|
type ExpressionEvaluator struct {
|
|
interpreter exprparser.Interpreter
|
|
}
|
|
|
|
func NewExpressionEvaluator(interpreter exprparser.Interpreter) *ExpressionEvaluator {
|
|
return &ExpressionEvaluator{interpreter: interpreter}
|
|
}
|
|
|
|
func (ee ExpressionEvaluator) evaluateScalarYamlNode(node *yaml.Node) error {
|
|
var in string
|
|
if err := node.Decode(&in); err != nil {
|
|
return err
|
|
}
|
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
return nil
|
|
}
|
|
res, err := ee.evaluateScalar(in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return node.Encode(res)
|
|
}
|
|
|
|
// GitHub has this undocumented feature to merge maps, called insert directive
|
|
var insertDirective = regexp.MustCompile(`\${{\s*insert\s*}}`)
|
|
|
|
func (ee ExpressionEvaluator) evaluateMappingYamlNode(node *yaml.Node) error {
|
|
for i := 0; i < len(node.Content)/2; {
|
|
k := node.Content[i*2]
|
|
v := node.Content[i*2+1]
|
|
if err := ee.EvaluateYamlNode(v); err != nil {
|
|
return err
|
|
}
|
|
var sk string
|
|
// Merge the nested map of the insert directive
|
|
if k.Decode(&sk) == nil && insertDirective.MatchString(sk) {
|
|
node.Content = append(append(node.Content[:i*2], v.Content...), node.Content[(i+1)*2:]...)
|
|
i += len(v.Content) / 2
|
|
} else {
|
|
if err := ee.EvaluateYamlNode(k); err != nil {
|
|
return err
|
|
}
|
|
i++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ee ExpressionEvaluator) evaluateSequenceYamlNode(node *yaml.Node) error {
|
|
for i := 0; i < len(node.Content); {
|
|
v := node.Content[i]
|
|
// Preserve nested sequences
|
|
wasseq := v.Kind == yaml.SequenceNode
|
|
if err := ee.EvaluateYamlNode(v); err != nil {
|
|
return err
|
|
}
|
|
// GitHub has this undocumented feature to merge sequences / arrays
|
|
// We have a nested sequence via evaluation, merge the arrays
|
|
if v.Kind == yaml.SequenceNode && !wasseq {
|
|
node.Content = append(append(node.Content[:i], v.Content...), node.Content[i+1:]...)
|
|
i += len(v.Content)
|
|
} else {
|
|
i++
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (ee ExpressionEvaluator) EvaluateYamlNode(node *yaml.Node) error {
|
|
switch node.Kind {
|
|
case yaml.ScalarNode:
|
|
return ee.evaluateScalarYamlNode(node)
|
|
case yaml.MappingNode:
|
|
return ee.evaluateMappingYamlNode(node)
|
|
case yaml.SequenceNode:
|
|
return ee.evaluateSequenceYamlNode(node)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours
|
|
func (ee ExpressionEvaluator) interpolate(in string) (string, error) {
|
|
parts, err := splitSubExpressions(in)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(parts) == 1 && !parts[0].isExpr {
|
|
return in, nil
|
|
}
|
|
var out strings.Builder
|
|
out.Grow(len(in))
|
|
for _, part := range parts {
|
|
if !part.isExpr {
|
|
out.WriteString(part.text)
|
|
continue
|
|
}
|
|
evaluated, err := ee.interpreter.Evaluate(part.text, exprparser.DefaultStatusCheckNone)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
out.WriteString(coerceToString(evaluated))
|
|
}
|
|
return out.String(), nil
|
|
}
|
|
|
|
// evaluateScalar keeps the type of a lone expression, so `${{ fromJSON('[1,2]') }}` stays an array
|
|
func (ee ExpressionEvaluator) evaluateScalar(in string) (any, error) {
|
|
parts, err := splitSubExpressions(in)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(parts) == 1 && parts[0].isExpr {
|
|
return ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckNone)
|
|
}
|
|
return ee.interpolate(in)
|
|
}
|
|
|
|
// evaluateCondition evaluates an `if:`, an expression even without `${{ }}`. Mixed content
|
|
// interpolates to a string, so the success() default applies to it separately.
|
|
func (ee ExpressionEvaluator) evaluateCondition(in string) (bool, error) {
|
|
parts, err := splitSubExpressions(in)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if len(parts) == 1 {
|
|
evaluated, err := ee.interpreter.Evaluate(parts[0].text, exprparser.DefaultStatusCheckSuccess)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exprparser.IsTruthy(evaluated), nil
|
|
}
|
|
|
|
// mixed content is a string, so the success() default applies to it separately
|
|
if !expressionCallsFunction(in, "success", "always", "failure", "cancelled") {
|
|
status, err := ee.interpreter.Evaluate("success()", exprparser.DefaultStatusCheckNone)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !exprparser.IsTruthy(status) {
|
|
return false, nil
|
|
}
|
|
}
|
|
interpolated, err := ee.interpolate(in)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return exprparser.IsTruthy(interpolated), nil
|
|
}
|
|
|
|
// coerceToString converts an evaluated expression value to a string the way GitHub does,
|
|
// see https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators
|
|
// An already reflected value is accepted as-is, since Interface() would panic on an invalid one.
|
|
func coerceToString(v any) string {
|
|
value, ok := v.(reflect.Value)
|
|
if !ok {
|
|
value = reflect.ValueOf(v)
|
|
}
|
|
|
|
switch value.Kind() {
|
|
case reflect.Invalid:
|
|
return ""
|
|
|
|
case reflect.Bool:
|
|
return strconv.FormatBool(value.Bool())
|
|
|
|
case reflect.String:
|
|
return value.String()
|
|
|
|
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
|
return strconv.FormatInt(value.Int(), 10)
|
|
|
|
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
|
return strconv.FormatUint(value.Uint(), 10)
|
|
|
|
case reflect.Float32, reflect.Float64:
|
|
if math.IsInf(value.Float(), 1) {
|
|
return "Infinity"
|
|
} else if math.IsInf(value.Float(), -1) {
|
|
return "-Infinity"
|
|
}
|
|
return fmt.Sprintf("%.15G", value.Float())
|
|
|
|
case reflect.Slice, reflect.Array:
|
|
return "Array"
|
|
|
|
// contexts such as `github` are pointers to structs, so they stringify as objects too
|
|
case reflect.Map, reflect.Struct:
|
|
return "Object"
|
|
|
|
case reflect.Interface, reflect.Pointer:
|
|
if value.IsNil() {
|
|
return ""
|
|
}
|
|
return coerceToString(value.Elem())
|
|
}
|
|
|
|
return fmt.Sprintf("%v", value)
|
|
}
|
|
|
|
type exprPart struct {
|
|
text string
|
|
isExpr bool
|
|
}
|
|
|
|
// splitSubExpressions splits in the way GitHub's template reader does, leaving a value without a
|
|
// complete expression literal.
|
|
func splitSubExpressions(in string) ([]exprPart, error) {
|
|
if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") {
|
|
return []exprPart{{text: in}}, nil
|
|
}
|
|
|
|
parts := make([]exprPart, 0, 2*strings.Count(in, "${{")+1)
|
|
for {
|
|
start := strings.Index(in, "${{")
|
|
if start < 0 {
|
|
if in != "" {
|
|
parts = append(parts, exprPart{text: in})
|
|
}
|
|
return parts, nil
|
|
}
|
|
if start > 0 {
|
|
parts = append(parts, exprPart{text: in[:start]})
|
|
}
|
|
rest := in[start+len("${{"):]
|
|
end := indexExprEnd(rest)
|
|
if end < 0 {
|
|
return nil, errors.New("unclosed expression")
|
|
}
|
|
parts = append(parts, exprPart{text: strings.TrimSpace(rest[:end]), isExpr: true})
|
|
in = rest[end+len("}}"):]
|
|
}
|
|
}
|
|
|
|
// indexExprEnd returns the offset of the `}}` ending an expression, or -1. A quote toggles string
|
|
// state, so a `}}` inside a string does not end it.
|
|
func indexExprEnd(in string) int {
|
|
inString := false
|
|
for i := range len(in) {
|
|
switch {
|
|
case in[i] == '\'':
|
|
inString = !inString
|
|
case !inString && in[i] == '}' && i+1 < len(in) && in[i+1] == '}':
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|