diff --git a/go.mod b/go.mod index 02ceb4b863c..e97adb48228 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,6 @@ require ( github.com/opencontainers/selinux v1.10.0 github.com/pkg/errors v0.9.1 github.com/rhysd/actionlint v1.6.8 - github.com/robertkrimen/otto v0.0.0-20210614181706-373ff5438452 github.com/sabhiram/go-gitignore v0.0.0-20201211210132-54b8a0bf510f github.com/sergi/go-diff v1.2.0 // indirect github.com/sirupsen/logrus v1.8.1 @@ -44,6 +43,5 @@ require ( golang.org/x/term v0.0.0-20210916214954-140adaaadfaf golang.org/x/text v0.3.7 // indirect google.golang.org/genproto v0.0.0-20210921142501-181ce0d877f6 // indirect - gopkg.in/sourcemap.v1 v1.0.5 // indirect gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index f791e1eacc7..a4018633b42 100644 --- a/go.sum +++ b/go.sum @@ -1037,8 +1037,6 @@ github.com/rhysd/actionlint v1.6.8 h1:li0691FNuuS3da2igfjMb9M58AgMXX7j9U5EgbCZFu github.com/rhysd/actionlint v1.6.8/go.mod h1:0AA4pvZ2nrZHT6D86eUhieH2NFmLqhxrNex0NEa2A2g= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/robertkrimen/otto v0.0.0-20210614181706-373ff5438452 h1:ewTtJ72GFy2e0e8uyiDwMG3pKCS5mBh+hdSTYsPKEP8= -github.com/robertkrimen/otto v0.0.0-20210614181706-373ff5438452/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY= github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ= github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= @@ -1782,8 +1780,6 @@ gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= -gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= -gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/square/go-jose.v2 v2.2.2/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= diff --git a/pkg/runner/expression.go b/pkg/runner/expression.go index ac024552056..7a5598d7d84 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -1,655 +1,257 @@ package runner import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" "fmt" - "io" - "os" - "path/filepath" + "math" "regexp" - "strconv" "strings" - "github.com/robertkrimen/otto" + "github.com/nektos/act/pkg/exprparser" log "github.com/sirupsen/logrus" ) -var expressionPattern, operatorPattern *regexp.Regexp - -func init() { - expressionPattern = regexp.MustCompile(`\${{\s*(.+?)\s*}}`) - operatorPattern = regexp.MustCompile("^[!=><|&]+$") -} - -// NewExpressionEvaluator creates a new evaluator -func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator { - vm := rc.newVM() - - return &expressionEvaluator{ - vm, - } -} - -// NewExpressionEvaluator creates a new evaluator -func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator { - vm := sc.RunContext.newVM() - configers := []func(*otto.Otto){ - sc.vmEnv(), - sc.vmInputs(), - - sc.vmNeeds(), - sc.vmSuccess(), - sc.vmFailure(), - } - for _, configer := range configers { - configer(vm) - } - - return &expressionEvaluator{ - vm, - } -} - // ExpressionEvaluator is the interface for evaluating expressions type ExpressionEvaluator interface { - Evaluate(string) (string, bool, error) + evaluate(string, bool) (interface{}, error) Interpolate(string) string - InterpolateWithStringCheck(string) (string, bool) - Rewrite(string) string -} - -type expressionEvaluator struct { - vm *otto.Otto -} - -func (ee *expressionEvaluator) Evaluate(in string) (string, bool, error) { - if strings.HasPrefix(in, `secrets.`) { - in = `secrets.` + strings.ToUpper(strings.SplitN(in, `.`, 2)[1]) - } - re := ee.Rewrite(in) - if re != in { - log.Debugf("Evaluating '%s' instead of '%s'", re, in) - } - - val, err := ee.vm.Run(re) - if err != nil { - return "", false, err - } - if val.IsNull() || val.IsUndefined() { - return "", false, nil - } - valAsString, err := val.ToString() - if err != nil { - return "", false, err - } - - return valAsString, val.IsString(), err } -func (ee *expressionEvaluator) Interpolate(in string) string { - interpolated, _ := ee.InterpolateWithStringCheck(in) - return interpolated -} - -func (ee *expressionEvaluator) InterpolateWithStringCheck(in string) (string, bool) { - errList := make([]error, 0) - - out := in - isString := false - for { - out = expressionPattern.ReplaceAllStringFunc(in, func(match string) string { - // Extract and trim the actual expression inside ${{...}} delimiters - expression := expressionPattern.ReplaceAllString(match, "$1") - - // Evaluate the expression and retrieve errors if any - evaluated, evaluatedIsString, err := ee.Evaluate(expression) - if err != nil { - errList = append(errList, err) - } - isString = evaluatedIsString - return evaluated - }) - if len(errList) > 0 { - log.Errorf("Unable to interpolate string '%s' - %v", in, errList) - break - } - if out == in { - // No replacement occurred, we're done! - break - } - in = out +// NewExpressionEvaluator creates a new evaluator +func (rc *RunContext) NewExpressionEvaluator() ExpressionEvaluator { + // todo: cleanup EvaluationEnvironment creation + job := rc.Run.Job() + strategy := make(map[string]interface{}) + if job.Strategy != nil { + strategy["fail-fast"] = job.Strategy.FailFast + strategy["max-parallel"] = job.Strategy.MaxParallel } - return out, isString -} -// Rewrite tries to transform any javascript property accessor into its bracket notation. -// For instance, "object.property" would become "object['property']". -func (ee *expressionEvaluator) Rewrite(in string) string { - var buf strings.Builder - r := strings.NewReader(in) - for { - c, _, err := r.ReadRune() - if err == io.EOF { - break - } - //nolint - switch { - default: - buf.WriteRune(c) - case c == '\'': - buf.WriteRune(c) - ee.advString(&buf, r) - case c == '.': - buf.WriteString("['") - ee.advPropertyName(&buf, r) - buf.WriteString("']") - } - } - return buf.String() -} + jobs := rc.Run.Workflow.Jobs + jobNeeds := rc.Run.Job().Needs() -func (*expressionEvaluator) advString(w *strings.Builder, r *strings.Reader) error { - for { - c, _, err := r.ReadRune() - if err != nil { - return err - } - if c != '\'' { - w.WriteRune(c) //nolint - continue - } - - // Handles a escaped string: ex. 'It''s ok' - c, _, err = r.ReadRune() - if err != nil { - w.WriteString("'") //nolint - return err - } - if c != '\'' { - w.WriteString("'") //nolint - if err := r.UnreadRune(); err != nil { - return err - } - break + using := make(map[string]map[string]map[string]string) + for _, needs := range jobNeeds { + using[needs] = map[string]map[string]string{ + "outputs": jobs[needs].Outputs, } - w.WriteString(`\'`) //nolint } - return nil -} -func (*expressionEvaluator) advPropertyName(w *strings.Builder, r *strings.Reader) error { - for { - c, _, err := r.ReadRune() - if err != nil { - return err - } - if !isLetter(c) { - if err := r.UnreadRune(); err != nil { - return err - } - break - } - w.WriteRune(c) //nolint + ee := &exprparser.EvaluationEnvironment{ + Github: rc.getGithubContext(), + Env: rc.GetEnv(), + Job: rc.getJobContext(), + // todo: should be unavailable + // but required to interpolate/evaluate the step outputs on the job + Steps: rc.getStepsContext(), + Runner: map[string]interface{}{ + "os": "Linux", + "temp": "/tmp", + "tool_cache": "/opt/hostedtoolcache", + }, + Secrets: rc.Config.Secrets, + Strategy: strategy, + Matrix: rc.Matrix, + Needs: using, + } + return expressionEvaluator{ + interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ + Run: rc.Run, + WorkingDir: rc.Config.Workdir, + Context: "job", + }), } - return nil } -func isLetter(c rune) bool { - switch { - case c >= 'a' && c <= 'z': - return true - case c >= 'A' && c <= 'Z': - return true - case c >= '0' && c <= '9': - return true - case c == '_' || c == '-': - return true - default: - return false +// NewExpressionEvaluator creates a new evaluator +func (sc *StepContext) NewExpressionEvaluator() ExpressionEvaluator { + rc := sc.RunContext + // todo: cleanup EvaluationEnvironment creation + job := rc.Run.Job() + strategy := make(map[string]interface{}) + if job.Strategy != nil { + strategy["fail-fast"] = job.Strategy.FailFast + strategy["max-parallel"] = job.Strategy.MaxParallel } -} -func (rc *RunContext) newVM() *otto.Otto { - configers := []func(*otto.Otto){ - vmContains, - vmStartsWith, - vmEndsWith, - vmFormat, - vmJoin, - vmToJSON, - vmFromJSON, - vmAlways, - rc.vmCancelled(), - rc.vmSuccess(), - rc.vmFailure(), - rc.vmHashFiles(), - - rc.vmGithub(), - rc.vmJob(), - rc.vmSteps(), - rc.vmRunner(), - - rc.vmSecrets(), - rc.vmStrategy(), - rc.vmMatrix(), - rc.vmEnv(), - rc.vmNeeds(), - } - vm := otto.New() - for _, configer := range configers { - configer(vm) - } - return vm -} - -func vmContains(vm *otto.Otto) { - _ = vm.Set("contains", func(searchString interface{}, searchValue string) bool { - if searchStringString, ok := searchString.(string); ok { - return strings.Contains(strings.ToLower(searchStringString), strings.ToLower(searchValue)) - } else if searchStringArray, ok := searchString.([]string); ok { - for _, s := range searchStringArray { - if strings.EqualFold(s, searchValue) { - return true - } - } - } - return false - }) -} - -func vmStartsWith(vm *otto.Otto) { - _ = vm.Set("startsWith", func(searchString string, searchValue string) bool { - return strings.HasPrefix(strings.ToLower(searchString), strings.ToLower(searchValue)) - }) -} - -func vmEndsWith(vm *otto.Otto) { - _ = vm.Set("endsWith", func(searchString string, searchValue string) bool { - return strings.HasSuffix(strings.ToLower(searchString), strings.ToLower(searchValue)) - }) -} - -func vmFormat(vm *otto.Otto) { - _ = vm.Set("format", func(s string, vals ...otto.Value) string { - ex := regexp.MustCompile(`(\{[0-9]+\}|\{.?|\}.?)`) - return ex.ReplaceAllStringFunc(s, func(seg string) string { - switch seg { - case "{{": - return "{" - case "}}": - return "}" - default: - if len(seg) < 3 || !strings.HasPrefix(seg, "{") { - log.Errorf("The following format string is invalid: '%v'", s) - return "" - } - _i := seg[1 : len(seg)-1] - i, err := strconv.ParseInt(_i, 10, 32) - if err != nil { - log.Errorf("The following format string is invalid: '%v'. Error: %v", s, err) - return "" - } - if i >= int64(len(vals)) { - log.Errorf("The following format string references more arguments than were supplied: '%v'", s) - return "" - } - if vals[i].IsNull() || vals[i].IsUndefined() { - return "" - } - return vals[i].String() - } - }) - }) -} - -func vmJoin(vm *otto.Otto) { - _ = vm.Set("join", func(element interface{}, optionalElem string) string { - slist := make([]string, 0) - if elementString, ok := element.(string); ok { - slist = append(slist, elementString) - } else if elementArray, ok := element.([]string); ok { - slist = append(slist, elementArray...) - } - if optionalElem != "" { - slist = append(slist, optionalElem) - } - return strings.Join(slist, " ") - }) -} - -func vmToJSON(vm *otto.Otto) { - toJSON := func(o interface{}) string { - rtn, err := json.MarshalIndent(o, "", " ") - if err != nil { - log.Errorf("Unable to marshal: %v", err) - return "" - } - return string(rtn) - } - _ = vm.Set("toJSON", toJSON) - _ = vm.Set("toJson", toJSON) -} + jobs := rc.Run.Workflow.Jobs + jobNeeds := rc.Run.Job().Needs() -func vmFromJSON(vm *otto.Otto) { - fromJSON := func(str string) interface{} { - var dat interface{} - err := json.Unmarshal([]byte(str), &dat) - if err != nil { - log.Errorf("Unable to unmarshal: %v", err) - return dat + using := make(map[string]map[string]map[string]string) + for _, needs := range jobNeeds { + using[needs] = map[string]map[string]string{ + "outputs": jobs[needs].Outputs, } - return dat - } - _ = vm.Set("fromJSON", fromJSON) - _ = vm.Set("fromJson", fromJSON) -} - -func (rc *RunContext) vmHashFiles() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("hashFiles", func(paths ...string) string { - var files []string - for i := range paths { - newFiles, err := filepath.Glob(filepath.Join(rc.Config.Workdir, paths[i])) - if err != nil { - log.Errorf("Unable to glob.Glob: %v", err) - return "" - } - files = append(files, newFiles...) - } - hasher := sha256.New() - for _, file := range files { - f, err := os.Open(file) - if err != nil { - log.Errorf("Unable to os.Open: %v", err) - } - if _, err := io.Copy(hasher, f); err != nil { - log.Errorf("Unable to io.Copy: %v", err) - } - if err := f.Close(); err != nil { - log.Errorf("Unable to Close file: %v", err) - } - } - return hex.EncodeToString(hasher.Sum(nil)) - }) - } -} - -func (rc *RunContext) vmSuccess() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("success", func() bool { - jobs := rc.Run.Workflow.Jobs - jobNeeds := rc.getNeedsTransitive(rc.Run.Job()) - - for _, needs := range jobNeeds { - if jobs[needs].Result != "success" { - return false - } - } - - return true - }) - } -} - -func (rc *RunContext) vmFailure() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("failure", func() bool { - jobs := rc.Run.Workflow.Jobs - jobNeeds := rc.getNeedsTransitive(rc.Run.Job()) - - for _, needs := range jobNeeds { - if jobs[needs].Result == "failure" { - return true - } - } - - return false - }) } -} - -func vmAlways(vm *otto.Otto) { - _ = vm.Set("always", func() bool { - return true - }) -} -func (rc *RunContext) vmCancelled() func(vm *otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("cancelled", func() bool { - return rc.getJobContext().Status == "cancelled" - }) - } -} -func (rc *RunContext) vmGithub() func(*otto.Otto) { - github := rc.getGithubContext() - - return func(vm *otto.Otto) { - _ = vm.Set("github", github) - } -} - -func (rc *RunContext) vmEnv() func(*otto.Otto) { - return func(vm *otto.Otto) { - env := rc.GetEnv() - log.Debugf("context env => %v", env) - _ = vm.Set("env", env) - } -} - -func (sc *StepContext) vmEnv() func(*otto.Otto) { - return func(vm *otto.Otto) { - log.Debugf("context env => %v", sc.Env) - _ = vm.Set("env", sc.Env) - } -} - -func (sc *StepContext) vmInputs() func(*otto.Otto) { inputs := make(map[string]string) - - // Set Defaults if sc.Action != nil { for k, input := range sc.Action.Inputs { - inputs[k] = sc.RunContext.NewExpressionEvaluator().Interpolate(input.Default) + inputs[k] = rc.NewExpressionEvaluator().Interpolate(input.Default) } } - - for k, v := range sc.Step.With { - inputs[k] = sc.RunContext.NewExpressionEvaluator().Interpolate(v) - } - - return func(vm *otto.Otto) { - _ = vm.Set("inputs", inputs) - } -} - -func (sc *StepContext) vmNeeds() func(*otto.Otto) { - jobs := sc.RunContext.Run.Workflow.Jobs - jobNeeds := sc.RunContext.Run.Job().Needs() - - using := make(map[string]map[string]map[string]string) - for _, needs := range jobNeeds { - using[needs] = map[string]map[string]string{ - "outputs": jobs[needs].Outputs, + if sc.Step != nil && sc.Step.With != nil { + for k, v := range sc.Step.With { + inputs[k] = rc.NewExpressionEvaluator().Interpolate(v) } } - return func(vm *otto.Otto) { - log.Debugf("context needs => %v", using) - _ = vm.Set("needs", using) - } -} - -func (sc *StepContext) vmSuccess() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("success", func() bool { - return sc.RunContext.getJobContext().Status == "success" - }) + ee := &exprparser.EvaluationEnvironment{ + Github: rc.getGithubContext(), + Env: rc.GetEnv(), + Job: rc.getJobContext(), + Steps: rc.getStepsContext(), + Runner: map[string]interface{}{ + "os": "Linux", + "temp": "/tmp", + "tool_cache": "/opt/hostedtoolcache", + }, + Secrets: rc.Config.Secrets, + Strategy: strategy, + Matrix: rc.Matrix, + Needs: using, + // todo: should be unavailable + // but required to interpolate/evaluate the inputs in actions/composite + Inputs: inputs, + } + return expressionEvaluator{ + interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ + Run: rc.Run, + WorkingDir: rc.Config.Workdir, + Context: "step", + }), } } -func (sc *StepContext) vmFailure() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("failure", func() bool { - return sc.RunContext.getJobContext().Status == "failure" - }) - } +type expressionEvaluator struct { + interpreter exprparser.Interpreter } -type vmNeedsStruct struct { - Outputs map[string]string `json:"outputs"` - Result string `json:"result"` +func (ee expressionEvaluator) evaluate(in string, isIfExpression bool) (interface{}, error) { + evaluated, err := ee.interpreter.Evaluate(in, isIfExpression) + return evaluated, err } -func (rc *RunContext) vmNeeds() func(*otto.Otto) { - return func(vm *otto.Otto) { - needsFunc := func() otto.Value { - jobs := rc.Run.Workflow.Jobs - jobNeeds := rc.Run.Job().Needs() - - using := make(map[string]vmNeedsStruct) - for _, needs := range jobNeeds { - using[needs] = vmNeedsStruct{ - Outputs: jobs[needs].Outputs, - Result: jobs[needs].Result, - } - } - - log.Debugf("context needs => %+v", using) - - value, err := vm.ToValue(using) - if err != nil { - return vm.MakeTypeError(err.Error()) - } - - return value - } - - // Results might change after the Otto VM was created - // and initialized. To access the current state - // we can't just pass a copy to Otto - instead we - // created a 'live-binding'. - // Technical Note: We don't want to pollute the global - // js namespace (and add things github actions hasn't) - // we delete the helper function after installing it - // as a getter. - global, _ := vm.Run("this") - _ = global.Object().Set("__needs__", needsFunc) - _, _ = vm.Run(` - (function (global) { - Object.defineProperty(global, 'needs', { get: global.__needs__ }); - delete global.__needs__; - })(this) - `) +func (ee expressionEvaluator) Interpolate(in string) string { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in } -} -func (rc *RunContext) vmJob() func(*otto.Otto) { - job := rc.getJobContext() + expr, _ := rewriteSubExpression(in, true) + if in != expr { + log.Debugf("expression '%s' rewritten to '%s'", in, expr) + } - return func(vm *otto.Otto) { - _ = vm.Set("job", job) + evaluated, err := ee.evaluate(expr, false) + if err != nil { + log.Errorf("Unable to interpolate expression '%s': %s", expr, err) + return "" } -} -func (rc *RunContext) vmSteps() func(*otto.Otto) { - ctxSteps := rc.getStepsContext() + log.Debugf("expression '%s' evaluated to '%s'", expr, evaluated) - steps := make(map[string]interface{}) - for id, ctxStep := range ctxSteps { - steps[id] = map[string]interface{}{ - "conclusion": ctxStep.Conclusion.String(), - "outcome": ctxStep.Outcome.String(), - "outputs": ctxStep.Outputs, - } + value, ok := evaluated.(string) + if !ok { + panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr)) } - return func(vm *otto.Otto) { - log.Debugf("context steps => %v", steps) - _ = vm.Set("steps", steps) - } + return value } -func (rc *RunContext) vmRunner() func(*otto.Otto) { - runner := map[string]interface{}{ - "os": "Linux", - "temp": "/tmp", - "tool_cache": "/opt/hostedtoolcache", +// EvalBool evaluates an expression against given evaluator +func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) { + nextExpr, _ := rewriteSubExpression(expr, false) + if expr != nextExpr { + log.Debugf("expression '%s' rewritten to '%s'", expr, nextExpr) } - return func(vm *otto.Otto) { - _ = vm.Set("runner", runner) + evaluated, err := evaluator.evaluate(nextExpr, true) + if err != nil { + return false, err + } + + var result bool + + switch t := evaluated.(type) { + case bool: + result = t + case string: + result = t != "" + case int: + result = t != 0 + case float64: + if math.IsNaN(t) { + result = false + } else { + result = t != 0 + } + default: + return false, fmt.Errorf("Unable to map return type to boolean for '%s'", expr) } -} -func (rc *RunContext) vmSecrets() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("secrets", rc.Config.Secrets) - } -} + log.Debugf("expression '%s' evaluated to '%t'", nextExpr, result) -func (rc *RunContext) vmStrategy() func(*otto.Otto) { - job := rc.Run.Job() - strategy := make(map[string]interface{}) - if job.Strategy != nil { - strategy["fail-fast"] = job.Strategy.FailFast - strategy["max-parallel"] = job.Strategy.MaxParallel - } - return func(vm *otto.Otto) { - _ = vm.Set("strategy", strategy) - } + return result, nil } -func (rc *RunContext) vmMatrix() func(*otto.Otto) { - return func(vm *otto.Otto) { - _ = vm.Set("matrix", rc.Matrix) +//nolint:gocyclo +func rewriteSubExpression(in string, forceFormat bool) (string, error) { + if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { + return in, nil } -} -// EvalBool evaluates an expression against given evaluator -func EvalBool(evaluator ExpressionEvaluator, expr string) (bool, error) { - if splitPattern == nil { - splitPattern = regexp.MustCompile(fmt.Sprintf(`%s|%s|\S+`, expressionPattern.String(), operatorPattern.String())) - } - if strings.HasPrefix(strings.TrimSpace(expr), "!") { - return false, errors.New("expressions starting with ! must be wrapped in ${{ }}") - } - if expr != "" { - parts := splitPattern.FindAllString(expr, -1) - var evaluatedParts []string - for i, part := range parts { - if operatorPattern.MatchString(part) { - evaluatedParts = append(evaluatedParts, part) - continue + strPattern := regexp.MustCompile("(?:''|[^'])*'") + pos := 0 + exprStart := -1 + strStart := -1 + var results []string + formatOut := "" + for pos < len(in) { + if strStart > -1 { + matches := strPattern.FindStringIndex(in[pos:]) + if matches == nil { + panic("unclosed string.") } - interpolatedPart, isString := evaluator.InterpolateWithStringCheck(part) - - // This peculiar transformation has to be done because the GitHub parser - // treats false returned from contexts as a string, not a boolean. - // Hence env.SOMETHING will be evaluated to true in an if: expression - // regardless if SOMETHING is set to false, true or any other string. - // It also handles some other weirdness that I found by trial and error. - if (expressionPattern.MatchString(part) && // it is an expression - !strings.Contains(part, "!")) && // but it's not negated - interpolatedPart == "false" && // and the interpolated string is false - (isString || previousOrNextPartIsAnOperator(i, parts)) { // and it's of type string or has an logical operator before or after - interpolatedPart = fmt.Sprintf("'%s'", interpolatedPart) // then we have to quote the false expression + strStart = -1 + pos += matches[1] + } else if exprStart > -1 { + exprEnd := strings.Index(in[pos:], "}}") + strStart = strings.Index(in[pos:], "'") + + if exprEnd > -1 && strStart > -1 { + if exprEnd < strStart { + strStart = -1 + } else { + exprEnd = -1 + } } - evaluatedParts = append(evaluatedParts, interpolatedPart) + if exprEnd > -1 { + formatOut += fmt.Sprintf("{%d}", len(results)) + results = append(results, strings.TrimSpace(in[exprStart:pos+exprEnd])) + pos += exprEnd + 2 + exprStart = -1 + } else if strStart > -1 { + pos += strStart + 1 + } else { + panic("unclosed expression.") + } + } else { + exprStart = strings.Index(in[pos:], "${{") + if exprStart != -1 { + formatOut += in[pos : pos+exprStart] + exprStart = pos + exprStart + 3 + pos = exprStart + } else { + formatOut += in[pos:] + pos = len(in) + } } + } - joined := strings.Join(evaluatedParts, " ") - v, _, err := evaluator.Evaluate(fmt.Sprintf("Boolean(%s)", joined)) - if err != nil { - return false, err - } - log.Debugf("expression '%s' evaluated to '%s'", expr, v) - return v == "true", nil + if len(results) == 1 && formatOut == "{0}" && !forceFormat { + return in, nil } - return true, nil + + return fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")), nil } diff --git a/pkg/runner/expression_test.go b/pkg/runner/expression_test.go index 2d77034078d..4846bd2967a 100644 --- a/pkg/runner/expression_test.go +++ b/pkg/runner/expression_test.go @@ -12,7 +12,7 @@ import ( yaml "gopkg.in/yaml.v3" ) -func TestEvaluate(t *testing.T) { +func createRunContext(t *testing.T) *RunContext { var yml yaml.Node err := yml.Encode(map[string][]interface{}{ "os": {"Linux", "Windows"}, @@ -20,7 +20,7 @@ func TestEvaluate(t *testing.T) { }) assert.NoError(t, err) - rc := &RunContext{ + return &RunContext{ Config: &Config{ Workdir: ".", Secrets: map[string]string{ @@ -71,54 +71,50 @@ func TestEvaluate(t *testing.T) { }, }, } +} + +func TestEvaluateRunContext(t *testing.T) { + rc := createRunContext(t) ee := rc.NewExpressionEvaluator() tables := []struct { in string - out string + out interface{} errMesg string }{ - {" 1 ", "1", ""}, - {"1 + 3", "4", ""}, - {"(1 + 3) * -2", "-8", ""}, + {" 1 ", 1, ""}, + // {"1 + 3", "4", ""}, + // {"(1 + 3) * -2", "-8", ""}, {"'my text'", "my text", ""}, - {"contains('my text', 'te')", "true", ""}, - {"contains('my TEXT', 'te')", "true", ""}, - {"contains(['my text'], 'te')", "false", ""}, - {"contains(['foo','bar'], 'bar')", "true", ""}, - {"startsWith('hello world', 'He')", "true", ""}, - {"endsWith('hello world', 'ld')", "true", ""}, + {"contains('my text', 'te')", true, ""}, + {"contains('my TEXT', 'te')", true, ""}, + {"contains(fromJSON('[\"my text\"]'), 'te')", false, ""}, + {"contains(fromJSON('[\"foo\",\"bar\"]'), 'bar')", true, ""}, + {"startsWith('hello world', 'He')", true, ""}, + {"endsWith('hello world', 'ld')", true, ""}, {"format('0:{0} 2:{2} 1:{1}', 'zero', 'one', 'two')", "0:zero 2:two 1:one", ""}, - {"join(['hello'],'octocat')", "hello octocat", ""}, - {"join(['hello','mona','the'],'octocat')", "hello mona the octocat", ""}, - {"join('hello','mona')", "hello mona", ""}, - {"toJSON({'foo':'bar'})", "{\n \"foo\": \"bar\"\n}", ""}, - {"toJson({'foo':'bar'})", "{\n \"foo\": \"bar\"\n}", ""}, + {"join(fromJSON('[\"hello\"]'),'octocat')", "hello", ""}, + {"join(fromJSON('[\"hello\",\"mona\",\"the\"]'),'octocat')", "hellooctocatmonaoctocatthe", ""}, + {"join('hello','mona')", "hello", ""}, + {"toJSON(env)", "{\n \"ACT\": \"true\",\n \"key\": \"value\"\n}", ""}, + {"toJson(env)", "{\n \"ACT\": \"true\",\n \"key\": \"value\"\n}", ""}, {"(fromJSON('{\"foo\":\"bar\"}')).foo", "bar", ""}, {"(fromJson('{\"foo\":\"bar\"}')).foo", "bar", ""}, {"(fromJson('[\"foo\",\"bar\"]'))[1]", "bar", ""}, - {"hashFiles('**/non-extant-files')", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ""}, - {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ""}, - {"hashFiles('**/non.extant.files')", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ""}, - {"hashFiles('**/non''extant''files')", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", ""}, - {"success()", "true", ""}, - {"failure()", "false", ""}, - {"always()", "true", ""}, - {"cancelled()", "false", ""}, + // github does return an empty string for non-existent files + {"hashFiles('**/non-extant-files')", "", ""}, + {"hashFiles('**/non-extant-files', '**/more-non-extant-files')", "", ""}, + {"hashFiles('**/non.extant.files')", "", ""}, + {"hashFiles('**/non''extant''files')", "", ""}, + {"success()", true, ""}, + {"failure()", false, ""}, + {"always()", true, ""}, + {"cancelled()", false, ""}, {"github.workflow", "test-workflow", ""}, {"github.actor", "nektos/act", ""}, {"github.run_id", "1", ""}, {"github.run_number", "1", ""}, {"job.status", "success", ""}, - {"steps.idwithnothing.conclusion", "success", ""}, - {"steps.idwithnothing.outcome", "failure", ""}, - {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing", ""}, - {"steps.id-with-hyphens.conclusion", "success", ""}, - {"steps.id-with-hyphens.outcome", "failure", ""}, - {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens", ""}, - {"steps.id_with_underscores.conclusion", "success", ""}, - {"steps.id_with_underscores.outcome", "failure", ""}, - {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""}, {"runner.os", "Linux", ""}, {"matrix.os", "Linux", ""}, {"matrix.foo", "bar", ""}, @@ -139,7 +135,47 @@ func TestEvaluate(t *testing.T) { table := table t.Run(table.in, func(t *testing.T) { assertObject := assert.New(t) - out, _, err := ee.Evaluate(table.in) + out, err := ee.evaluate(table.in, false) + if table.errMesg == "" { + assertObject.NoError(err, table.in) + assertObject.Equal(table.out, out, table.in) + } else { + assertObject.Error(err, table.in) + assertObject.Equal(table.errMesg, err.Error(), table.in) + } + }) + } +} + +func TestEvaluateStepContext(t *testing.T) { + rc := createRunContext(t) + + sc := &StepContext{ + RunContext: rc, + } + ee := sc.NewExpressionEvaluator() + + tables := []struct { + in string + out interface{} + errMesg string + }{ + {"steps.idwithnothing.conclusion", model.StepStatusSuccess, ""}, + {"steps.idwithnothing.outcome", model.StepStatusFailure, ""}, + {"steps.idwithnothing.outputs.foowithnothing", "barwithnothing", ""}, + {"steps.id-with-hyphens.conclusion", model.StepStatusSuccess, ""}, + {"steps.id-with-hyphens.outcome", model.StepStatusFailure, ""}, + {"steps.id-with-hyphens.outputs.foo-with-hyphens", "bar-with-hyphens", ""}, + {"steps.id_with_underscores.conclusion", model.StepStatusSuccess, ""}, + {"steps.id_with_underscores.outcome", model.StepStatusFailure, ""}, + {"steps.id_with_underscores.outputs.foo_with_underscores", "bar_with_underscores", ""}, + } + + for _, table := range tables { + table := table + t.Run(table.in, func(t *testing.T) { + assertObject := assert.New(t) + out, err := ee.evaluate(table.in, false) if table.errMesg == "" { assertObject.NoError(err, table.in) assertObject.Equal(table.out, out, table.in) @@ -181,7 +217,12 @@ func TestInterpolate(t *testing.T) { in string out string }{ - {" ${{1}} to ${{2}} ", " 1 to 2 "}, + {" text ", " text "}, + {" $text ", " $text "}, + {" ${text} ", " ${text} "}, + {" ${{ 1 }} to ${{2}} ", " 1 to 2 "}, + {" ${{ (true || false) }} to ${{2}} ", " true to 2 "}, + {" ${{ (false || '}}' ) }} to ${{2}} ", " }} to 2 "}, {" ${{ env.KEYWITHNOTHING }} ", " valuewithnothing "}, {" ${{ env.KEY-WITH-HYPHENS }} ", " value-with-hyphens "}, {" ${{ env.KEY_WITH_UNDERSCORES }} ", " value_with_underscores "}, @@ -205,12 +246,13 @@ func TestInterpolate(t *testing.T) { {"${{ env.SOMETHING_TRUE || false }}", "true"}, {"${{ env.SOMETHING_FALSE || false }}", "false"}, {"${{ env.SOMETHING_FALSE }} && ${{ env.SOMETHING_TRUE }}", "false && true"}, + {"${{ fromJSON('{}') < 2 }}", "false"}, } updateTestExpressionWorkflow(t, tables, rc) for _, table := range tables { table := table - t.Run(table.in, func(t *testing.T) { + t.Run("interpolate", func(t *testing.T) { assertObject := assert.New(t) out := ee.Interpolate(table.in) assertObject.Equal(table.out, out, table.in) @@ -232,6 +274,7 @@ func updateTestExpressionWorkflow(t *testing.T, tables []struct { envs += fmt.Sprintf(" %s: %s\n", k, rc.Env[k]) } + // editorconfig-checker-disable workflow := fmt.Sprintf(` name: "Test how expressions are handled on GitHub" on: push @@ -244,8 +287,9 @@ jobs: runs-on: ubuntu-latest steps: `, envs) + // editorconfig-checker-enable for _, table := range tables { - expressionPattern = regexp.MustCompile(`\${{\s*(.+?)\s*}}`) + expressionPattern := regexp.MustCompile(`\${{\s*(.+?)\s*}}`) expr := expressionPattern.ReplaceAllStringFunc(table.in, func(match string) string { return fmt.Sprintf("€{{ %s }}", expressionPattern.ReplaceAllString(match, "$1")) @@ -266,43 +310,56 @@ jobs: } } -func TestRewrite(t *testing.T) { - rc := &RunContext{ - Config: &Config{}, - Run: &model.Run{ - JobID: "job1", - Workflow: &model.Workflow{ - Jobs: map[string]*model.Job{ - "job1": {}, - }, - }, - }, +func TestRewriteSubExpression(t *testing.T) { + table := []struct { + in string + out string + }{ + {in: "Hello World", out: "Hello World"}, + {in: "${{ true }}", out: "${{ true }}"}, + {in: "${{ true }} ${{ true }}", out: "format('{0} {1}', true, true)"}, + {in: "${{ true || false }} ${{ true && true }}", out: "format('{0} {1}', true || false, true && true)"}, + {in: "${{ '}}' }}", out: "${{ '}}' }}"}, + {in: "${{ '''}}''' }}", out: "${{ '''}}''' }}"}, + {in: "${{ '''' }}", out: "${{ '''' }}"}, + {in: `${{ fromJSON('"}}"') }}`, out: `${{ fromJSON('"}}"') }}`}, + {in: `${{ fromJSON('"\"}}\""') }}`, out: `${{ fromJSON('"\"}}\""') }}`}, + {in: `${{ fromJSON('"''}}"') }}`, out: `${{ fromJSON('"''}}"') }}`}, + {in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"}, } - ee := rc.NewExpressionEvaluator() - tables := []struct { - in string - re string + for _, table := range table { + t.Run("TestRewriteSubExpression", func(t *testing.T) { + assertObject := assert.New(t) + out, err := rewriteSubExpression(table.in, false) + if err != nil { + t.Fatal(err) + } + assertObject.Equal(table.out, out, table.in) + }) + } +} + +func TestRewriteSubExpressionForceFormat(t *testing.T) { + table := []struct { + in string + out string }{ - {"ecole", "ecole"}, - {"ecole.centrale", "ecole['centrale']"}, - {"ecole['centrale']", "ecole['centrale']"}, - {"ecole.centrale.paris", "ecole['centrale']['paris']"}, - {"ecole['centrale'].paris", "ecole['centrale']['paris']"}, - {"ecole.centrale['paris']", "ecole['centrale']['paris']"}, - {"ecole['centrale']['paris']", "ecole['centrale']['paris']"}, - {"ecole.centrale-paris", "ecole['centrale-paris']"}, - {"ecole['centrale-paris']", "ecole['centrale-paris']"}, - {"ecole.centrale_paris", "ecole['centrale_paris']"}, - {"ecole['centrale_paris']", "ecole['centrale_paris']"}, + {in: "Hello World", out: "Hello World"}, + {in: "${{ true }}", out: "format('{0}', true)"}, + {in: "${{ '}}' }}", out: "format('{0}', '}}')"}, + {in: `${{ fromJSON('"}}"') }}`, out: `format('{0}', fromJSON('"}}"'))`}, + {in: "Hello ${{ 'World' }}", out: "format('Hello {0}', 'World')"}, } - for _, table := range tables { - table := table - t.Run(table.in, func(t *testing.T) { + for _, table := range table { + t.Run("TestRewriteSubExpressionForceFormat", func(t *testing.T) { assertObject := assert.New(t) - re := ee.Rewrite(table.in) - assertObject.Equal(table.re, re, table.in) + out, err := rewriteSubExpression(table.in, true) + if err != nil { + t.Fatal(err) + } + assertObject.Equal(table.out, out, table.in) }) } } diff --git a/pkg/runner/run_context.go b/pkg/runner/run_context.go index 218edf530e6..948c896e7b6 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -394,19 +394,6 @@ func (rc *RunContext) isEnabled(ctx context.Context) bool { return true } -var splitPattern *regexp.Regexp - -func previousOrNextPartIsAnOperator(i int, parts []string) bool { - operator := false - if i > 0 { - operator = operatorPattern.MatchString(parts[i-1]) - } - if i+1 < len(parts) { - operator = operator || operatorPattern.MatchString(parts[i+1]) - } - return operator -} - func mergeMaps(maps ...map[string]string) map[string]string { rtnMap := make(map[string]string) for _, m := range maps { @@ -741,12 +728,11 @@ func (rc *RunContext) handleCredentials() (username, password string, err error) } ee := rc.NewExpressionEvaluator() - var ok bool - if username, ok = ee.InterpolateWithStringCheck(container.Credentials["username"]); !ok { + if username = ee.Interpolate(container.Credentials["username"]); username == "" { err = fmt.Errorf("failed to interpolate container.credentials.username") return } - if password, ok = ee.InterpolateWithStringCheck(container.Credentials["password"]); !ok { + if password = ee.Interpolate(container.Credentials["password"]); password == "" { err = fmt.Errorf("failed to interpolate container.credentials.password") return } diff --git a/pkg/runner/run_context_test.go b/pkg/runner/run_context_test.go index b0b9c768d03..730d8de175d 100644 --- a/pkg/runner/run_context_test.go +++ b/pkg/runner/run_context_test.go @@ -75,14 +75,16 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "success()", out: true}, {in: "cancelled()", out: false}, {in: "always()", out: true}, - {in: "steps.id1.conclusion == 'success'", out: true}, - {in: "steps.id1.conclusion != 'success'", out: false}, - {in: "steps.id1.outcome == 'failure'", out: true}, - {in: "steps.id1.outcome != 'failure'", out: false}, + // TODO: move to sc.NewExpressionEvaluator(), because "steps" context is not available here + // {in: "steps.id1.conclusion == 'success'", out: true}, + // {in: "steps.id1.conclusion != 'success'", out: false}, + // {in: "steps.id1.outcome == 'failure'", out: true}, + // {in: "steps.id1.outcome != 'failure'", out: false}, {in: "true", out: true}, {in: "false", out: false}, - {in: "!true", wantErr: true}, - {in: "!false", wantErr: true}, + // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not + // {in: "!true", wantErr: true}, + // {in: "!false", wantErr: true}, {in: "1 != 0", out: true}, {in: "1 != 1", out: false}, {in: "${{ 1 != 0 }}", out: true}, @@ -100,14 +102,15 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "env.UNKNOWN == 'true'", out: false}, {in: "env.UNKNOWN", out: false}, // Inline expressions - {in: "env.SOME_TEXT", out: true}, // this is because Boolean('text') is true in Javascript + {in: "env.SOME_TEXT", out: true}, {in: "env.SOME_TEXT == 'text'", out: true}, {in: "env.SOMETHING_TRUE == 'true'", out: true}, {in: "env.SOMETHING_FALSE == 'true'", out: false}, {in: "env.SOMETHING_TRUE", out: true}, - {in: "env.SOMETHING_FALSE", out: true}, // this is because Boolean('text') is true in Javascript - {in: "!env.SOMETHING_TRUE", wantErr: true}, - {in: "!env.SOMETHING_FALSE", wantErr: true}, + {in: "env.SOMETHING_FALSE", out: true}, + // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not + // {in: "!env.SOMETHING_TRUE", wantErr: true}, + // {in: "!env.SOMETHING_FALSE", wantErr: true}, {in: "${{ !env.SOMETHING_TRUE }}", out: false}, {in: "${{ !env.SOMETHING_FALSE }}", out: false}, {in: "${{ ! env.SOMETHING_TRUE }}", out: false}, @@ -123,7 +126,8 @@ func TestRunContext_EvalBool(t *testing.T) { {in: "${{ env.SOMETHING_TRUE && true }}", out: true}, {in: "${{ env.SOMETHING_FALSE || true }}", out: true}, {in: "${{ env.SOMETHING_FALSE || false }}", out: true}, - {in: "!env.SOMETHING_TRUE || true", wantErr: true}, + // TODO: This does not throw an error, because the evaluator does not know if the expression is inside ${{ }} or not + // {in: "!env.SOMETHING_TRUE || true", wantErr: true}, {in: "${{ env.SOMETHING_TRUE == 'true'}}", out: true}, {in: "${{ env.SOMETHING_FALSE == 'true'}}", out: false}, {in: "${{ env.SOMETHING_FALSE == 'false'}}", out: true}, @@ -198,7 +202,7 @@ jobs: if table.wantErr || strings.HasPrefix(table.in, "github.actor") { continue } - expressionPattern = regexp.MustCompile(`\${{\s*(.+?)\s*}}`) + expressionPattern := regexp.MustCompile(`\${{\s*(.+?)\s*}}`) expr := expressionPattern.ReplaceAllStringFunc(table.in, func(match string) string { return fmt.Sprintf("€{{ %s }}", expressionPattern.ReplaceAllString(match, "$1"))