Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add strings.quote #639

Merged
merged 18 commits into from
Mar 14, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions ext/strings.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"strconv"
"strings"
"unicode"
"unicode/utf8"

"golang.org/x/text/language"
"golang.org/x/text/message"
Expand Down Expand Up @@ -433,6 +434,11 @@ func (sl *stringLib) CompileOptions() []cel.EnvOption {
d := delim.(types.String)
return stringOrError(joinSeparator(l.([]string), string(d)))
}))),
cel.Function("strings.quote", cel.Overload("strings_quote", []*cel.Type{cel.StringType}, cel.StringType,
DangerOnTheRanger marked this conversation as resolved.
Show resolved Hide resolved
cel.UnaryBinding(func(str ref.Val) ref.Val {
s := str.(types.String)
return stringOrError(quote(string(s)))
}))),
}
if sl.version >= 1 {
opts = append(opts, cel.Function("format",
Expand Down Expand Up @@ -1084,6 +1090,43 @@ func makeMatcher(locale string) (language.Matcher, error) {
return language.NewMatcher(tags), nil
}

// quote implements a string quoting function. The string will be wrapped in
// double quotes, and all valid CEL escape sequences will be escaped to show up
// literally if printed. If the input is not valid UTF-8, quote will return with
// an error.
func quote(s string) (string, error) {
var quotedStrBuilder strings.Builder
if !utf8.ValidString(s) {
DangerOnTheRanger marked this conversation as resolved.
Show resolved Hide resolved
return s, errors.New("input is not valid utf8")
}
for _, c := range s {
switch c {
case '\a':
quotedStrBuilder.WriteString("\\a")
case '\b':
quotedStrBuilder.WriteString("\\b")
case '\f':
quotedStrBuilder.WriteString("\\f")
case '\n':
quotedStrBuilder.WriteString("\\n")
case '\r':
quotedStrBuilder.WriteString("\\r")
case '\t':
quotedStrBuilder.WriteString("\\t")
case '\v':
quotedStrBuilder.WriteString("\\v")
case '\\':
quotedStrBuilder.WriteString("\\\\")
case '"':
quotedStrBuilder.WriteString("\\\"")
default:
quotedStrBuilder.WriteRune(c)
}
}
escapedStr := quotedStrBuilder.String()
return "\"" + escapedStr + "\"", nil
}

var (
stringListType = reflect.TypeOf([]string{})
)
217 changes: 217 additions & 0 deletions ext/strings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@
package ext

import (
"errors"
"fmt"
"math"
"reflect"
"strings"
"testing"
"time"
"unicode/utf8"

"github.com/google/cel-go/cel"
"github.com/google/cel-go/common/types"
Expand Down Expand Up @@ -105,6 +107,21 @@ var stringTests = []struct {
{expr: `['x', 'y'].join('-') == 'x-y'`},
{expr: `[].join() == ''`},
{expr: `[].join('-') == ''`},
// Escaping tests.
DangerOnTheRanger marked this conversation as resolved.
Show resolved Hide resolved
{expr: `strings.quote("first\nsecond") == "\"first\\nsecond\""`},
TristonianJones marked this conversation as resolved.
Show resolved Hide resolved
{expr: `strings.quote("bell\a") == "\"bell\\a\""`},
{expr: `strings.quote("\bbackspace") == "\"\\bbackspace\""`},
{expr: `strings.quote("\fform feed") == "\"\\fform feed\""`},
{expr: `strings.quote("carriage \r return") == "\"carriage \\r return\""`},
{expr: `strings.quote("horizontal tab\t") == "\"horizontal tab\\t\""`},
{expr: `strings.quote("vertical \v tab") == "\"vertical \\v tab\""`},
{expr: `strings.quote("double \\\\ slash") == "\"double \\\\\\\\ slash\""`},
{expr: `strings.quote("two escape sequences \a\n") == "\"two escape sequences \\a\\n\""`},
{expr: `strings.quote("verbatim") == "\"verbatim\""`},
{expr: `strings.quote("ends with \\") == "\"ends with \\\\\""`},
{expr: `strings.quote("\\ starts with") == "\"\\\\ starts with\""`},
{expr: `strings.quote("printable unicode😀") == "\"printable unicode😀\""`},
{expr: `strings.quote("mid string \" quote") == "\"mid string \\\" quote\""`},
DangerOnTheRanger marked this conversation as resolved.
Show resolved Hide resolved
// Error test cases based on checked expression usage.
{
expr: `'tacocat'.charAt(30) == ''`,
Expand Down Expand Up @@ -1079,3 +1096,203 @@ func mustParseDuration(s string) time.Duration {
return d
}
}

func unquote(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid utf8")
}
r := []rune(s)
if r[0] != '"' || r[len(r)-1] != '"' {
return "", fmt.Errorf("expected given string to be enclosed in double quotes: %q", r)
}
var unquotedStrBuilder strings.Builder
noQuotes := r[1 : len(r)-1]
for i := 0; i < len(noQuotes); {
c := noQuotes[i]
hasNext := i < len(noQuotes)
DangerOnTheRanger marked this conversation as resolved.
Show resolved Hide resolved
if c == '\\' {
if hasNext {
nextChar := noQuotes[i+1]
switch nextChar {
case 'a':
unquotedStrBuilder.WriteRune('\a')
case 'b':
unquotedStrBuilder.WriteRune('\b')
case 'f':
unquotedStrBuilder.WriteRune('\f')
case 'n':
unquotedStrBuilder.WriteRune('\n')
case 'r':
unquotedStrBuilder.WriteRune('\r')
case 't':
unquotedStrBuilder.WriteRune('\t')
case 'v':
unquotedStrBuilder.WriteRune('\v')
case '\\':
unquotedStrBuilder.WriteRune('\\')
case '"':
unquotedStrBuilder.WriteRune('"')
default:
unquotedStrBuilder.WriteRune(c)
unquotedStrBuilder.WriteRune(nextChar)
}
i += 2
continue
}
}
unquotedStrBuilder.WriteRune(c)
i++
}
return unquotedStrBuilder.String(), nil
}

func TestUnquote(t *testing.T) {
tests := []struct {
name string
testStr string
expectedErr string
disableQuote bool
}{
{
name: "remove quotes only",
testStr: "this is a test",
},
{
name: "mid-string newline",
testStr: "first\nsecond",
},
{
name: "bell",
testStr: "bell\a",
},
{
name: "backspace",
testStr: "\bbackspace",
},
{
name: "form feed",
testStr: "\fform feed",
},
{
name: "carriage return",
testStr: "carriage \r return",
},
{
name: "horizontal tab",
testStr: "horizontal \ttab",
},
{
name: "vertical tab",
testStr: "vertical \v tab",
},
{
name: "double slash",
testStr: "double \\\\ slash",
},
{
name: "two escape sequences",
testStr: "two escape sequences \a\n",
},
{
name: "ends with slash",
testStr: "ends with \\",
},
{
name: "starts with slash",
testStr: "\\ starts with",
},
{
name: "printable unicode",
testStr: "printable unicode😀",
},
{
name: "mid-string quote",
testStr: "mid-string \" quote",
},
{
name: "missing opening quote",
testStr: `only one quote"`,
expectedErr: "expected given string to be enclosed in double quotes",
disableQuote: true,
},
{
name: "missing closing quote",
testStr: `"only one quote`,
expectedErr: "expected given string to be enclosed in double quotes",
disableQuote: true,
},
{
name: "invalid utf8",
testStr: "filler \x9f",
expectedErr: "input is not valid utf8",
disableQuote: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s string
if tt.disableQuote {
s = tt.testStr
} else {
s, _ = quote(tt.testStr)
}
output, err := unquote(s)
if err != nil {
if tt.expectedErr != "" {
if !strings.Contains(err.Error(), tt.expectedErr) {
t.Fatalf("expected error message %q to contain %q", err, tt.expectedErr)
}
} else {
t.Fatalf("unexpected error: %s", err)
}
} else {
if tt.expectedErr != "" {
t.Fatalf("expected error message with substring %q but no error was seen", tt.expectedErr)
}
if output != tt.testStr {
t.Fatalf("input-output mismatch: original: %q, quote/unquote: %q", tt.testStr, output)
}
}
})
}
}

func FuzzQuote(f *testing.F) {
tests := []string{
"this is a test",
`only one quote"`,
`"only one quote`,
"first\nsecond",
"bell\a",
"\bbackspace",
"\fform feed",
"carriage \r return",
"horizontal \ttab",
"vertical \v tab",
"double \\\\ slash",
"two escape sequences \a\n",
"ends with \\",
"\\ starts with",
"printable unicode😀",
"mid-string \" quote",
"filler \x9f",
}
for _, tc := range tests {
f.Add(tc)
}
f.Fuzz(func(t *testing.T, s string) {
quoted, err := quote(s)
if err != nil {
if utf8.ValidString(s) {
t.Errorf("unexpected error: %s", err)
}
} else {
unquoted, err := unquote(quoted)
if err != nil {
t.Errorf("unexpected error: %s", err)
} else if unquoted != s {
t.Errorf("input-output mismatch: original: %q, quoted: %q, quote/unquote: %q", s, quoted, unquoted)
}
}
})
}