diff options
Diffstat (limited to 'pkg/exprparser')
-rw-r--r-- | pkg/exprparser/functions.go | 295 | ||||
-rw-r--r-- | pkg/exprparser/functions_test.go | 251 | ||||
-rw-r--r-- | pkg/exprparser/interpreter.go | 642 | ||||
-rw-r--r-- | pkg/exprparser/interpreter_test.go | 627 | ||||
-rw-r--r-- | pkg/exprparser/testdata/for-hashing-1.txt | 1 | ||||
-rw-r--r-- | pkg/exprparser/testdata/for-hashing-2.txt | 1 | ||||
-rw-r--r-- | pkg/exprparser/testdata/for-hashing-3/data.txt | 1 | ||||
-rw-r--r-- | pkg/exprparser/testdata/for-hashing-3/nested/nested-data.txt | 1 |
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? |