Skip to content

Commit 9f0b113

Browse files
committed
fix escaped { is not handled in format string passed to format()
1 parent 05dfc72 commit 9f0b113

File tree

2 files changed

+167
-8
lines changed

2 files changed

+167
-8
lines changed

expr_sema.go

+51-8
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,55 @@ func ordinal(i int) string {
2828
return fmt.Sprintf("%d%s", i, suffix)
2929
}
3030

31+
// parseFormatFuncSpecifiers parses the format string passed to `format()` calls.
32+
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#format
33+
func parseFormatFuncSpecifiers(f string, n int) map[int]struct{} {
34+
ret := make(map[int]struct{}, n)
35+
36+
type state int
37+
const (
38+
init state = iota // Initial state
39+
brace // {
40+
digit // 0..9
41+
)
42+
43+
var cur state
44+
var start int
45+
for i, r := range f {
46+
switch cur {
47+
case init:
48+
switch r {
49+
case '{':
50+
cur = brace
51+
start = i + 1 // `+ 1` because `i` points char '{'
52+
}
53+
case brace:
54+
switch {
55+
case '0' <= r && r <= '9':
56+
cur = digit
57+
default:
58+
cur = init
59+
}
60+
case digit:
61+
switch {
62+
case '0' <= r && r <= '9':
63+
// Do nothing
64+
case r == '{':
65+
cur = brace
66+
start = i + 1
67+
case r == '}':
68+
i, _ := strconv.Atoi(f[start:i])
69+
ret[i] = struct{}{}
70+
cur = init
71+
default:
72+
cur = init
73+
}
74+
}
75+
}
76+
77+
return ret
78+
}
79+
3180
// Functions
3281

3382
// FuncSignature is a signature of function, which holds return and arguments types.
@@ -798,16 +847,10 @@ func (sema *ExprSemanticsChecker) checkBuiltinFunctionCall(n *FuncCallNode, _ *F
798847
}
799848
l := len(n.Args) - 1 // -1 means removing first format string argument
800849

801-
// Find all placeholders in format string
802-
holders := make(map[int]struct{}, l)
803-
for _, m := range reFormatPlaceholder.FindAllString(lit.Value, -1) {
804-
i, _ := strconv.Atoi(m[1 : len(m)-1])
805-
holders[i] = struct{}{}
806-
}
850+
holders := parseFormatFuncSpecifiers(lit.Value, l)
807851

808852
for i := 0; i < l; i++ {
809-
_, ok := holders[i]
810-
if !ok {
853+
if _, ok := holders[i]; !ok {
811854
sema.errorf(n, "format string %q does not contain placeholder {%d}. remove argument which is unused in the format string", lit.Value, i)
812855
continue
813856
}

expr_sema_test.go

+116
Original file line numberDiff line numberDiff line change
@@ -719,6 +719,16 @@ func TestExprSemanticsCheckOK(t *testing.T) {
719719
input: "!!('foo' || 10) && 20",
720720
expected: NumberType{},
721721
},
722+
{
723+
what: "escaped braces in format string",
724+
input: "format('hello {{1}} {0}', 42)",
725+
expected: StringType{},
726+
},
727+
{
728+
what: "format specifier is escaped",
729+
input: "format('hello {{{0}', 'world')", // First {{ is escaped. {0} is not escaped
730+
expected: StringType{},
731+
},
722732
}
723733

724734
allSPFuncs := []string{}
@@ -1023,6 +1033,20 @@ func TestExprSemanticsCheckError(t *testing.T) {
10231033
`format string "{0}" does not contain placeholder {1}`,
10241034
},
10251035
},
1036+
{
1037+
what: "format specifier is escaped",
1038+
input: "format('hello {{0}}', 'world')",
1039+
expected: []string{
1040+
"does not contain placeholder {0}",
1041+
},
1042+
},
1043+
{
1044+
what: "format specifier is still escaped",
1045+
input: "format('hello {{{{0}}', 'world')", // First {{ is escaped. {{0}} is still escaped
1046+
expected: []string{
1047+
"does not contain placeholder {0}",
1048+
},
1049+
},
10261050
{
10271051
what: "undefined matrix value",
10281052
input: "matrix.bar",
@@ -1660,3 +1684,95 @@ func TestBuiltinGlobalVariableTypesValidation(t *testing.T) {
16601684
testObjectPropertiesAreInLowerCase(t, ty)
16611685
}
16621686
}
1687+
1688+
func TestParseFormatSpecifiers(t *testing.T) {
1689+
tests := []struct {
1690+
what string
1691+
in string
1692+
want []int // Specifiers in the `in` string
1693+
}{
1694+
{
1695+
what: "empty input",
1696+
in: "",
1697+
},
1698+
{
1699+
what: "no specifier",
1700+
in: "hello, world!",
1701+
},
1702+
{
1703+
what: "single specifier",
1704+
in: "Hello{0}specifier",
1705+
want: []int{0},
1706+
},
1707+
{
1708+
what: "mutliple specifiers",
1709+
in: "{0} {1}{2}x{3}}{4}!",
1710+
want: []int{0, 1, 2, 3, 4},
1711+
},
1712+
{
1713+
what: "many specifiers",
1714+
in: "{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}!",
1715+
want: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
1716+
},
1717+
{
1718+
what: "unordered",
1719+
in: "{2}foo{4} {0}{3}",
1720+
want: []int{2, 4, 0, 3},
1721+
},
1722+
{
1723+
what: "uncontiguous",
1724+
in: "{0} {2}foo{5} {1}",
1725+
want: []int{0, 2, 5, 1},
1726+
},
1727+
{
1728+
what: "unclosed",
1729+
in: "{12foo",
1730+
},
1731+
{
1732+
what: "not digit",
1733+
in: "{hello}",
1734+
},
1735+
{
1736+
what: "space in digits",
1737+
in: "{1 2}",
1738+
},
1739+
{
1740+
what: "empty",
1741+
in: "{}",
1742+
},
1743+
{
1744+
what: "specifier inside specifier",
1745+
in: "{1{0}2}",
1746+
want: []int{0},
1747+
},
1748+
{
1749+
what: "escaped",
1750+
in: "{{hello{{0}{{{{1}world}}",
1751+
},
1752+
{
1753+
what: "after escaped",
1754+
in: "{{{{{0}",
1755+
want: []int{0},
1756+
},
1757+
{
1758+
what: "kuma-",
1759+
in: "{・{ᴥ}・}",
1760+
},
1761+
}
1762+
1763+
for _, tc := range tests {
1764+
t.Run(tc.what, func(t *testing.T) {
1765+
want := map[int]struct{}{}
1766+
for _, i := range tc.want {
1767+
want[i] = struct{}{}
1768+
}
1769+
have := parseFormatFuncSpecifiers(tc.in, len(tc.want))
1770+
1771+
if !cmp.Equal(want, have) {
1772+
t.Fatal(cmp.Diff(want, have))
1773+
}
1774+
})
1775+
}
1776+
}
1777+
1778+
// vim: nofoldenable

0 commit comments

Comments
 (0)