diff --git a/act/runner/expression.go b/act/runner/expression.go index 87e157d2..08bfa459 100644 --- a/act/runner/expression.go +++ b/act/runner/expression.go @@ -7,7 +7,6 @@ package runner import ( "bytes" "context" - "errors" "fmt" "maps" "path" @@ -21,9 +20,9 @@ import ( _ "embed" + "gitea.dev/actionslib/pkg/expreval" "gitea.dev/actionslib/pkg/exprparser" "gitea.dev/actionslib/pkg/model" - "github.com/rhysd/actionlint" "go.yaml.in/yaml/v4" ) @@ -235,137 +234,16 @@ func (ee expressionEvaluator) evaluate(ctx context.Context, in string, defaultSt return evaluated, err } -func (ee expressionEvaluator) evaluateScalarYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { - var in string - if err := node.Decode(&in); err != nil { - return nil, err - } - if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { - return nil, nil //nolint:nilnil // pre-existing issue from nektos/act - } - res, err := ee.evaluateScalar(ctx, in) - if err != nil { - return nil, err - } - ret := &yaml.Node{} - if err := ret.Encode(res); err != nil { - return nil, err - } - return ret, err -} - -func (ee expressionEvaluator) evaluateMappingYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { - var ret *yaml.Node - // GitHub has this undocumented feature to merge maps, called insert directive - insertDirective := regexp.MustCompile(`\${{\s*insert\s*}}`) - for i := 0; i < len(node.Content)/2; i++ { - changed := func() error { - if ret == nil { - ret = &yaml.Node{} - if err := ret.Encode(node); err != nil { - return err - } - ret.Content = ret.Content[:i*2] - } - return nil - } - k := node.Content[i*2] - v := node.Content[i*2+1] - ev, err := ee.evaluateYamlNodeInternal(ctx, v) - if err != nil { - return nil, err - } - if ev != nil { - if err := changed(); err != nil { - return nil, err - } - } else { - ev = v - } - var sk string - // Merge the nested map of the insert directive - if k.Decode(&sk) == nil && insertDirective.MatchString(sk) { - if ev.Kind != yaml.MappingNode { - return nil, fmt.Errorf("failed to insert node %v into mapping %v unexpected type %v expected MappingNode", ev, node, ev.Kind) - } - if err := changed(); err != nil { - return nil, err - } - ret.Content = append(ret.Content, ev.Content...) - } else { - ek, err := ee.evaluateYamlNodeInternal(ctx, k) - if err != nil { - return nil, err - } - if ek != nil { - if err := changed(); err != nil { - return nil, err - } - } else { - ek = k - } - if ret != nil { - ret.Content = append(ret.Content, ek, ev) - } - } - } - return ret, nil -} - -func (ee expressionEvaluator) evaluateSequenceYamlNode(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { - var ret *yaml.Node - for i := 0; i < len(node.Content); i++ { - v := node.Content[i] - // Preserve nested sequences - wasseq := v.Kind == yaml.SequenceNode - ev, err := ee.evaluateYamlNodeInternal(ctx, v) - if err != nil { - return nil, err - } - if ev != nil { - if ret == nil { - ret = &yaml.Node{} - if err := ret.Encode(node); err != nil { - return nil, err - } - ret.Content = ret.Content[:i] - } - // GitHub has this undocumented feature to merge sequences / arrays - // We have a nested sequence via evaluation, merge the arrays - if ev.Kind == yaml.SequenceNode && !wasseq { - ret.Content = append(ret.Content, ev.Content...) - } else { - ret.Content = append(ret.Content, ev) - } - } else if ret != nil { - ret.Content = append(ret.Content, v) - } - } - return ret, nil -} - -func (ee expressionEvaluator) evaluateYamlNodeInternal(ctx context.Context, node *yaml.Node) (*yaml.Node, error) { - switch node.Kind { - case yaml.ScalarNode: - return ee.evaluateScalarYamlNode(ctx, node) - case yaml.MappingNode: - return ee.evaluateMappingYamlNode(ctx, node) - case yaml.SequenceNode: - return ee.evaluateSequenceYamlNode(ctx, node) - default: - return nil, nil //nolint:nilnil // pre-existing issue from nektos/act - } +// shared returns the evaluation layer of the shared library, bound to this context so the +// evaluation of every single expression is still logged and masked here. +func (ee expressionEvaluator) shared(ctx context.Context) expreval.Evaluator { + return expreval.New(func(in string, defaultStatusCheck exprparser.DefaultStatusCheck) (any, error) { + return ee.evaluate(ctx, in, defaultStatusCheck) + }) } func (ee expressionEvaluator) EvaluateYamlNode(ctx context.Context, node *yaml.Node) error { - ret, err := ee.evaluateYamlNodeInternal(ctx, node) - if err != nil { - return err - } - if ret != nil { - return ret.Decode(node) - } - return nil + return ee.shared(ctx).EvaluateYamlNode(node) } func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string { @@ -377,138 +255,16 @@ func (ee expressionEvaluator) Interpolate(ctx context.Context, in string) string return out } -// interpolate evaluates every part on its own, so a malformed one cannot restructure its neighbours func (ee expressionEvaluator) interpolate(ctx context.Context, 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.evaluate(ctx, part.text, exprparser.DefaultStatusCheckNone) - if err != nil { - return "", err - } - out.WriteString(exprparser.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(ctx context.Context, in string) (any, error) { - parts, err := splitSubExpressions(in) - if err != nil { - return nil, err - } - if len(parts) == 1 && parts[0].isExpr { - return ee.evaluate(ctx, parts[0].text, exprparser.DefaultStatusCheckNone) - } - return ee.interpolate(ctx, in) + return ee.shared(ctx).Interpolate(in) } // EvalBool evaluates an expression against given evaluator. An `if:` is an expression even without // `${{ }}`, while literal text around one makes the whole value a string. func EvalBool(ctx context.Context, evaluator ExpressionEvaluator, expr string, defaultStatusCheck exprparser.DefaultStatusCheck) (bool, error) { - parts, err := splitSubExpressions(expr) - if err != nil { - return false, err - } - if len(parts) == 1 { - evaluated, err := evaluator.evaluate(ctx, parts[0].text, defaultStatusCheck) - if err != nil { - return false, err - } - return exprparser.IsTruthy(evaluated), nil - } - - // mixed content is a string, so the status check applies to it separately - if defaultStatusCheck != exprparser.DefaultStatusCheckNone && !callsStatusFunction(parts) { - status, err := evaluator.evaluate(ctx, "", defaultStatusCheck) - if err != nil { - return false, err - } - if !exprparser.IsTruthy(status) { - return false, nil - } - } - interpolated, err := evaluator.interpolate(ctx, expr) - if err != nil { - return false, err - } - return exprparser.IsTruthy(interpolated), nil -} - -// callsStatusFunction reports whether any part calls a status function. A part that does not parse -// counts as one, so the evaluation reports it against the real values. -func callsStatusFunction(parts []exprPart) bool { - for _, part := range parts { - if !part.isExpr { - continue - } - // The lexer needs the closing `}}` that the scanner strips. - exprNode, err := actionlint.NewExprParser().Parse(actionlint.NewExprLexer(part.text + "}}")) - if err != nil || exprparser.CallsStatusFunction(exprNode) { - return true - } - } - return false -} - -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 + return expreval.New(func(in string, dsc exprparser.DefaultStatusCheck) (any, error) { + return evaluator.evaluate(ctx, in, dsc) + }).EvalBool(expr, defaultStatusCheck) } func getEvaluatorInputs(ctx context.Context, rc *RunContext, step step, ghc *model.GithubContext) map[string]any { diff --git a/act/runner/expression_test.go b/act/runner/expression_test.go index 5eea4fae..6a6e6906 100644 --- a/act/runner/expression_test.go +++ b/act/runner/expression_test.go @@ -278,41 +278,6 @@ func TestInterpolate(t *testing.T) { } } -func TestSplitSubExpressions(t *testing.T) { - expr := func(text string) exprPart { return exprPart{text: text, isExpr: true} } - literal := func(text string) exprPart { return exprPart{text: text} } - - for _, tt := range []struct { - in string - want []exprPart - }{ - {"Hello World", []exprPart{literal("Hello World")}}, - {"${{ true }}", []exprPart{expr("true")}}, - {"${{ true }} ${{ false }}", []exprPart{expr("true"), literal(" "), expr("false")}}, - {"Hello ${{ 'World' }}", []exprPart{literal("Hello "), expr("'World'")}}, - // a quote toggles string state, so a `}}` inside a string does not end the expression - {"${{ '}}' }}", []exprPart{expr("'}}'")}}, - {"${{ '''}}''' }}", []exprPart{expr("'''}}'''")}}, - {"${{ '''' }}", []exprPart{expr("''''")}}, - {`${{ fromJSON('"}}"') }}`, []exprPart{expr(`fromJSON('"}}"')`)}}, - {`${{ fromJSON('"\"}}\""') }}`, []exprPart{expr(`fromJSON('"\"}}\""')`)}}, - {`${{ fromJSON('"''}}"') }}`, []exprPart{expr(`fromJSON('"''}}"')`)}}, - // without a complete literal the value stays text, as GitHub's template reader leaves it - {"${{ 1", []exprPart{literal("${{ 1")}}, - // a malformed part stays one part, so it cannot restructure its neighbours - {"${{ 1) && (2 }}", []exprPart{expr("1) && (2")}}, - } { - got, err := splitSubExpressions(tt.in) - require.NoError(t, err, tt.in) - assert.Equal(t, tt.want, got, tt.in) - } - - for _, in := range []string{"${{ 'a' }} ${{ b", "${{ 'a }}"} { - _, err := splitSubExpressions(in) - assert.ErrorContains(t, err, "unclosed expression", in) - } -} - func TestGetEvaluatorInputsBoolean(t *testing.T) { workflows := map[string]string{ "workflow_call": `