Skip to content

Commit

Permalink
fix escaped { is not handled in format string passed to format()
Browse files Browse the repository at this point in the history
  • Loading branch information
rhysd committed Nov 12, 2024
1 parent 05dfc72 commit 9f0b113
Show file tree
Hide file tree
Showing 2 changed files with 167 additions and 8 deletions.
59 changes: 51 additions & 8 deletions expr_sema.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,55 @@ func ordinal(i int) string {
return fmt.Sprintf("%d%s", i, suffix)
}

// parseFormatFuncSpecifiers parses the format string passed to `format()` calls.
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/evaluate-expressions-in-workflows-and-actions#format
func parseFormatFuncSpecifiers(f string, n int) map[int]struct{} {
ret := make(map[int]struct{}, n)

type state int
const (
init state = iota // Initial state
brace // {
digit // 0..9
)

var cur state
var start int
for i, r := range f {
switch cur {
case init:
switch r {
case '{':
cur = brace
start = i + 1 // `+ 1` because `i` points char '{'
}
case brace:
switch {
case '0' <= r && r <= '9':
cur = digit
default:
cur = init
}
case digit:
switch {
case '0' <= r && r <= '9':
// Do nothing
case r == '{':
cur = brace
start = i + 1
case r == '}':
i, _ := strconv.Atoi(f[start:i])
ret[i] = struct{}{}
cur = init
default:
cur = init
}
}
}

return ret
}

// Functions

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

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

for i := 0; i < l; i++ {
_, ok := holders[i]
if !ok {
if _, ok := holders[i]; !ok {
sema.errorf(n, "format string %q does not contain placeholder {%d}. remove argument which is unused in the format string", lit.Value, i)
continue
}
Expand Down
116 changes: 116 additions & 0 deletions expr_sema_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,16 @@ func TestExprSemanticsCheckOK(t *testing.T) {
input: "!!('foo' || 10) && 20",
expected: NumberType{},
},
{
what: "escaped braces in format string",
input: "format('hello {{1}} {0}', 42)",
expected: StringType{},
},
{
what: "format specifier is escaped",
input: "format('hello {{{0}', 'world')", // First {{ is escaped. {0} is not escaped
expected: StringType{},
},
}

allSPFuncs := []string{}
Expand Down Expand Up @@ -1023,6 +1033,20 @@ func TestExprSemanticsCheckError(t *testing.T) {
`format string "{0}" does not contain placeholder {1}`,
},
},
{
what: "format specifier is escaped",
input: "format('hello {{0}}', 'world')",
expected: []string{
"does not contain placeholder {0}",
},
},
{
what: "format specifier is still escaped",
input: "format('hello {{{{0}}', 'world')", // First {{ is escaped. {{0}} is still escaped
expected: []string{
"does not contain placeholder {0}",
},
},
{
what: "undefined matrix value",
input: "matrix.bar",
Expand Down Expand Up @@ -1660,3 +1684,95 @@ func TestBuiltinGlobalVariableTypesValidation(t *testing.T) {
testObjectPropertiesAreInLowerCase(t, ty)
}
}

func TestParseFormatSpecifiers(t *testing.T) {
tests := []struct {
what string
in string
want []int // Specifiers in the `in` string
}{
{
what: "empty input",
in: "",
},
{
what: "no specifier",
in: "hello, world!",
},
{
what: "single specifier",
in: "Hello{0}specifier",
want: []int{0},
},
{
what: "mutliple specifiers",
in: "{0} {1}{2}x{3}}{4}!",
want: []int{0, 1, 2, 3, 4},
},
{
what: "many specifiers",
in: "{0}{1}{2}{3}{4}{5}{6}{7}{8}{9}{10}!",
want: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
},
{
what: "unordered",
in: "{2}foo{4} {0}{3}",
want: []int{2, 4, 0, 3},
},
{
what: "uncontiguous",
in: "{0} {2}foo{5} {1}",
want: []int{0, 2, 5, 1},
},
{
what: "unclosed",
in: "{12foo",
},
{
what: "not digit",
in: "{hello}",
},
{
what: "space in digits",
in: "{1 2}",
},
{
what: "empty",
in: "{}",
},
{
what: "specifier inside specifier",
in: "{1{0}2}",
want: []int{0},
},
{
what: "escaped",
in: "{{hello{{0}{{{{1}world}}",
},
{
what: "after escaped",
in: "{{{{{0}",
want: []int{0},
},
{
what: "kuma-",
in: "{・{ᴥ}・}",
},
}

for _, tc := range tests {
t.Run(tc.what, func(t *testing.T) {
want := map[int]struct{}{}
for _, i := range tc.want {
want[i] = struct{}{}
}
have := parseFormatFuncSpecifiers(tc.in, len(tc.want))

if !cmp.Equal(want, have) {
t.Fatal(cmp.Diff(want, have))
}
})
}
}

// vim: nofoldenable

0 comments on commit 9f0b113

Please sign in to comment.