summaryrefslogtreecommitdiffstats
path: root/pkg/exprparser
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/exprparser')
-rw-r--r--pkg/exprparser/functions.go295
-rw-r--r--pkg/exprparser/functions_test.go251
-rw-r--r--pkg/exprparser/interpreter.go642
-rw-r--r--pkg/exprparser/interpreter_test.go627
-rw-r--r--pkg/exprparser/testdata/for-hashing-1.txt1
-rw-r--r--pkg/exprparser/testdata/for-hashing-2.txt1
-rw-r--r--pkg/exprparser/testdata/for-hashing-3/data.txt1
-rw-r--r--pkg/exprparser/testdata/for-hashing-3/nested/nested-data.txt1
8 files changed, 1819 insertions, 0 deletions
diff --git a/pkg/exprparser/functions.go b/pkg/exprparser/functions.go
new file mode 100644
index 0000000..83b2a08
--- /dev/null
+++ b/pkg/exprparser/functions.go
@@ -0,0 +1,295 @@
+package exprparser
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+
+ "github.com/go-git/go-git/v5/plumbing/format/gitignore"
+
+ "github.com/nektos/act/pkg/model"
+ "github.com/rhysd/actionlint"
+)
+
+func (impl *interperterImpl) contains(search, item reflect.Value) (bool, error) {
+ switch search.Kind() {
+ case reflect.String, reflect.Int, reflect.Float64, reflect.Bool, reflect.Invalid:
+ return strings.Contains(
+ strings.ToLower(impl.coerceToString(search).String()),
+ strings.ToLower(impl.coerceToString(item).String()),
+ ), nil
+
+ case reflect.Slice:
+ for i := 0; i < search.Len(); i++ {
+ arrayItem := search.Index(i).Elem()
+ result, err := impl.compareValues(arrayItem, item, actionlint.CompareOpNodeKindEq)
+ if err != nil {
+ return false, err
+ }
+
+ if isEqual, ok := result.(bool); ok && isEqual {
+ return true, nil
+ }
+ }
+ }
+
+ return false, nil
+}
+
+func (impl *interperterImpl) startsWith(searchString, searchValue reflect.Value) (bool, error) {
+ return strings.HasPrefix(
+ strings.ToLower(impl.coerceToString(searchString).String()),
+ strings.ToLower(impl.coerceToString(searchValue).String()),
+ ), nil
+}
+
+func (impl *interperterImpl) endsWith(searchString, searchValue reflect.Value) (bool, error) {
+ return strings.HasSuffix(
+ strings.ToLower(impl.coerceToString(searchString).String()),
+ strings.ToLower(impl.coerceToString(searchValue).String()),
+ ), nil
+}
+
+const (
+ passThrough = iota
+ bracketOpen
+ bracketClose
+)
+
+func (impl *interperterImpl) format(str reflect.Value, replaceValue ...reflect.Value) (string, error) {
+ input := impl.coerceToString(str).String()
+ output := ""
+ replacementIndex := ""
+
+ state := passThrough
+ for _, character := range input {
+ switch state {
+ case passThrough: // normal buffer output
+ switch character {
+ case '{':
+ state = bracketOpen
+
+ case '}':
+ state = bracketClose
+
+ default:
+ output += string(character)
+ }
+
+ case bracketOpen: // found {
+ switch character {
+ case '{':
+ output += "{"
+ replacementIndex = ""
+ state = passThrough
+
+ case '}':
+ index, err := strconv.ParseInt(replacementIndex, 10, 32)
+ if err != nil {
+ return "", fmt.Errorf("The following format string is invalid: '%s'", input)
+ }
+
+ replacementIndex = ""
+
+ if len(replaceValue) <= int(index) {
+ return "", fmt.Errorf("The following format string references more arguments than were supplied: '%s'", input)
+ }
+
+ output += impl.coerceToString(replaceValue[index]).String()
+
+ state = passThrough
+
+ default:
+ replacementIndex += string(character)
+ }
+
+ case bracketClose: // found }
+ switch character {
+ case '}':
+ output += "}"
+ replacementIndex = ""
+ state = passThrough
+
+ default:
+ panic("Invalid format parser state")
+ }
+ }
+ }
+
+ if state != passThrough {
+ switch state {
+ case bracketOpen:
+ return "", fmt.Errorf("Unclosed brackets. The following format string is invalid: '%s'", input)
+
+ case bracketClose:
+ return "", fmt.Errorf("Closing bracket without opening one. The following format string is invalid: '%s'", input)
+ }
+ }
+
+ return output, nil
+}
+
+func (impl *interperterImpl) join(array reflect.Value, sep reflect.Value) (string, error) {
+ separator := impl.coerceToString(sep).String()
+ switch array.Kind() {
+ case reflect.Slice:
+ var items []string
+ for i := 0; i < array.Len(); i++ {
+ items = append(items, impl.coerceToString(array.Index(i).Elem()).String())
+ }
+
+ return strings.Join(items, separator), nil
+ default:
+ return strings.Join([]string{impl.coerceToString(array).String()}, separator), nil
+ }
+}
+
+func (impl *interperterImpl) toJSON(value reflect.Value) (string, error) {
+ if value.Kind() == reflect.Invalid {
+ return "null", nil
+ }
+
+ json, err := json.MarshalIndent(value.Interface(), "", " ")
+ if err != nil {
+ return "", fmt.Errorf("Cannot convert value to JSON. Cause: %v", err)
+ }
+
+ return string(json), nil
+}
+
+func (impl *interperterImpl) fromJSON(value reflect.Value) (interface{}, error) {
+ if value.Kind() != reflect.String {
+ return nil, fmt.Errorf("Cannot parse non-string type %v as JSON", value.Kind())
+ }
+
+ var data interface{}
+
+ err := json.Unmarshal([]byte(value.String()), &data)
+ if err != nil {
+ return nil, fmt.Errorf("Invalid JSON: %v", err)
+ }
+
+ return data, nil
+}
+
+func (impl *interperterImpl) hashFiles(paths ...reflect.Value) (string, error) {
+ var ps []gitignore.Pattern
+
+ const cwdPrefix = "." + string(filepath.Separator)
+ const excludeCwdPrefix = "!" + cwdPrefix
+ for _, path := range paths {
+ if path.Kind() == reflect.String {
+ cleanPath := path.String()
+ if strings.HasPrefix(cleanPath, cwdPrefix) {
+ cleanPath = cleanPath[len(cwdPrefix):]
+ } else if strings.HasPrefix(cleanPath, excludeCwdPrefix) {
+ cleanPath = "!" + cleanPath[len(excludeCwdPrefix):]
+ }
+ ps = append(ps, gitignore.ParsePattern(cleanPath, nil))
+ } else {
+ return "", fmt.Errorf("Non-string path passed to hashFiles")
+ }
+ }
+
+ matcher := gitignore.NewMatcher(ps)
+
+ var files []string
+ if err := filepath.Walk(impl.config.WorkingDir, func(path string, fi fs.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ sansPrefix := strings.TrimPrefix(path, impl.config.WorkingDir+string(filepath.Separator))
+ parts := strings.Split(sansPrefix, string(filepath.Separator))
+ if fi.IsDir() || !matcher.Match(parts, fi.IsDir()) {
+ return nil
+ }
+ files = append(files, path)
+ return nil
+ }); err != nil {
+ return "", fmt.Errorf("Unable to filepath.Walk: %v", err)
+ }
+
+ if len(files) == 0 {
+ return "", nil
+ }
+
+ hasher := sha256.New()
+
+ for _, file := range files {
+ f, err := os.Open(file)
+ if err != nil {
+ return "", fmt.Errorf("Unable to os.Open: %v", err)
+ }
+
+ if _, err := io.Copy(hasher, f); err != nil {
+ return "", fmt.Errorf("Unable to io.Copy: %v", err)
+ }
+
+ if err := f.Close(); err != nil {
+ return "", fmt.Errorf("Unable to Close file: %v", err)
+ }
+ }
+
+ return hex.EncodeToString(hasher.Sum(nil)), nil
+}
+
+func (impl *interperterImpl) getNeedsTransitive(job *model.Job) []string {
+ needs := job.Needs()
+
+ for _, need := range needs {
+ parentNeeds := impl.getNeedsTransitive(impl.config.Run.Workflow.GetJob(need))
+ needs = append(needs, parentNeeds...)
+ }
+
+ return needs
+}
+
+func (impl *interperterImpl) always() (bool, error) {
+ return true, nil
+}
+
+func (impl *interperterImpl) jobSuccess() (bool, error) {
+ jobs := impl.config.Run.Workflow.Jobs
+ jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
+
+ for _, needs := range jobNeeds {
+ if jobs[needs].Result != "success" {
+ return false, nil
+ }
+ }
+
+ return true, nil
+}
+
+func (impl *interperterImpl) stepSuccess() (bool, error) {
+ return impl.env.Job.Status == "success", nil
+}
+
+func (impl *interperterImpl) jobFailure() (bool, error) {
+ jobs := impl.config.Run.Workflow.Jobs
+ jobNeeds := impl.getNeedsTransitive(impl.config.Run.Job())
+
+ for _, needs := range jobNeeds {
+ if jobs[needs].Result == "failure" {
+ return true, nil
+ }
+ }
+
+ return false, nil
+}
+
+func (impl *interperterImpl) stepFailure() (bool, error) {
+ return impl.env.Job.Status == "failure", nil
+}
+
+func (impl *interperterImpl) cancelled() (bool, error) {
+ return impl.env.Job.Status == "cancelled", nil
+}
diff --git a/pkg/exprparser/functions_test.go b/pkg/exprparser/functions_test.go
new file mode 100644
index 0000000..ea51a2b
--- /dev/null
+++ b/pkg/exprparser/functions_test.go
@@ -0,0 +1,251 @@
+package exprparser
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/nektos/act/pkg/model"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFunctionContains(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"contains('search', 'item') }}", false, "contains-str-str"},
+ {`cOnTaInS('Hello', 'll') }}`, true, "contains-str-casing"},
+ {`contains('HELLO', 'll') }}`, true, "contains-str-casing"},
+ {`contains('3.141592', 3.14) }}`, true, "contains-str-number"},
+ {`contains(3.141592, '3.14') }}`, true, "contains-number-str"},
+ {`contains(3.141592, 3.14) }}`, true, "contains-number-number"},
+ {`contains(true, 'u') }}`, true, "contains-bool-str"},
+ {`contains(null, '') }}`, true, "contains-null-str"},
+ {`contains(fromJSON('["first","second"]'), 'first') }}`, true, "contains-item"},
+ {`contains(fromJSON('[null,"second"]'), '') }}`, true, "contains-item-null-empty-str"},
+ {`contains(fromJSON('["","second"]'), null) }}`, true, "contains-item-empty-str-null"},
+ {`contains(fromJSON('[true,"second"]'), 'true') }}`, false, "contains-item-bool-arr"},
+ {`contains(fromJSON('["true","second"]'), true) }}`, false, "contains-item-str-bool"},
+ {`contains(fromJSON('[3.14,"second"]'), '3.14') }}`, true, "contains-item-number-str"},
+ {`contains(fromJSON('[3.14,"second"]'), 3.14) }}`, true, "contains-item-number-number"},
+ {`contains(fromJSON('["","second"]'), fromJSON('[]')) }}`, false, "contains-item-str-arr"},
+ {`contains(fromJSON('["","second"]'), fromJSON('{}')) }}`, false, "contains-item-str-obj"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionStartsWith(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"startsWith('search', 'se') }}", true, "startswith-string"},
+ {"startsWith('search', 'sa') }}", false, "startswith-string"},
+ {"startsWith('123search', '123s') }}", true, "startswith-string"},
+ {"startsWith(123, 's') }}", false, "startswith-string"},
+ {"startsWith(123, '12') }}", true, "startswith-string"},
+ {"startsWith('123', 12) }}", true, "startswith-string"},
+ {"startsWith(null, '42') }}", false, "startswith-string"},
+ {"startsWith('null', null) }}", true, "startswith-string"},
+ {"startsWith('null', '') }}", true, "startswith-string"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionEndsWith(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"endsWith('search', 'ch') }}", true, "endsWith-string"},
+ {"endsWith('search', 'sa') }}", false, "endsWith-string"},
+ {"endsWith('search123s', '123s') }}", true, "endsWith-string"},
+ {"endsWith(123, 's') }}", false, "endsWith-string"},
+ {"endsWith(123, '23') }}", true, "endsWith-string"},
+ {"endsWith('123', 23) }}", true, "endsWith-string"},
+ {"endsWith(null, '42') }}", false, "endsWith-string"},
+ {"endsWith('null', null) }}", true, "endsWith-string"},
+ {"endsWith('null', '') }}", true, "endsWith-string"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionJoin(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"join(fromJSON('[\"a\", \"b\"]'), ',')", "a,b", "join-arr"},
+ {"join('string', ',')", "string", "join-str"},
+ {"join(1, ',')", "1", "join-number"},
+ {"join(null, ',')", "", "join-number"},
+ {"join(fromJSON('[\"a\", \"b\", null]'), null)", "ab", "join-number"},
+ {"join(fromJSON('[\"a\", \"b\"]'))", "a,b", "join-number"},
+ {"join(fromJSON('[\"a\", \"b\", null]'), 1)", "a1b1", "join-number"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionToJSON(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"toJSON(env) }}", "{\n \"key\": \"value\"\n}", "toJSON"},
+ {"toJSON(null)", "null", "toJSON-null"},
+ }
+
+ env := &EvaluationEnvironment{
+ Env: map[string]string{
+ "key": "value",
+ },
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionFromJSON(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"fromJSON('{\"foo\":\"bar\"}') }}", map[string]interface{}{
+ "foo": "bar",
+ }, "fromJSON"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionHashFiles(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"hashFiles('**/non-extant-files') }}", "", "hash-non-existing-file"},
+ {"hashFiles('**/non-extant-files', '**/more-non-extant-files') }}", "", "hash-multiple-non-existing-files"},
+ {"hashFiles('./for-hashing-1.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-single-file"},
+ {"hashFiles('./for-hashing-*.txt') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"},
+ {"hashFiles('./for-hashing-*.txt', '!./for-hashing-2.txt') }}", "66a045b452102c59d840ec097d59d9467e13a3f34f6494e539ffd32c1bb35f18", "hash-negative-pattern"},
+ {"hashFiles('./for-hashing-**') }}", "c418ba693753c84115ced0da77f876cddc662b9054f4b129b90f822597ee2f94", "hash-multiple-files-and-directories"},
+ {"hashFiles('./for-hashing-3/**') }}", "6f5696b546a7a9d6d42a449dc9a56bef244aaa826601ef27466168846139d2c2", "hash-nested-directories"},
+ {"hashFiles('./for-hashing-3/**/nested-data.txt') }}", "8ecadfb49f7f978d0a9f3a957e9c8da6cc9ab871f5203b5d9f9d1dc87d8af18c", "hash-nested-directories-2"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ workdir, err := filepath.Abs("testdata")
+ assert.Nil(t, err)
+ output, err := NewInterpeter(env, Config{WorkingDir: workdir}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestFunctionFormat(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ error interface{}
+ name string
+ }{
+ {"format('text')", "text", nil, "format-plain-string"},
+ {"format('Hello {0} {1} {2}!', 'Mona', 'the', 'Octocat')", "Hello Mona the Octocat!", nil, "format-with-placeholders"},
+ {"format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')", "{Hello Mona the Octocat!}", nil, "format-with-escaped-braces"},
+ {"format('{{0}}', 'test')", "{0}", nil, "format-with-escaped-braces"},
+ {"format('{{{0}}}', 'test')", "{test}", nil, "format-with-escaped-braces-and-value"},
+ {"format('}}')", "}", nil, "format-output-closing-brace"},
+ {`format('Hello "{0}" {1} {2} {3} {4}', null, true, -3.14, NaN, Infinity)`, `Hello "" true -3.14 NaN Infinity`, nil, "format-with-primitives"},
+ {`format('Hello "{0}" {1} {2}', fromJSON('[0, true, "abc"]'), fromJSON('[{"a":1}]'), fromJSON('{"a":{"b":1}}'))`, `Hello "Array" Array Object`, nil, "format-with-complex-types"},
+ {"format(true)", "true", nil, "format-with-primitive-args"},
+ {"format('echo Hello {0} ${{Test}}', github.undefined_property)", "echo Hello ${Test}", nil, "format-with-undefined-value"},
+ {"format('{0}}', '{1}', 'World')", nil, "Closing bracket without opening one. The following format string is invalid: '{0}}'", "format-invalid-format-string"},
+ {"format('{0', '{1}', 'World')", nil, "Unclosed brackets. The following format string is invalid: '{0'", "format-invalid-format-string"},
+ {"format('{2}', '{1}', 'World')", "", "The following format string references more arguments than were supplied: '{2}'", "format-invalid-replacement-reference"},
+ {"format('{2147483648}')", "", "The following format string is invalid: '{2147483648}'", "format-invalid-replacement-reference"},
+ {"format('{0} {1} {2} {3}', 1.0, 1.1, 1234567890.0, 12345678901234567890.0)", "1 1.1 1234567890 1.23456789012346E+19", nil, "format-floats"},
+ }
+
+ env := &EvaluationEnvironment{
+ Github: &model.GithubContext{},
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ if tt.error != nil {
+ assert.Equal(t, tt.error, err.Error())
+ } else {
+ assert.Nil(t, err)
+ assert.Equal(t, tt.expected, output)
+ }
+ })
+ }
+}
diff --git a/pkg/exprparser/interpreter.go b/pkg/exprparser/interpreter.go
new file mode 100644
index 0000000..29c5686
--- /dev/null
+++ b/pkg/exprparser/interpreter.go
@@ -0,0 +1,642 @@
+package exprparser
+
+import (
+ "encoding"
+ "fmt"
+ "math"
+ "reflect"
+ "strings"
+
+ "github.com/nektos/act/pkg/model"
+ "github.com/rhysd/actionlint"
+)
+
+type EvaluationEnvironment struct {
+ Github *model.GithubContext
+ Env map[string]string
+ Job *model.JobContext
+ Jobs *map[string]*model.WorkflowCallResult
+ Steps map[string]*model.StepResult
+ Runner map[string]interface{}
+ Secrets map[string]string
+ Vars map[string]string
+ Strategy map[string]interface{}
+ Matrix map[string]interface{}
+ Needs map[string]Needs
+ Inputs map[string]interface{}
+ HashFiles func([]reflect.Value) (interface{}, error)
+}
+
+type Needs struct {
+ Outputs map[string]string `json:"outputs"`
+ Result string `json:"result"`
+}
+
+type Config struct {
+ Run *model.Run
+ WorkingDir string
+ Context string
+}
+
+type DefaultStatusCheck int
+
+const (
+ DefaultStatusCheckNone DefaultStatusCheck = iota
+ DefaultStatusCheckSuccess
+ DefaultStatusCheckAlways
+ DefaultStatusCheckCanceled
+ DefaultStatusCheckFailure
+)
+
+func (dsc DefaultStatusCheck) String() string {
+ switch dsc {
+ case DefaultStatusCheckSuccess:
+ return "success"
+ case DefaultStatusCheckAlways:
+ return "always"
+ case DefaultStatusCheckCanceled:
+ return "cancelled"
+ case DefaultStatusCheckFailure:
+ return "failure"
+ }
+ return ""
+}
+
+type Interpreter interface {
+ Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (interface{}, error)
+}
+
+type interperterImpl struct {
+ env *EvaluationEnvironment
+ config Config
+}
+
+func NewInterpeter(env *EvaluationEnvironment, config Config) Interpreter {
+ return &interperterImpl{
+ env: env,
+ config: config,
+ }
+}
+
+func (impl *interperterImpl) Evaluate(input string, defaultStatusCheck DefaultStatusCheck) (interface{}, error) {
+ input = strings.TrimPrefix(input, "${{")
+ if defaultStatusCheck != DefaultStatusCheckNone && input == "" {
+ input = "success()"
+ }
+ parser := actionlint.NewExprParser()
+ exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}"))
+ if err != nil {
+ return nil, fmt.Errorf("Failed to parse: %s", err.Message)
+ }
+
+ if defaultStatusCheck != DefaultStatusCheckNone {
+ hasStatusCheckFunction := false
+ actionlint.VisitExprNode(exprNode, func(node, _ actionlint.ExprNode, entering bool) {
+ if funcCallNode, ok := node.(*actionlint.FuncCallNode); entering && ok {
+ switch strings.ToLower(funcCallNode.Callee) {
+ case "success", "always", "cancelled", "failure":
+ hasStatusCheckFunction = true
+ }
+ }
+ })
+
+ if !hasStatusCheckFunction {
+ exprNode = &actionlint.LogicalOpNode{
+ Kind: actionlint.LogicalOpNodeKindAnd,
+ Left: &actionlint.FuncCallNode{
+ Callee: defaultStatusCheck.String(),
+ Args: []actionlint.ExprNode{},
+ },
+ Right: exprNode,
+ }
+ }
+ }
+
+ result, err2 := impl.evaluateNode(exprNode)
+
+ return result, err2
+}
+
+func (impl *interperterImpl) evaluateNode(exprNode actionlint.ExprNode) (interface{}, error) {
+ switch node := exprNode.(type) {
+ case *actionlint.VariableNode:
+ return impl.evaluateVariable(node)
+ case *actionlint.BoolNode:
+ return node.Value, nil
+ case *actionlint.NullNode:
+ return nil, nil
+ case *actionlint.IntNode:
+ return node.Value, nil
+ case *actionlint.FloatNode:
+ return node.Value, nil
+ case *actionlint.StringNode:
+ return node.Value, nil
+ case *actionlint.IndexAccessNode:
+ return impl.evaluateIndexAccess(node)
+ case *actionlint.ObjectDerefNode:
+ return impl.evaluateObjectDeref(node)
+ case *actionlint.ArrayDerefNode:
+ return impl.evaluateArrayDeref(node)
+ case *actionlint.NotOpNode:
+ return impl.evaluateNot(node)
+ case *actionlint.CompareOpNode:
+ return impl.evaluateCompare(node)
+ case *actionlint.LogicalOpNode:
+ return impl.evaluateLogicalCompare(node)
+ case *actionlint.FuncCallNode:
+ return impl.evaluateFuncCall(node)
+ default:
+ return nil, fmt.Errorf("Fatal error! Unknown node type: %s node: %+v", reflect.TypeOf(exprNode), exprNode)
+ }
+}
+
+//nolint:gocyclo
+func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (interface{}, error) {
+ switch strings.ToLower(variableNode.Name) {
+ case "github":
+ return impl.env.Github, nil
+ case "gitea": // compatible with Gitea
+ return impl.env.Github, nil
+ case "forge":
+ return impl.env.Github, nil
+ case "env":
+ return impl.env.Env, nil
+ case "job":
+ return impl.env.Job, nil
+ case "jobs":
+ if impl.env.Jobs == nil {
+ return nil, fmt.Errorf("Unavailable context: jobs")
+ }
+ return impl.env.Jobs, nil
+ case "steps":
+ return impl.env.Steps, nil
+ case "runner":
+ return impl.env.Runner, nil
+ case "secrets":
+ return impl.env.Secrets, nil
+ case "vars":
+ return impl.env.Vars, nil
+ case "strategy":
+ return impl.env.Strategy, nil
+ case "matrix":
+ return impl.env.Matrix, nil
+ case "needs":
+ return impl.env.Needs, nil
+ case "inputs":
+ return impl.env.Inputs, nil
+ case "infinity":
+ return math.Inf(1), nil
+ case "nan":
+ return math.NaN(), nil
+ default:
+ return nil, fmt.Errorf("Unavailable context: %s", variableNode.Name)
+ }
+}
+
+func (impl *interperterImpl) evaluateIndexAccess(indexAccessNode *actionlint.IndexAccessNode) (interface{}, error) {
+ left, err := impl.evaluateNode(indexAccessNode.Operand)
+ if err != nil {
+ return nil, err
+ }
+
+ leftValue := reflect.ValueOf(left)
+
+ right, err := impl.evaluateNode(indexAccessNode.Index)
+ if err != nil {
+ return nil, err
+ }
+
+ rightValue := reflect.ValueOf(right)
+
+ switch rightValue.Kind() {
+ case reflect.String:
+ return impl.getPropertyValue(leftValue, rightValue.String())
+
+ case reflect.Int:
+ switch leftValue.Kind() {
+ case reflect.Slice:
+ if rightValue.Int() < 0 || rightValue.Int() >= int64(leftValue.Len()) {
+ return nil, nil
+ }
+ return leftValue.Index(int(rightValue.Int())).Interface(), nil
+ default:
+ return nil, nil
+ }
+
+ default:
+ return nil, nil
+ }
+}
+
+func (impl *interperterImpl) evaluateObjectDeref(objectDerefNode *actionlint.ObjectDerefNode) (interface{}, error) {
+ left, err := impl.evaluateNode(objectDerefNode.Receiver)
+ if err != nil {
+ return nil, err
+ }
+
+ return impl.getPropertyValue(reflect.ValueOf(left), objectDerefNode.Property)
+}
+
+func (impl *interperterImpl) evaluateArrayDeref(arrayDerefNode *actionlint.ArrayDerefNode) (interface{}, error) {
+ left, err := impl.evaluateNode(arrayDerefNode.Receiver)
+ if err != nil {
+ return nil, err
+ }
+
+ return impl.getSafeValue(reflect.ValueOf(left)), nil
+}
+
+func (impl *interperterImpl) getPropertyValue(left reflect.Value, property string) (value interface{}, err error) {
+ switch left.Kind() {
+ case reflect.Ptr:
+ return impl.getPropertyValue(left.Elem(), property)
+
+ case reflect.Struct:
+ leftType := left.Type()
+ for i := 0; i < leftType.NumField(); i++ {
+ jsonName := leftType.Field(i).Tag.Get("json")
+ if jsonName == property {
+ property = leftType.Field(i).Name
+ break
+ }
+ }
+
+ fieldValue := left.FieldByNameFunc(func(name string) bool {
+ return strings.EqualFold(name, property)
+ })
+
+ if fieldValue.Kind() == reflect.Invalid {
+ return "", nil
+ }
+
+ i := fieldValue.Interface()
+ // The type stepStatus int is an integer, but should be treated as string
+ if m, ok := i.(encoding.TextMarshaler); ok {
+ text, err := m.MarshalText()
+ if err != nil {
+ return nil, err
+ }
+ return string(text), nil
+ }
+ return i, nil
+
+ case reflect.Map:
+ iter := left.MapRange()
+
+ for iter.Next() {
+ key := iter.Key()
+
+ switch key.Kind() {
+ case reflect.String:
+ if strings.EqualFold(key.String(), property) {
+ return impl.getMapValue(iter.Value())
+ }
+
+ default:
+ return nil, fmt.Errorf("'%s' in map key not implemented", key.Kind())
+ }
+ }
+
+ return nil, nil
+
+ case reflect.Slice:
+ var values []interface{}
+
+ for i := 0; i < left.Len(); i++ {
+ value, err := impl.getPropertyValue(left.Index(i).Elem(), property)
+ if err != nil {
+ return nil, err
+ }
+
+ values = append(values, value)
+ }
+
+ return values, nil
+ }
+
+ return nil, nil
+}
+
+func (impl *interperterImpl) getMapValue(value reflect.Value) (interface{}, error) {
+ if value.Kind() == reflect.Ptr {
+ return impl.getMapValue(value.Elem())
+ }
+
+ return value.Interface(), nil
+}
+
+func (impl *interperterImpl) evaluateNot(notNode *actionlint.NotOpNode) (interface{}, error) {
+ operand, err := impl.evaluateNode(notNode.Operand)
+ if err != nil {
+ return nil, err
+ }
+
+ return !IsTruthy(operand), nil
+}
+
+func (impl *interperterImpl) evaluateCompare(compareNode *actionlint.CompareOpNode) (interface{}, error) {
+ left, err := impl.evaluateNode(compareNode.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ right, err := impl.evaluateNode(compareNode.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ leftValue := reflect.ValueOf(left)
+ rightValue := reflect.ValueOf(right)
+
+ return impl.compareValues(leftValue, rightValue, compareNode.Kind)
+}
+
+func (impl *interperterImpl) compareValues(leftValue reflect.Value, rightValue reflect.Value, kind actionlint.CompareOpNodeKind) (interface{}, error) {
+ if leftValue.Kind() != rightValue.Kind() {
+ if !impl.isNumber(leftValue) {
+ leftValue = impl.coerceToNumber(leftValue)
+ }
+ if !impl.isNumber(rightValue) {
+ rightValue = impl.coerceToNumber(rightValue)
+ }
+ }
+
+ switch leftValue.Kind() {
+ case reflect.Bool:
+ return impl.compareNumber(float64(impl.coerceToNumber(leftValue).Int()), float64(impl.coerceToNumber(rightValue).Int()), kind)
+ case reflect.String:
+ return impl.compareString(strings.ToLower(leftValue.String()), strings.ToLower(rightValue.String()), kind)
+
+ case reflect.Int:
+ if rightValue.Kind() == reflect.Float64 {
+ return impl.compareNumber(float64(leftValue.Int()), rightValue.Float(), kind)
+ }
+
+ return impl.compareNumber(float64(leftValue.Int()), float64(rightValue.Int()), kind)
+
+ case reflect.Float64:
+ if rightValue.Kind() == reflect.Int {
+ return impl.compareNumber(leftValue.Float(), float64(rightValue.Int()), kind)
+ }
+
+ return impl.compareNumber(leftValue.Float(), rightValue.Float(), kind)
+
+ case reflect.Invalid:
+ if rightValue.Kind() == reflect.Invalid {
+ return true, nil
+ }
+
+ // not possible situation - params are converted to the same type in code above
+ return nil, fmt.Errorf("Compare params of Invalid type: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
+
+ default:
+ return nil, fmt.Errorf("Compare not implemented for types: left: %+v, right: %+v", leftValue.Kind(), rightValue.Kind())
+ }
+}
+
+func (impl *interperterImpl) coerceToNumber(value reflect.Value) reflect.Value {
+ switch value.Kind() {
+ case reflect.Invalid:
+ return reflect.ValueOf(0)
+
+ case reflect.Bool:
+ switch value.Bool() {
+ case true:
+ return reflect.ValueOf(1)
+ case false:
+ return reflect.ValueOf(0)
+ }
+
+ case reflect.String:
+ if value.String() == "" {
+ return reflect.ValueOf(0)
+ }
+
+ // try to parse the string as a number
+ evaluated, err := impl.Evaluate(value.String(), DefaultStatusCheckNone)
+ if err != nil {
+ return reflect.ValueOf(math.NaN())
+ }
+
+ if value := reflect.ValueOf(evaluated); impl.isNumber(value) {
+ return value
+ }
+ }
+
+ return reflect.ValueOf(math.NaN())
+}
+
+func (impl *interperterImpl) coerceToString(value reflect.Value) reflect.Value {
+ switch value.Kind() {
+ case reflect.Invalid:
+ return reflect.ValueOf("")
+
+ case reflect.Bool:
+ switch value.Bool() {
+ case true:
+ return reflect.ValueOf("true")
+ case false:
+ return reflect.ValueOf("false")
+ }
+
+ case reflect.String:
+ return value
+
+ case reflect.Int:
+ return reflect.ValueOf(fmt.Sprint(value))
+
+ case reflect.Float64:
+ if math.IsInf(value.Float(), 1) {
+ return reflect.ValueOf("Infinity")
+ } else if math.IsInf(value.Float(), -1) {
+ return reflect.ValueOf("-Infinity")
+ }
+ return reflect.ValueOf(fmt.Sprintf("%.15G", value.Float()))
+
+ case reflect.Slice:
+ return reflect.ValueOf("Array")
+
+ case reflect.Map:
+ return reflect.ValueOf("Object")
+ }
+
+ return value
+}
+
+func (impl *interperterImpl) compareString(left string, right string, kind actionlint.CompareOpNodeKind) (bool, error) {
+ switch kind {
+ case actionlint.CompareOpNodeKindLess:
+ return left < right, nil
+ case actionlint.CompareOpNodeKindLessEq:
+ return left <= right, nil
+ case actionlint.CompareOpNodeKindGreater:
+ return left > right, nil
+ case actionlint.CompareOpNodeKindGreaterEq:
+ return left >= right, nil
+ case actionlint.CompareOpNodeKindEq:
+ return left == right, nil
+ case actionlint.CompareOpNodeKindNotEq:
+ return left != right, nil
+ default:
+ return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
+ }
+}
+
+func (impl *interperterImpl) compareNumber(left float64, right float64, kind actionlint.CompareOpNodeKind) (bool, error) {
+ switch kind {
+ case actionlint.CompareOpNodeKindLess:
+ return left < right, nil
+ case actionlint.CompareOpNodeKindLessEq:
+ return left <= right, nil
+ case actionlint.CompareOpNodeKindGreater:
+ return left > right, nil
+ case actionlint.CompareOpNodeKindGreaterEq:
+ return left >= right, nil
+ case actionlint.CompareOpNodeKindEq:
+ return left == right, nil
+ case actionlint.CompareOpNodeKindNotEq:
+ return left != right, nil
+ default:
+ return false, fmt.Errorf("TODO: not implemented to compare '%+v'", kind)
+ }
+}
+
+func IsTruthy(input interface{}) bool {
+ value := reflect.ValueOf(input)
+ switch value.Kind() {
+ case reflect.Bool:
+ return value.Bool()
+
+ case reflect.String:
+ return value.String() != ""
+
+ case reflect.Int:
+ return value.Int() != 0
+
+ case reflect.Float64:
+ if math.IsNaN(value.Float()) {
+ return false
+ }
+
+ return value.Float() != 0
+
+ case reflect.Map, reflect.Slice:
+ return true
+
+ default:
+ return false
+ }
+}
+
+func (impl *interperterImpl) isNumber(value reflect.Value) bool {
+ switch value.Kind() {
+ case reflect.Int, reflect.Float64:
+ return true
+ default:
+ return false
+ }
+}
+
+func (impl *interperterImpl) getSafeValue(value reflect.Value) interface{} {
+ switch value.Kind() {
+ case reflect.Invalid:
+ return nil
+
+ case reflect.Float64:
+ if value.Float() == 0 {
+ return 0
+ }
+ }
+
+ return value.Interface()
+}
+
+func (impl *interperterImpl) evaluateLogicalCompare(compareNode *actionlint.LogicalOpNode) (interface{}, error) {
+ left, err := impl.evaluateNode(compareNode.Left)
+ if err != nil {
+ return nil, err
+ }
+
+ leftValue := reflect.ValueOf(left)
+
+ if IsTruthy(left) == (compareNode.Kind == actionlint.LogicalOpNodeKindOr) {
+ return impl.getSafeValue(leftValue), nil
+ }
+
+ right, err := impl.evaluateNode(compareNode.Right)
+ if err != nil {
+ return nil, err
+ }
+
+ rightValue := reflect.ValueOf(right)
+
+ switch compareNode.Kind {
+ case actionlint.LogicalOpNodeKindAnd:
+ return impl.getSafeValue(rightValue), nil
+ case actionlint.LogicalOpNodeKindOr:
+ return impl.getSafeValue(rightValue), nil
+ }
+
+ return nil, fmt.Errorf("Unable to compare incompatibles types '%s' and '%s'", leftValue.Kind(), rightValue.Kind())
+}
+
+//nolint:gocyclo
+func (impl *interperterImpl) evaluateFuncCall(funcCallNode *actionlint.FuncCallNode) (interface{}, error) {
+ args := make([]reflect.Value, 0)
+
+ for _, arg := range funcCallNode.Args {
+ value, err := impl.evaluateNode(arg)
+ if err != nil {
+ return nil, err
+ }
+
+ args = append(args, reflect.ValueOf(value))
+ }
+
+ switch strings.ToLower(funcCallNode.Callee) {
+ case "contains":
+ return impl.contains(args[0], args[1])
+ case "startswith":
+ return impl.startsWith(args[0], args[1])
+ case "endswith":
+ return impl.endsWith(args[0], args[1])
+ case "format":
+ return impl.format(args[0], args[1:]...)
+ case "join":
+ if len(args) == 1 {
+ return impl.join(args[0], reflect.ValueOf(","))
+ }
+ return impl.join(args[0], args[1])
+ case "tojson":
+ return impl.toJSON(args[0])
+ case "fromjson":
+ return impl.fromJSON(args[0])
+ case "hashfiles":
+ if impl.env.HashFiles != nil {
+ return impl.env.HashFiles(args)
+ }
+ return impl.hashFiles(args...)
+ case "always":
+ return impl.always()
+ case "success":
+ if impl.config.Context == "job" {
+ return impl.jobSuccess()
+ }
+ if impl.config.Context == "step" {
+ return impl.stepSuccess()
+ }
+ return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
+ case "failure":
+ if impl.config.Context == "job" {
+ return impl.jobFailure()
+ }
+ if impl.config.Context == "step" {
+ return impl.stepFailure()
+ }
+ return nil, fmt.Errorf("Context '%s' must be one of 'job' or 'step'", impl.config.Context)
+ case "cancelled":
+ return impl.cancelled()
+ default:
+ return nil, fmt.Errorf("TODO: '%s' not implemented", funcCallNode.Callee)
+ }
+}
diff --git a/pkg/exprparser/interpreter_test.go b/pkg/exprparser/interpreter_test.go
new file mode 100644
index 0000000..f45851d
--- /dev/null
+++ b/pkg/exprparser/interpreter_test.go
@@ -0,0 +1,627 @@
+package exprparser
+
+import (
+ "math"
+ "testing"
+
+ "github.com/nektos/act/pkg/model"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestLiterals(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"true", true, "true"},
+ {"false", false, "false"},
+ {"null", nil, "null"},
+ {"123", 123, "integer"},
+ {"-9.7", -9.7, "float"},
+ {"0xff", 255, "hex"},
+ {"-2.99e-2", -2.99e-2, "exponential"},
+ {"'foo'", "foo", "string"},
+ {"'it''s foo'", "it's foo", "string"},
+ }
+
+ env := &EvaluationEnvironment{}
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestOperators(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ error string
+ }{
+ {"(false || (false || true))", true, "logical-grouping", ""},
+ {"github.action", "push", "property-dereference", ""},
+ {"github['action']", "push", "property-index", ""},
+ {"github.action[0]", nil, "string-index", ""},
+ {"github.action['0']", nil, "string-index", ""},
+ {"fromJSON('[0,1]')[1]", 1.0, "array-index", ""},
+ {"fromJSON('[0,1]')[1.1]", nil, "array-index", ""},
+ // Disabled weird things are happening
+ // {"fromJSON('[0,1]')['1.1']", nil, "array-index", ""},
+ {"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""},
+ {"fromJSON('[0,1]')[2]", nil, "array-index-out-of-bounds-0", ""},
+ {"fromJSON('[0,1]')[34553]", nil, "array-index-out-of-bounds-1", ""},
+ {"fromJSON('[0,1]')[-1]", nil, "array-index-out-of-bounds-2", ""},
+ {"fromJSON('[0,1]')[-34553]", nil, "array-index-out-of-bounds-3", ""},
+ {"!true", false, "not", ""},
+ {"1 < 2", true, "less-than", ""},
+ {`'b' <= 'a'`, false, "less-than-or-equal", ""},
+ {"1 > 2", false, "greater-than", ""},
+ {`'b' >= 'a'`, true, "greater-than-or-equal", ""},
+ {`'a' == 'a'`, true, "equal", ""},
+ {`'a' != 'a'`, false, "not-equal", ""},
+ {`true && false`, false, "and", ""},
+ {`true || false`, true, "or", ""},
+ {`fromJSON('{}') && true`, true, "and-boolean-object", ""},
+ {`fromJSON('{}') || false`, make(map[string]interface{}), "or-boolean-object", ""},
+ {"github.event.commits[0].author.username != github.event.commits[1].author.username", true, "property-comparison1", ""},
+ {"github.event.commits[0].author.username1 != github.event.commits[1].author.username", true, "property-comparison2", ""},
+ {"github.event.commits[0].author.username != github.event.commits[1].author.username1", true, "property-comparison3", ""},
+ {"github.event.commits[0].author.username1 != github.event.commits[1].author.username2", true, "property-comparison4", ""},
+ {"secrets != env", nil, "property-comparison5", "Compare not implemented for types: left: map, right: map"},
+ }
+
+ env := &EvaluationEnvironment{
+ Github: &model.GithubContext{
+ Action: "push",
+ Event: map[string]interface{}{
+ "commits": []interface{}{
+ map[string]interface{}{
+ "author": map[string]interface{}{
+ "username": "someone",
+ },
+ },
+ map[string]interface{}{
+ "author": map[string]interface{}{
+ "username": "someone-else",
+ },
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ if tt.error != "" {
+ assert.NotNil(t, err)
+ assert.Equal(t, tt.error, err.Error())
+ } else {
+ assert.Nil(t, err)
+ }
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestOperatorsCompare(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"!null", true, "not-null"},
+ {"!-10", false, "not-neg-num"},
+ {"!0", true, "not-zero"},
+ {"!3.14", false, "not-pos-float"},
+ {"!''", true, "not-empty-str"},
+ {"!'abc'", false, "not-str"},
+ {"!fromJSON('{}')", false, "not-obj"},
+ {"!fromJSON('[]')", false, "not-arr"},
+ {`null == 0 }}`, true, "null-coercion"},
+ {`true == 1 }}`, true, "boolean-coercion"},
+ {`'' == 0 }}`, true, "string-0-coercion"},
+ {`'3' == 3 }}`, true, "string-3-coercion"},
+ {`0 == null }}`, true, "null-coercion-alt"},
+ {`1 == true }}`, true, "boolean-coercion-alt"},
+ {`0 == '' }}`, true, "string-0-coercion-alt"},
+ {`3 == '3' }}`, true, "string-3-coercion-alt"},
+ {`'TEST' == 'test' }}`, true, "string-casing"},
+ {"true > false }}", true, "bool-greater-than"},
+ {"true >= false }}", true, "bool-greater-than-eq"},
+ {"true >= true }}", true, "bool-greater-than-1"},
+ {"true != false }}", true, "bool-not-equal"},
+ {`fromJSON('{}') < 2 }}`, false, "object-with-less"},
+ {`fromJSON('{}') < fromJSON('[]') }}`, false, "object/arr-with-lt"},
+ {`fromJSON('{}') > fromJSON('[]') }}`, false, "object/arr-with-gt"},
+ }
+
+ env := &EvaluationEnvironment{
+ Github: &model.GithubContext{
+ Action: "push",
+ },
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
+
+func TestOperatorsBooleanEvaluation(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ // true &&
+ {"true && true", true, "true-and"},
+ {"true && false", false, "true-and"},
+ {"true && null", nil, "true-and"},
+ {"true && -10", -10, "true-and"},
+ {"true && 0", 0, "true-and"},
+ {"true && 10", 10, "true-and"},
+ {"true && 3.14", 3.14, "true-and"},
+ {"true && 0.0", 0, "true-and"},
+ {"true && Infinity", math.Inf(1), "true-and"},
+ // {"true && -Infinity", math.Inf(-1), "true-and"},
+ {"true && NaN", math.NaN(), "true-and"},
+ {"true && ''", "", "true-and"},
+ {"true && 'abc'", "abc", "true-and"},
+ // false &&
+ {"false && true", false, "false-and"},
+ {"false && false", false, "false-and"},
+ {"false && null", false, "false-and"},
+ {"false && -10", false, "false-and"},
+ {"false && 0", false, "false-and"},
+ {"false && 10", false, "false-and"},
+ {"false && 3.14", false, "false-and"},
+ {"false && 0.0", false, "false-and"},
+ {"false && Infinity", false, "false-and"},
+ // {"false && -Infinity", false, "false-and"},
+ {"false && NaN", false, "false-and"},
+ {"false && ''", false, "false-and"},
+ {"false && 'abc'", false, "false-and"},
+ // true ||
+ {"true || true", true, "true-or"},
+ {"true || false", true, "true-or"},
+ {"true || null", true, "true-or"},
+ {"true || -10", true, "true-or"},
+ {"true || 0", true, "true-or"},
+ {"true || 10", true, "true-or"},
+ {"true || 3.14", true, "true-or"},
+ {"true || 0.0", true, "true-or"},
+ {"true || Infinity", true, "true-or"},
+ // {"true || -Infinity", true, "true-or"},
+ {"true || NaN", true, "true-or"},
+ {"true || ''", true, "true-or"},
+ {"true || 'abc'", true, "true-or"},
+ // false ||
+ {"false || true", true, "false-or"},
+ {"false || false", false, "false-or"},
+ {"false || null", nil, "false-or"},
+ {"false || -10", -10, "false-or"},
+ {"false || 0", 0, "false-or"},
+ {"false || 10", 10, "false-or"},
+ {"false || 3.14", 3.14, "false-or"},
+ {"false || 0.0", 0, "false-or"},
+ {"false || Infinity", math.Inf(1), "false-or"},
+ // {"false || -Infinity", math.Inf(-1), "false-or"},
+ {"false || NaN", math.NaN(), "false-or"},
+ {"false || ''", "", "false-or"},
+ {"false || 'abc'", "abc", "false-or"},
+ // null &&
+ {"null && true", nil, "null-and"},
+ {"null && false", nil, "null-and"},
+ {"null && null", nil, "null-and"},
+ {"null && -10", nil, "null-and"},
+ {"null && 0", nil, "null-and"},
+ {"null && 10", nil, "null-and"},
+ {"null && 3.14", nil, "null-and"},
+ {"null && 0.0", nil, "null-and"},
+ {"null && Infinity", nil, "null-and"},
+ // {"null && -Infinity", nil, "null-and"},
+ {"null && NaN", nil, "null-and"},
+ {"null && ''", nil, "null-and"},
+ {"null && 'abc'", nil, "null-and"},
+ // null ||
+ {"null || true", true, "null-or"},
+ {"null || false", false, "null-or"},
+ {"null || null", nil, "null-or"},
+ {"null || -10", -10, "null-or"},
+ {"null || 0", 0, "null-or"},
+ {"null || 10", 10, "null-or"},
+ {"null || 3.14", 3.14, "null-or"},
+ {"null || 0.0", 0, "null-or"},
+ {"null || Infinity", math.Inf(1), "null-or"},
+ // {"null || -Infinity", math.Inf(-1), "null-or"},
+ {"null || NaN", math.NaN(), "null-or"},
+ {"null || ''", "", "null-or"},
+ {"null || 'abc'", "abc", "null-or"},
+ // -10 &&
+ {"-10 && true", true, "neg-num-and"},
+ {"-10 && false", false, "neg-num-and"},
+ {"-10 && null", nil, "neg-num-and"},
+ {"-10 && -10", -10, "neg-num-and"},
+ {"-10 && 0", 0, "neg-num-and"},
+ {"-10 && 10", 10, "neg-num-and"},
+ {"-10 && 3.14", 3.14, "neg-num-and"},
+ {"-10 && 0.0", 0, "neg-num-and"},
+ {"-10 && Infinity", math.Inf(1), "neg-num-and"},
+ // {"-10 && -Infinity", math.Inf(-1), "neg-num-and"},
+ {"-10 && NaN", math.NaN(), "neg-num-and"},
+ {"-10 && ''", "", "neg-num-and"},
+ {"-10 && 'abc'", "abc", "neg-num-and"},
+ // -10 ||
+ {"-10 || true", -10, "neg-num-or"},
+ {"-10 || false", -10, "neg-num-or"},
+ {"-10 || null", -10, "neg-num-or"},
+ {"-10 || -10", -10, "neg-num-or"},
+ {"-10 || 0", -10, "neg-num-or"},
+ {"-10 || 10", -10, "neg-num-or"},
+ {"-10 || 3.14", -10, "neg-num-or"},
+ {"-10 || 0.0", -10, "neg-num-or"},
+ {"-10 || Infinity", -10, "neg-num-or"},
+ // {"-10 || -Infinity", -10, "neg-num-or"},
+ {"-10 || NaN", -10, "neg-num-or"},
+ {"-10 || ''", -10, "neg-num-or"},
+ {"-10 || 'abc'", -10, "neg-num-or"},
+ // 0 &&
+ {"0 && true", 0, "zero-and"},
+ {"0 && false", 0, "zero-and"},
+ {"0 && null", 0, "zero-and"},
+ {"0 && -10", 0, "zero-and"},
+ {"0 && 0", 0, "zero-and"},
+ {"0 && 10", 0, "zero-and"},
+ {"0 && 3.14", 0, "zero-and"},
+ {"0 && 0.0", 0, "zero-and"},
+ {"0 && Infinity", 0, "zero-and"},
+ // {"0 && -Infinity", 0, "zero-and"},
+ {"0 && NaN", 0, "zero-and"},
+ {"0 && ''", 0, "zero-and"},
+ {"0 && 'abc'", 0, "zero-and"},
+ // 0 ||
+ {"0 || true", true, "zero-or"},
+ {"0 || false", false, "zero-or"},
+ {"0 || null", nil, "zero-or"},
+ {"0 || -10", -10, "zero-or"},
+ {"0 || 0", 0, "zero-or"},
+ {"0 || 10", 10, "zero-or"},
+ {"0 || 3.14", 3.14, "zero-or"},
+ {"0 || 0.0", 0, "zero-or"},
+ {"0 || Infinity", math.Inf(1), "zero-or"},
+ // {"0 || -Infinity", math.Inf(-1), "zero-or"},
+ {"0 || NaN", math.NaN(), "zero-or"},
+ {"0 || ''", "", "zero-or"},
+ {"0 || 'abc'", "abc", "zero-or"},
+ // 10 &&
+ {"10 && true", true, "pos-num-and"},
+ {"10 && false", false, "pos-num-and"},
+ {"10 && null", nil, "pos-num-and"},
+ {"10 && -10", -10, "pos-num-and"},
+ {"10 && 0", 0, "pos-num-and"},
+ {"10 && 10", 10, "pos-num-and"},
+ {"10 && 3.14", 3.14, "pos-num-and"},
+ {"10 && 0.0", 0, "pos-num-and"},
+ {"10 && Infinity", math.Inf(1), "pos-num-and"},
+ // {"10 && -Infinity", math.Inf(-1), "pos-num-and"},
+ {"10 && NaN", math.NaN(), "pos-num-and"},
+ {"10 && ''", "", "pos-num-and"},
+ {"10 && 'abc'", "abc", "pos-num-and"},
+ // 10 ||
+ {"10 || true", 10, "pos-num-or"},
+ {"10 || false", 10, "pos-num-or"},
+ {"10 || null", 10, "pos-num-or"},
+ {"10 || -10", 10, "pos-num-or"},
+ {"10 || 0", 10, "pos-num-or"},
+ {"10 || 10", 10, "pos-num-or"},
+ {"10 || 3.14", 10, "pos-num-or"},
+ {"10 || 0.0", 10, "pos-num-or"},
+ {"10 || Infinity", 10, "pos-num-or"},
+ // {"10 || -Infinity", 10, "pos-num-or"},
+ {"10 || NaN", 10, "pos-num-or"},
+ {"10 || ''", 10, "pos-num-or"},
+ {"10 || 'abc'", 10, "pos-num-or"},
+ // 3.14 &&
+ {"3.14 && true", true, "pos-float-and"},
+ {"3.14 && false", false, "pos-float-and"},
+ {"3.14 && null", nil, "pos-float-and"},
+ {"3.14 && -10", -10, "pos-float-and"},
+ {"3.14 && 0", 0, "pos-float-and"},
+ {"3.14 && 10", 10, "pos-float-and"},
+ {"3.14 && 3.14", 3.14, "pos-float-and"},
+ {"3.14 && 0.0", 0, "pos-float-and"},
+ {"3.14 && Infinity", math.Inf(1), "pos-float-and"},
+ // {"3.14 && -Infinity", math.Inf(-1), "pos-float-and"},
+ {"3.14 && NaN", math.NaN(), "pos-float-and"},
+ {"3.14 && ''", "", "pos-float-and"},
+ {"3.14 && 'abc'", "abc", "pos-float-and"},
+ // 3.14 ||
+ {"3.14 || true", 3.14, "pos-float-or"},
+ {"3.14 || false", 3.14, "pos-float-or"},
+ {"3.14 || null", 3.14, "pos-float-or"},
+ {"3.14 || -10", 3.14, "pos-float-or"},
+ {"3.14 || 0", 3.14, "pos-float-or"},
+ {"3.14 || 10", 3.14, "pos-float-or"},
+ {"3.14 || 3.14", 3.14, "pos-float-or"},
+ {"3.14 || 0.0", 3.14, "pos-float-or"},
+ {"3.14 || Infinity", 3.14, "pos-float-or"},
+ // {"3.14 || -Infinity", 3.14, "pos-float-or"},
+ {"3.14 || NaN", 3.14, "pos-float-or"},
+ {"3.14 || ''", 3.14, "pos-float-or"},
+ {"3.14 || 'abc'", 3.14, "pos-float-or"},
+ // Infinity &&
+ {"Infinity && true", true, "pos-inf-and"},
+ {"Infinity && false", false, "pos-inf-and"},
+ {"Infinity && null", nil, "pos-inf-and"},
+ {"Infinity && -10", -10, "pos-inf-and"},
+ {"Infinity && 0", 0, "pos-inf-and"},
+ {"Infinity && 10", 10, "pos-inf-and"},
+ {"Infinity && 3.14", 3.14, "pos-inf-and"},
+ {"Infinity && 0.0", 0, "pos-inf-and"},
+ {"Infinity && Infinity", math.Inf(1), "pos-inf-and"},
+ // {"Infinity && -Infinity", math.Inf(-1), "pos-inf-and"},
+ {"Infinity && NaN", math.NaN(), "pos-inf-and"},
+ {"Infinity && ''", "", "pos-inf-and"},
+ {"Infinity && 'abc'", "abc", "pos-inf-and"},
+ // Infinity ||
+ {"Infinity || true", math.Inf(1), "pos-inf-or"},
+ {"Infinity || false", math.Inf(1), "pos-inf-or"},
+ {"Infinity || null", math.Inf(1), "pos-inf-or"},
+ {"Infinity || -10", math.Inf(1), "pos-inf-or"},
+ {"Infinity || 0", math.Inf(1), "pos-inf-or"},
+ {"Infinity || 10", math.Inf(1), "pos-inf-or"},
+ {"Infinity || 3.14", math.Inf(1), "pos-inf-or"},
+ {"Infinity || 0.0", math.Inf(1), "pos-inf-or"},
+ {"Infinity || Infinity", math.Inf(1), "pos-inf-or"},
+ // {"Infinity || -Infinity", math.Inf(1), "pos-inf-or"},
+ {"Infinity || NaN", math.Inf(1), "pos-inf-or"},
+ {"Infinity || ''", math.Inf(1), "pos-inf-or"},
+ {"Infinity || 'abc'", math.Inf(1), "pos-inf-or"},
+ // -Infinity &&
+ // {"-Infinity && true", true, "neg-inf-and"},
+ // {"-Infinity && false", false, "neg-inf-and"},
+ // {"-Infinity && null", nil, "neg-inf-and"},
+ // {"-Infinity && -10", -10, "neg-inf-and"},
+ // {"-Infinity && 0", 0, "neg-inf-and"},
+ // {"-Infinity && 10", 10, "neg-inf-and"},
+ // {"-Infinity && 3.14", 3.14, "neg-inf-and"},
+ // {"-Infinity && 0.0", 0, "neg-inf-and"},
+ // {"-Infinity && Infinity", math.Inf(1), "neg-inf-and"},
+ // {"-Infinity && -Infinity", math.Inf(-1), "neg-inf-and"},
+ // {"-Infinity && NaN", math.NaN(), "neg-inf-and"},
+ // {"-Infinity && ''", "", "neg-inf-and"},
+ // {"-Infinity && 'abc'", "abc", "neg-inf-and"},
+ // -Infinity ||
+ // {"-Infinity || true", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || false", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || null", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || -10", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || 0", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || 10", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || 3.14", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || 0.0", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || Infinity", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || -Infinity", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || NaN", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || ''", math.Inf(-1), "neg-inf-or"},
+ // {"-Infinity || 'abc'", math.Inf(-1), "neg-inf-or"},
+ // NaN &&
+ {"NaN && true", math.NaN(), "nan-and"},
+ {"NaN && false", math.NaN(), "nan-and"},
+ {"NaN && null", math.NaN(), "nan-and"},
+ {"NaN && -10", math.NaN(), "nan-and"},
+ {"NaN && 0", math.NaN(), "nan-and"},
+ {"NaN && 10", math.NaN(), "nan-and"},
+ {"NaN && 3.14", math.NaN(), "nan-and"},
+ {"NaN && 0.0", math.NaN(), "nan-and"},
+ {"NaN && Infinity", math.NaN(), "nan-and"},
+ // {"NaN && -Infinity", math.NaN(), "nan-and"},
+ {"NaN && NaN", math.NaN(), "nan-and"},
+ {"NaN && ''", math.NaN(), "nan-and"},
+ {"NaN && 'abc'", math.NaN(), "nan-and"},
+ // NaN ||
+ {"NaN || true", true, "nan-or"},
+ {"NaN || false", false, "nan-or"},
+ {"NaN || null", nil, "nan-or"},
+ {"NaN || -10", -10, "nan-or"},
+ {"NaN || 0", 0, "nan-or"},
+ {"NaN || 10", 10, "nan-or"},
+ {"NaN || 3.14", 3.14, "nan-or"},
+ {"NaN || 0.0", 0, "nan-or"},
+ {"NaN || Infinity", math.Inf(1), "nan-or"},
+ // {"NaN || -Infinity", math.Inf(-1), "nan-or"},
+ {"NaN || NaN", math.NaN(), "nan-or"},
+ {"NaN || ''", "", "nan-or"},
+ {"NaN || 'abc'", "abc", "nan-or"},
+ // "" &&
+ {"'' && true", "", "empty-str-and"},
+ {"'' && false", "", "empty-str-and"},
+ {"'' && null", "", "empty-str-and"},
+ {"'' && -10", "", "empty-str-and"},
+ {"'' && 0", "", "empty-str-and"},
+ {"'' && 10", "", "empty-str-and"},
+ {"'' && 3.14", "", "empty-str-and"},
+ {"'' && 0.0", "", "empty-str-and"},
+ {"'' && Infinity", "", "empty-str-and"},
+ // {"'' && -Infinity", "", "empty-str-and"},
+ {"'' && NaN", "", "empty-str-and"},
+ {"'' && ''", "", "empty-str-and"},
+ {"'' && 'abc'", "", "empty-str-and"},
+ // "" ||
+ {"'' || true", true, "empty-str-or"},
+ {"'' || false", false, "empty-str-or"},
+ {"'' || null", nil, "empty-str-or"},
+ {"'' || -10", -10, "empty-str-or"},
+ {"'' || 0", 0, "empty-str-or"},
+ {"'' || 10", 10, "empty-str-or"},
+ {"'' || 3.14", 3.14, "empty-str-or"},
+ {"'' || 0.0", 0, "empty-str-or"},
+ {"'' || Infinity", math.Inf(1), "empty-str-or"},
+ // {"'' || -Infinity", math.Inf(-1), "empty-str-or"},
+ {"'' || NaN", math.NaN(), "empty-str-or"},
+ {"'' || ''", "", "empty-str-or"},
+ {"'' || 'abc'", "abc", "empty-str-or"},
+ // "abc" &&
+ {"'abc' && true", true, "str-and"},
+ {"'abc' && false", false, "str-and"},
+ {"'abc' && null", nil, "str-and"},
+ {"'abc' && -10", -10, "str-and"},
+ {"'abc' && 0", 0, "str-and"},
+ {"'abc' && 10", 10, "str-and"},
+ {"'abc' && 3.14", 3.14, "str-and"},
+ {"'abc' && 0.0", 0, "str-and"},
+ {"'abc' && Infinity", math.Inf(1), "str-and"},
+ // {"'abc' && -Infinity", math.Inf(-1), "str-and"},
+ {"'abc' && NaN", math.NaN(), "str-and"},
+ {"'abc' && ''", "", "str-and"},
+ {"'abc' && 'abc'", "abc", "str-and"},
+ // "abc" ||
+ {"'abc' || true", "abc", "str-or"},
+ {"'abc' || false", "abc", "str-or"},
+ {"'abc' || null", "abc", "str-or"},
+ {"'abc' || -10", "abc", "str-or"},
+ {"'abc' || 0", "abc", "str-or"},
+ {"'abc' || 10", "abc", "str-or"},
+ {"'abc' || 3.14", "abc", "str-or"},
+ {"'abc' || 0.0", "abc", "str-or"},
+ {"'abc' || Infinity", "abc", "str-or"},
+ // {"'abc' || -Infinity", "abc", "str-or"},
+ {"'abc' || NaN", "abc", "str-or"},
+ {"'abc' || ''", "abc", "str-or"},
+ {"'abc' || 'abc'", "abc", "str-or"},
+ // extra tests
+ {"0.0 && true", 0, "float-evaluation-0-alt"},
+ {"-1.5 && true", true, "float-evaluation-neg-alt"},
+ }
+
+ env := &EvaluationEnvironment{
+ Github: &model.GithubContext{
+ Action: "push",
+ },
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ if expected, ok := tt.expected.(float64); ok && math.IsNaN(expected) {
+ assert.True(t, math.IsNaN(output.(float64)))
+ } else {
+ assert.Equal(t, tt.expected, output)
+ }
+ })
+ }
+}
+
+func TestContexts(t *testing.T) {
+ table := []struct {
+ input string
+ expected interface{}
+ name string
+ }{
+ {"github.action", "push", "github-context"},
+ {"github.event.commits[0].message", nil, "github-context-noexist-prop"},
+ {"fromjson('{\"commits\":[]}').commits[0].message", nil, "github-context-noexist-prop"},
+ {"github.event.pull_request.labels.*.name", nil, "github-context-noexist-prop"},
+ {"env.TEST", "value", "env-context"},
+ {"job.status", "success", "job-context"},
+ {"steps.step-id.outputs.name", "value", "steps-context"},
+ {"steps.step-id.conclusion", "success", "steps-context-conclusion"},
+ {"steps.step-id.conclusion && true", true, "steps-context-conclusion"},
+ {"steps.step-id2.conclusion", "skipped", "steps-context-conclusion"},
+ {"steps.step-id2.conclusion && true", true, "steps-context-conclusion"},
+ {"steps.step-id.outcome", "success", "steps-context-outcome"},
+ {"steps.step-id['outcome']", "success", "steps-context-outcome"},
+ {"steps.step-id.outcome == 'success'", true, "steps-context-outcome"},
+ {"steps.step-id['outcome'] == 'success'", true, "steps-context-outcome"},
+ {"steps.step-id.outcome && true", true, "steps-context-outcome"},
+ {"steps['step-id']['outcome'] && true", true, "steps-context-outcome"},
+ {"steps.step-id2.outcome", "failure", "steps-context-outcome"},
+ {"steps.step-id2.outcome && true", true, "steps-context-outcome"},
+ // Disabled, since the interpreter is still too broken
+ // {"contains(steps.*.outcome, 'success')", true, "steps-context-array-outcome"},
+ // {"contains(steps.*.outcome, 'failure')", true, "steps-context-array-outcome"},
+ // {"contains(steps.*.outputs.name, 'value')", true, "steps-context-array-outputs"},
+ {"runner.os", "Linux", "runner-context"},
+ {"secrets.name", "value", "secrets-context"},
+ {"vars.name", "value", "vars-context"},
+ {"strategy.fail-fast", true, "strategy-context"},
+ {"matrix.os", "Linux", "matrix-context"},
+ {"needs.job-id.outputs.output-name", "value", "needs-context"},
+ {"needs.job-id.result", "success", "needs-context"},
+ {"inputs.name", "value", "inputs-context"},
+ }
+
+ env := &EvaluationEnvironment{
+ Github: &model.GithubContext{
+ Action: "push",
+ },
+ Env: map[string]string{
+ "TEST": "value",
+ },
+ Job: &model.JobContext{
+ Status: "success",
+ },
+ Steps: map[string]*model.StepResult{
+ "step-id": {
+ Outputs: map[string]string{
+ "name": "value",
+ },
+ },
+ "step-id2": {
+ Outcome: model.StepStatusFailure,
+ Conclusion: model.StepStatusSkipped,
+ },
+ },
+ Runner: map[string]interface{}{
+ "os": "Linux",
+ "temp": "/tmp",
+ "tool_cache": "/opt/hostedtoolcache",
+ },
+ Secrets: map[string]string{
+ "name": "value",
+ },
+ Vars: map[string]string{
+ "name": "value",
+ },
+ Strategy: map[string]interface{}{
+ "fail-fast": true,
+ },
+ Matrix: map[string]interface{}{
+ "os": "Linux",
+ },
+ Needs: map[string]Needs{
+ "job-id": {
+ Outputs: map[string]string{
+ "output-name": "value",
+ },
+ Result: "success",
+ },
+ },
+ Inputs: map[string]interface{}{
+ "name": "value",
+ },
+ }
+
+ for _, tt := range table {
+ t.Run(tt.name, func(t *testing.T) {
+ output, err := NewInterpeter(env, Config{}).Evaluate(tt.input, DefaultStatusCheckNone)
+ assert.Nil(t, err)
+
+ assert.Equal(t, tt.expected, output)
+ })
+ }
+}
diff --git a/pkg/exprparser/testdata/for-hashing-1.txt b/pkg/exprparser/testdata/for-hashing-1.txt
new file mode 100644
index 0000000..e965047
--- /dev/null
+++ b/pkg/exprparser/testdata/for-hashing-1.txt
@@ -0,0 +1 @@
+Hello
diff --git a/pkg/exprparser/testdata/for-hashing-2.txt b/pkg/exprparser/testdata/for-hashing-2.txt
new file mode 100644
index 0000000..496c875
--- /dev/null
+++ b/pkg/exprparser/testdata/for-hashing-2.txt
@@ -0,0 +1 @@
+World!
diff --git a/pkg/exprparser/testdata/for-hashing-3/data.txt b/pkg/exprparser/testdata/for-hashing-3/data.txt
new file mode 100644
index 0000000..5ac7bf9
--- /dev/null
+++ b/pkg/exprparser/testdata/for-hashing-3/data.txt
@@ -0,0 +1 @@
+Knock knock!
diff --git a/pkg/exprparser/testdata/for-hashing-3/nested/nested-data.txt b/pkg/exprparser/testdata/for-hashing-3/nested/nested-data.txt
new file mode 100644
index 0000000..ebe288b
--- /dev/null
+++ b/pkg/exprparser/testdata/for-hashing-3/nested/nested-data.txt
@@ -0,0 +1 @@
+Anybody home?