From eba71f98fe54beb7d62a18d0d6edecef1fea3ffb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Brauer?= Date: Fri, 21 Jan 2022 17:07:20 +0100 Subject: [PATCH] Refactor expression evaluator to use parser from actionlint package (#908) * feat: implement expression evaluator Co-authored-by: Markus Wolf Co-authored-by: Philipp Hinrichsen * feat: integrate exprparser into act Co-authored-by: Markus Wolf Co-authored-by: Philipp Hinrichsen * Escape { and }, do not fail on missing properties * Fix empty inputs context * fix: contains() comparison for complex values Co-authored-by: Markus Wolf Co-authored-by: Philipp Hinrichsen Co-authored-by: Christopher Homberger --- go.mod | 5 +- go.sum | 25 +- pkg/exprparser/functions.go | 277 ++++++++ pkg/exprparser/functions_test.go | 245 +++++++ pkg/exprparser/interpreter.go | 576 +++++++++++++++ pkg/exprparser/interpreter_test.go | 581 ++++++++++++++++ pkg/exprparser/testdata/for-hashing-1.txt | 1 + pkg/exprparser/testdata/for-hashing-2.txt | 1 + pkg/runner/expression.go | 808 ++++++---------------- pkg/runner/expression_test.go | 197 ++++-- pkg/runner/run_context.go | 29 +- pkg/runner/run_context_test.go | 28 +- 12 files changed, 2056 insertions(+), 717 deletions(-) create mode 100644 pkg/exprparser/functions.go create mode 100644 pkg/exprparser/functions_test.go create mode 100644 pkg/exprparser/interpreter.go create mode 100644 pkg/exprparser/interpreter_test.go create mode 100644 pkg/exprparser/testdata/for-hashing-1.txt create mode 100644 pkg/exprparser/testdata/for-hashing-2.txt diff --git a/go.mod b/go.mod index 81f8cb7..e97adb4 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,6 @@ require ( github.com/julienschmidt/httprouter v1.3.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/kevinburke/ssh_config v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.9 // indirect github.com/mattn/go-isatty v0.0.14 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect github.com/mitchellh/go-homedir v1.1.0 @@ -31,7 +30,7 @@ require ( github.com/opencontainers/runc v1.0.2 // indirect github.com/opencontainers/selinux v1.10.0 github.com/pkg/errors v0.9.1 - github.com/robertkrimen/otto v0.0.0-20210614181706-373ff5438452 + github.com/rhysd/actionlint v1.6.8 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 @@ -41,10 +40,8 @@ require ( github.com/xanzy/ssh-agent v0.3.1 // indirect golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 // indirect golang.org/x/net v0.0.0-20210917221730-978cfadd31cf // indirect - golang.org/x/sys v0.0.0-20210923061019-b8560ed6a9b7 // indirect 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 4615e8a..a401863 100644 --- a/go.sum +++ b/go.sum @@ -425,6 +425,7 @@ github.com/docker/spdystream v0.0.0-20160310174837-449fdfce4d96/go.mod h1:Qh8CwZ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dvyukov/go-fuzz v0.0.0-20210914135545-4980593459a1/go.mod h1:11Gm+ccJnvAhCNLlf5+cS9KjtbaD5I5zaZpFMsTHWTw= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= @@ -446,6 +447,8 @@ github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLi github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoDwaVPLA+WlJuPGy+QneDUgJi2k= @@ -825,8 +828,9 @@ github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcncea github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= +github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= github.com/mattn/go-ieproxy v0.0.1/go.mod h1:pYabZ6IHcRpFh7vIaLfK7rdcWgFEb3SFJ6/gNWuh88E= @@ -839,6 +843,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -1027,8 +1033,12 @@ github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1: github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20170806203942-52369c62f446/go.mod h1:uYEyJGbgTkfkS4+E/PavXkNJcbFIpEtjt2B0KDQ5+9M= -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/rhysd/actionlint v1.6.8 h1:li0691FNuuS3da2igfjMb9M58AgMXX7j9U5EgbCZFuc= +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/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= github.com/rogpeppe/fastuuid v1.1.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= @@ -1189,6 +1199,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= github.com/yvasiyarov/gorelic v0.0.0-20141212073537-a9bba5b9ab50/go.mod h1:NUSPSUX/bi6SeDMUh6brw0nXpxHnc96TguQh0+r/ssA= github.com/yvasiyarov/newrelic_platform_go v0.0.0-20140908184405-b21fdbd4370f/go.mod h1:GlGEuHIJweS1mbCqG+7vt2nvWLzLLnRHbXz5JKd/Qbg= @@ -1395,6 +1406,7 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1494,8 +1506,9 @@ golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210923061019-b8560ed6a9b7 h1:c20P3CcPbopVp2f7099WLOqSNKURf30Z0uq66HpijZY= -golang.org/x/sys v0.0.0-20210923061019-b8560ed6a9b7/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211113001501-0c823b97ae02 h1:7NCfEGl0sfUojmX78nK9pBJuUlSZWEJA/TwASvfiPLo= +golang.org/x/sys v0.0.0-20211113001501-0c823b97ae02/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= @@ -1767,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/exprparser/functions.go b/pkg/exprparser/functions.go new file mode 100644 index 0000000..64103e0 --- /dev/null +++ b/pkg/exprparser/functions.go @@ -0,0 +1,277 @@ +package exprparser + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + + "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 "", 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 filepaths []string + + for _, path := range paths { + if path.Kind() == reflect.String { + filepaths = append(filepaths, path.String()) + } else { + return "", fmt.Errorf("Non-string path passed to hashFiles") + } + } + + var files []string + + for i := range filepaths { + newFiles, err := filepath.Glob(filepath.Join(impl.config.WorkingDir, filepaths[i])) + if err != nil { + return "", fmt.Errorf("Unable to glob.Glob: %v", err) + } + + files = append(files, newFiles...) + } + + 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..bdae533 --- /dev/null +++ b/pkg/exprparser/functions_test.go @@ -0,0 +1,245 @@ +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, false) + 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, false) + 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, false) + 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, false) + 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"}, + } + + 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, false) + 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, false) + 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-*') }}", "8e5935e7e13368cd9688fe8f48a0955293676a021562582c7e848dafe13fb046", "hash-multiple-files"}, + } + + 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, false) + 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"}, + } + + 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, false) + 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..8518a12 --- /dev/null +++ b/pkg/exprparser/interpreter.go @@ -0,0 +1,576 @@ +package exprparser + +import ( + "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 + Steps map[string]*model.StepResult + Runner map[string]interface{} + Secrets map[string]string + Strategy map[string]interface{} + Matrix map[string]interface{} + Needs map[string]map[string]map[string]string + Inputs map[string]interface{} +} + +type Config struct { + Run *model.Run + WorkingDir string + Context string +} + +type Interpreter interface { + Evaluate(input string, isIfExpression bool) (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, isIfExpression bool) (interface{}, error) { + input = strings.TrimPrefix(input, "${{") + parser := actionlint.NewExprParser() + exprNode, err := parser.Parse(actionlint.NewExprLexer(input + "}}")) + if err != nil { + return nil, fmt.Errorf("Failed to parse: %s", err.Message) + } + + if isIfExpression { + 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: "success", + 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) + } +} + +func (impl *interperterImpl) evaluateVariable(variableNode *actionlint.VariableNode) (interface{}, error) { + switch strings.ToLower(variableNode.Name) { + case "github": + return impl.env.Github, nil + case "env": + return impl.env.Env, nil + case "job": + return impl.env.Job, nil + case "steps": + return impl.env.Steps, nil + case "runner": + return impl.env.Runner, nil + case "secrets": + return impl.env.Secrets, 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: + return leftValue.Index(int(rightValue.Int())).Interface(), nil + default: + return nil, fmt.Errorf("Unable to index on non-slice value: %s", leftValue.Kind()) + } + + default: + return nil, fmt.Errorf("Unknown index type: %s", rightValue.Kind()) + } +} + +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 reflect.ValueOf(left).Interface(), 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 + } + + return fieldValue.Interface(), 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, fmt.Errorf("Unable to dereference '%s' on non-struct '%s'", property, left.Kind()) +} + +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 !impl.isTruthy(reflect.ValueOf(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.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) + + default: + return nil, fmt.Errorf("TODO: evaluateCompare not implemented %+v", reflect.TypeOf(leftValue)) + } +} + +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(), false) + 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.Sprint(value)) + + 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 (impl *interperterImpl) isTruthy(value reflect.Value) bool { + 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: + return true + + case 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) + + right, err := impl.evaluateNode(compareNode.Right) + if err != nil { + return nil, err + } + + rightValue := reflect.ValueOf(right) + + switch compareNode.Kind { + case actionlint.LogicalOpNodeKindAnd: + if impl.isTruthy(leftValue) { + return impl.getSafeValue(rightValue), nil + } + + return impl.getSafeValue(leftValue), nil + + case actionlint.LogicalOpNodeKindOr: + if impl.isTruthy(leftValue) { + return impl.getSafeValue(leftValue), nil + } + + 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": + 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..a23d045 --- /dev/null +++ b/pkg/exprparser/interpreter_test.go @@ -0,0 +1,581 @@ +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, false) + 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", "Unable to index on non-slice value: string"}, + {"fromJSON('[0,1]')[1]", 1.0, "array-index", ""}, + {"(github.event.commits.*.author.username)[0]", "someone", "array-index-0", ""}, + {"!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", ""}, + } + + 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, false) + 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"}, + {`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, false) + 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, false) + 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"}, + {"env.TEST", "value", "env-context"}, + {"job.status", "success", "job-context"}, + {"steps.step-id.outputs.name", "value", "steps-context"}, + {"runner.os", "Linux", "runner-context"}, + {"secrets.name", "value", "secrets-context"}, + {"strategy.fail-fast", true, "strategy-context"}, + {"matrix.os", "Linux", "matrix-context"}, + {"needs.job-id.outputs.output-name", "value", "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", + }, + }, + }, + Runner: map[string]interface{}{ + "os": "Linux", + "temp": "/tmp", + "tool_cache": "/opt/hostedtoolcache", + }, + Secrets: map[string]string{ + "name": "value", + }, + Strategy: map[string]interface{}{ + "fail-fast": true, + }, + Matrix: map[string]interface{}{ + "os": "Linux", + }, + Needs: map[string]map[string]map[string]string{ + "job-id": { + "outputs": { + "output-name": "value", + }, + }, + }, + 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, false) + 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/runner/expression.go b/pkg/runner/expression.go index e025f3a..cc27f41 100644 --- a/pkg/runner/expression.go +++ b/pkg/runner/expression.go @@ -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 } diff --git a/pkg/runner/expression_test.go b/pkg/runner/expression_test.go index d343208..4846bd2 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) @@ -247,7 +289,7 @@ jobs: `, 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")) @@ -268,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": {}, - }, - }, - }, - } - ee := rc.NewExpressionEvaluator() - - tables := []struct { - in string - re string +func TestRewriteSubExpression(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: "${{ 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')"}, } - for _, table := range tables { - table := table - t.Run(table.in, func(t *testing.T) { + for _, table := range table { + t.Run("TestRewriteSubExpression", 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, 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 + }{ + {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 table { + t.Run("TestRewriteSubExpressionForceFormat", func(t *testing.T) { + assertObject := assert.New(t) + 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 63afd58..32a52f6 100644 --- a/pkg/runner/run_context.go +++ b/pkg/runner/run_context.go @@ -426,19 +426,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 { @@ -499,17 +486,6 @@ func (rc *RunContext) getStepsContext() map[string]*model.StepResult { return rc.StepResults } -func (rc *RunContext) getNeedsTransitive(job *model.Job) []string { - needs := job.Needs() - - for _, need := range needs { - parentNeeds := rc.getNeedsTransitive(rc.Run.Workflow.GetJob(need)) - needs = append(needs, parentNeeds...) - } - - return needs -} - func (rc *RunContext) getGithubContext() *model.GithubContext { ghc := &model.GithubContext{ Event: make(map[string]interface{}), @@ -784,12 +760,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 b0b9c76..730d8de 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"))