parse: Fix a O(2^n) code path when input is "(" repeated n times.

The parsing function for Form first tries to parse a temp assignment, and
backtracks when an assignment cannot be parsed. When the input is "(" repeated n
times, each level of Form parsing will first try to parse the rest of the code
as an assignment and then backtrack, without making any progress. This results
in a call tree with a branching factor of 2, hence the O(2^n) complexity.

The fix is to first try to parse a head instead, and only try to parse it as a
temp assignment if it does contain "=". This fixes this particular pathological
case, although I'm not 100% sure it eliminates all possibilities of O(2^n) time
complexity.

With the introduction of the "tmp" special command, the current syntax for
temporary assignments will be deprecated and eventually go away, which will
eliminate all backtracking in the parser. In the meanwhile, this fix may be good
enough.

This case was discovered with fuzzing support in Go 1.18. Also add the fuzzing
test data.
This commit is contained in:
Qi Xiao 2022-01-01 22:10:30 +00:00
parent 1c1227324e
commit 9cda3f643e
4 changed files with 54 additions and 26 deletions

17
pkg/parse/fuzz_test.go Normal file
View File

@ -0,0 +1,17 @@
//go:build go1.18
// +build go1.18
package parse
import (
"testing"
)
func FuzzParse(f *testing.F) {
f.Add("echo")
f.Add("put $x")
f.Add("put foo bar | each {|x| echo $x }")
f.Fuzz(func(t *testing.T, code string) {
Parse(Source{Name: "fuzz", Code: code}, Config{})
})
}

View File

@ -14,6 +14,7 @@ import (
"bytes"
"fmt"
"io"
"strings"
"unicode"
"src.elv.sh/pkg/diag"
@ -156,12 +157,29 @@ type Form struct {
func (fn *Form) parse(ps *parser) {
parseSpaces(fn, ps)
for fn.tryAssignment(ps) {
for startsCompound(ps.peek(), CmdExpr) {
initial := ps.save()
parsedCmd := ps.parse(&Compound{ExprCtx: CmdExpr})
if s := SourceText(parsedCmd.n); strings.ContainsRune(s, '=') {
postCmd := ps.save()
ps.restore(initial)
parsedAssignment := ps.parse(&Assignment{})
if len(ps.errors.Entries) == len(initial.errors.Entries) {
parsedAssignment.addTo(&fn.Assignments, fn)
parseSpaces(fn, ps)
continue
} else {
ps.restore(postCmd)
}
}
parsedCmd.addAs(&fn.Head, fn)
parseSpaces(fn, ps)
break
}
// Parse head.
if !startsCompound(ps.peek(), CmdExpr) {
if fn.Head == nil {
if len(fn.Assignments) > 0 {
// Assignment-only form.
return
@ -169,8 +187,6 @@ func (fn *Form) parse(ps *parser) {
// Bad form.
ps.error(fmt.Errorf("bad rune at form head: %q", ps.peek()))
}
ps.parse(&Compound{ExprCtx: CmdExpr}).addAs(&fn.Head, fn)
parseSpaces(fn, ps)
for {
r := ps.peek()
@ -202,27 +218,6 @@ func (fn *Form) parse(ps *parser) {
}
}
// tryAssignment tries to parse an assignment. If succeeded, it adds the parsed
// assignment to fn.Assignments and returns true. Otherwise it rewinds the
// parser and returns false.
func (fn *Form) tryAssignment(ps *parser) bool {
if !startsIndexing(ps.peek(), LHSExpr) {
return false
}
pos := ps.pos
errorEntries := ps.errors.Entries
parsedAssignment := ps.parse(&Assignment{})
// If errors were added, revert
if len(ps.errors.Entries) > len(errorEntries) {
ps.errors.Entries = errorEntries
ps.pos = pos
return false
}
parsedAssignment.addTo(&fn.Assignments, fn)
return true
}
func startsForm(r rune) bool {
return IsInlineWhitespace(r) || startsCompound(r, CmdExpr)
}

View File

@ -33,6 +33,20 @@ func (ps *parser) parse(n Node) parsed {
return parsed{n}
}
type parserState struct {
pos int
overEOF int
errors Error
}
func (ps *parser) save() parserState {
return parserState{ps.pos, ps.overEOF, ps.errors}
}
func (ps *parser) restore(s parserState) {
ps.pos, ps.overEOF, ps.errors = s.pos, s.overEOF, s.errors
}
var nodeType = reflect.TypeOf((*Node)(nil)).Elem()
type parsed struct {

View File

@ -0,0 +1,2 @@
go test fuzz v1
string("(((((((((((((((((((((((((((((((($'")