Refactor expression evaluator to use parser from actionlint package (#908)
* feat: implement expression evaluator Co-authored-by: Markus Wolf <markus.wolf@new-work.se> Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se> * feat: integrate exprparser into act Co-authored-by: Markus Wolf <markus.wolf@new-work.se> Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se> * Escape { and }, do not fail on missing properties * Fix empty inputs context * fix: contains() comparison for complex values Co-authored-by: Markus Wolf <markus.wolf@new-work.se> Co-authored-by: Philipp Hinrichsen <philipp.hinrichsen@new-work.se> Co-authored-by: Christopher Homberger <christopher.homberger@web.de>
This commit is contained in:
		| @@ -1,460 +1,33 @@ | ||||
| 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("^[!=><|&]+$") | ||||
| // ExpressionEvaluator is the interface for evaluating expressions | ||||
| type ExpressionEvaluator interface { | ||||
| 	evaluate(string, bool) (interface{}, error) | ||||
| 	Interpolate(string) string | ||||
| } | ||||
|  | ||||
| // 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.vmNeeds(), | ||||
| 		sc.vmSuccess(), | ||||
| 		sc.vmFailure(), | ||||
| 	} | ||||
| 	for _, configer := range configers { | ||||
| 		configer(vm) | ||||
| 	// 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 &expressionEvaluator{ | ||||
| 		vm, | ||||
| 	} | ||||
| } | ||||
|  | ||||
| // ExpressionEvaluator is the interface for evaluating expressions | ||||
| type ExpressionEvaluator interface { | ||||
| 	Evaluate(string) (string, bool, 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 | ||||
| 	} | ||||
| 	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() | ||||
| } | ||||
|  | ||||
| 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) | ||||
| 			continue | ||||
| 		} | ||||
|  | ||||
| 		// Handles a escaped string: ex. 'It''s ok' | ||||
| 		c, _, err = r.ReadRune() | ||||
| 		if err != nil { | ||||
| 			w.WriteString("'") | ||||
| 			return err | ||||
| 		} | ||||
| 		if c != '\'' { | ||||
| 			w.WriteString("'") | ||||
| 			if err := r.UnreadRune(); err != nil { | ||||
| 				return err | ||||
| 			} | ||||
| 			break | ||||
| 		} | ||||
| 		w.WriteString(`\'`) | ||||
| 	} | ||||
| 	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) | ||||
| 	} | ||||
| 	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 | ||||
| 	} | ||||
| } | ||||
|  | ||||
| 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(), | ||||
| 		rc.vmInputs(), | ||||
| 	} | ||||
| 	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) | ||||
| } | ||||
|  | ||||
| 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 | ||||
| 		} | ||||
| 		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 (rc *RunContext) vmInputs() func(*otto.Otto) { | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("inputs", rc.Inputs) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (sc *StepContext) vmNeeds() func(*otto.Otto) { | ||||
| 	jobs := sc.RunContext.Run.Workflow.Jobs | ||||
| 	jobNeeds := sc.RunContext.Run.Job().Needs() | ||||
| 	jobs := rc.Run.Workflow.Jobs | ||||
| 	jobNeeds := rc.Run.Job().Needs() | ||||
|  | ||||
| 	using := make(map[string]map[string]map[string]string) | ||||
| 	for _, needs := range jobNeeds { | ||||
| @@ -463,182 +36,225 @@ func (sc *StepContext) vmNeeds() func(*otto.Otto) { | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		log.Debugf("context needs => %v", using) | ||||
| 		_ = vm.Set("needs", using) | ||||
| 	secrets := rc.Config.Secrets | ||||
| 	if rc.Composite != nil { | ||||
| 		secrets = nil | ||||
| 	} | ||||
|  | ||||
| 	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:  secrets, | ||||
| 		Strategy: strategy, | ||||
| 		Matrix:   rc.Matrix, | ||||
| 		Needs:    using, | ||||
| 		Inputs:   rc.Inputs, | ||||
| 	} | ||||
| 	return expressionEvaluator{ | ||||
| 		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ | ||||
| 			Run:        rc.Run, | ||||
| 			WorkingDir: rc.Config.Workdir, | ||||
| 			Context:    "job", | ||||
| 		}), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (sc *StepContext) vmSuccess() func(*otto.Otto) { | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("success", func() bool { | ||||
| 			return sc.RunContext.getJobContext().Status == "success" | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (sc *StepContext) vmFailure() func(*otto.Otto) { | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("failure", func() bool { | ||||
| 			return sc.RunContext.getJobContext().Status == "failure" | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| type vmNeedsStruct struct { | ||||
| 	Outputs map[string]string `json:"outputs"` | ||||
| 	Result  string            `json:"result"` | ||||
| } | ||||
|  | ||||
| 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 (rc *RunContext) vmJob() func(*otto.Otto) { | ||||
| 	job := rc.getJobContext() | ||||
|  | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("job", job) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (rc *RunContext) vmSteps() func(*otto.Otto) { | ||||
| 	ctxSteps := rc.getStepsContext() | ||||
|  | ||||
| 	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, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		log.Debugf("context steps => %v", steps) | ||||
| 		_ = vm.Set("steps", steps) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (rc *RunContext) vmRunner() func(*otto.Otto) { | ||||
| 	runner := map[string]interface{}{ | ||||
| 		"os":         "Linux", | ||||
| 		"temp":       "/tmp", | ||||
| 		"tool_cache": "/opt/hostedtoolcache", | ||||
| 	} | ||||
|  | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("runner", runner) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (rc *RunContext) vmSecrets() func(*otto.Otto) { | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		// Hide secrets from composite actions | ||||
| 		if rc.Composite == nil { | ||||
| 			_ = vm.Set("secrets", rc.Config.Secrets) | ||||
| 		} | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (rc *RunContext) vmStrategy() func(*otto.Otto) { | ||||
| // 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 | ||||
| 	} | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("strategy", strategy) | ||||
|  | ||||
| 	jobs := rc.Run.Workflow.Jobs | ||||
| 	jobNeeds := rc.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, | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	secrets := rc.Config.Secrets | ||||
| 	if rc.Composite != nil { | ||||
| 		secrets = nil | ||||
| 	} | ||||
|  | ||||
| 	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:  secrets, | ||||
| 		Strategy: strategy, | ||||
| 		Matrix:   rc.Matrix, | ||||
| 		Needs:    using, | ||||
| 		// todo: should be unavailable | ||||
| 		// but required to interpolate/evaluate the inputs in actions/composite | ||||
| 		Inputs: rc.Inputs, | ||||
| 	} | ||||
| 	return expressionEvaluator{ | ||||
| 		interpreter: exprparser.NewInterpeter(ee, exprparser.Config{ | ||||
| 			Run:        rc.Run, | ||||
| 			WorkingDir: rc.Config.Workdir, | ||||
| 			Context:    "step", | ||||
| 		}), | ||||
| 	} | ||||
| } | ||||
|  | ||||
| func (rc *RunContext) vmMatrix() func(*otto.Otto) { | ||||
| 	return func(vm *otto.Otto) { | ||||
| 		_ = vm.Set("matrix", rc.Matrix) | ||||
| type expressionEvaluator struct { | ||||
| 	interpreter exprparser.Interpreter | ||||
| } | ||||
|  | ||||
| func (ee expressionEvaluator) evaluate(in string, isIfExpression bool) (interface{}, error) { | ||||
| 	evaluated, err := ee.interpreter.Evaluate(in, isIfExpression) | ||||
| 	return evaluated, err | ||||
| } | ||||
|  | ||||
| func (ee expressionEvaluator) Interpolate(in string) string { | ||||
| 	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { | ||||
| 		return in | ||||
| 	} | ||||
|  | ||||
| 	expr, _ := rewriteSubExpression(in, true) | ||||
| 	if in != expr { | ||||
| 		log.Debugf("expression '%s' rewritten to '%s'", in, expr) | ||||
| 	} | ||||
|  | ||||
| 	evaluated, err := ee.evaluate(expr, false) | ||||
| 	if err != nil { | ||||
| 		log.Errorf("Unable to interpolate expression '%s': %s", expr, err) | ||||
| 		return "" | ||||
| 	} | ||||
|  | ||||
| 	log.Debugf("expression '%s' evaluated to '%s'", expr, evaluated) | ||||
|  | ||||
| 	value, ok := evaluated.(string) | ||||
| 	if !ok { | ||||
| 		panic(fmt.Sprintf("Expression %s did not evaluate to a string", expr)) | ||||
| 	} | ||||
|  | ||||
| 	return value | ||||
| } | ||||
|  | ||||
| // 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())) | ||||
| 	nextExpr, _ := rewriteSubExpression(expr, false) | ||||
| 	if expr != nextExpr { | ||||
| 		log.Debugf("expression '%s' rewritten to '%s'", expr, nextExpr) | ||||
| 	} | ||||
| 	if strings.HasPrefix(strings.TrimSpace(expr), "!") { | ||||
| 		return false, errors.New("expressions starting with ! must be wrapped in ${{ }}") | ||||
|  | ||||
| 	evaluated, err := evaluator.evaluate(nextExpr, true) | ||||
| 	if err != nil { | ||||
| 		return false, err | ||||
| 	} | ||||
| 	if expr != "" { | ||||
| 		parts := splitPattern.FindAllString(expr, -1) | ||||
| 		var evaluatedParts []string | ||||
| 		for i, part := range parts { | ||||
| 			if operatorPattern.MatchString(part) { | ||||
| 				evaluatedParts = append(evaluatedParts, part) | ||||
| 				continue | ||||
| 			} | ||||
|  | ||||
| 			interpolatedPart, isString := evaluator.InterpolateWithStringCheck(part) | ||||
| 	var result bool | ||||
|  | ||||
| 			// 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 | ||||
| 			} | ||||
|  | ||||
| 			evaluatedParts = append(evaluatedParts, interpolatedPart) | ||||
| 	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 | ||||
| 		} | ||||
|  | ||||
| 		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 | ||||
| 	default: | ||||
| 		return false, fmt.Errorf("Unable to map return type to boolean for '%s'", expr) | ||||
| 	} | ||||
| 	return true, nil | ||||
|  | ||||
| 	log.Debugf("expression '%s' evaluated to '%t'", nextExpr, result) | ||||
|  | ||||
| 	return result, nil | ||||
| } | ||||
|  | ||||
| func escapeFormatString(in string) string { | ||||
| 	return strings.ReplaceAll(strings.ReplaceAll(in, "{", "{{"), "}", "}}") | ||||
| } | ||||
|  | ||||
| //nolint:gocyclo | ||||
| func rewriteSubExpression(in string, forceFormat bool) (string, error) { | ||||
| 	if !strings.Contains(in, "${{") || !strings.Contains(in, "}}") { | ||||
| 		return in, nil | ||||
| 	} | ||||
|  | ||||
| 	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.") | ||||
| 			} | ||||
|  | ||||
| 			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 | ||||
| 				} | ||||
| 			} | ||||
|  | ||||
| 			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 += escapeFormatString(in[pos : pos+exprStart]) | ||||
| 				exprStart = pos + exprStart + 3 | ||||
| 				pos = exprStart | ||||
| 			} else { | ||||
| 				formatOut += escapeFormatString(in[pos:]) | ||||
| 				pos = len(in) | ||||
| 			} | ||||
| 		} | ||||
| 	} | ||||
|  | ||||
| 	if len(results) == 1 && formatOut == "{0}" && !forceFormat { | ||||
| 		return in, nil | ||||
| 	} | ||||
|  | ||||
| 	return fmt.Sprintf("format('%s', %s)", strings.ReplaceAll(formatOut, "'", "''"), strings.Join(results, ", ")), nil | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user