diff --git a/go.mod b/go.mod index be33bb7..18fa1c7 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( git.sr.ht/~emersion/go-scfg v0.0.0-20201019143924-142a8aa629fc git.sr.ht/~sircmpwn/go-bare v0.0.0-20210331145808-46f9b5e5bcf9 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 - github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/klauspost/compress v1.11.13 // indirect github.com/mattn/go-sqlite3 v1.14.6 github.com/pires/go-proxyproto v0.5.0 diff --git a/service.go b/service.go index fbbb91f..88b75ae 100644 --- a/service.go +++ b/service.go @@ -21,8 +21,8 @@ import ( "strconv" "strings" "time" + "unicode" - "github.com/google/shlex" "golang.org/x/crypto/bcrypt" "gopkg.in/irc.v3" ) @@ -63,10 +63,63 @@ func sendServicePRIVMSG(dc *downstreamConn, text string) { }) } +func splitWords(s string) ([]string, error) { + var words []string + var lastWord strings.Builder + escape := false + prev := ' ' + wordDelim := ' ' + + for _, r := range s { + if escape { + // last char was a backslash, write the byte as-is. + lastWord.WriteRune(r) + escape = false + } else if r == '\\' { + escape = true + } else if wordDelim == ' ' && unicode.IsSpace(r) { + // end of last word + if !unicode.IsSpace(prev) { + words = append(words, lastWord.String()) + lastWord.Reset() + } + } else if r == wordDelim { + // wordDelim is either " or ', switch back to + // space-delimited words. + wordDelim = ' ' + } else if r == '"' || r == '\'' { + if wordDelim == ' ' { + // start of (double-)quoted word + wordDelim = r + } else { + // either wordDelim is " and r is ' or vice-versa + lastWord.WriteRune(r) + } + } else { + lastWord.WriteRune(r) + } + + prev = r + } + + if !unicode.IsSpace(prev) { + words = append(words, lastWord.String()) + } + + if wordDelim != ' ' { + return nil, fmt.Errorf("unterminated quoted string") + } + if escape { + return nil, fmt.Errorf("unterminated backslash sequence") + } + + return words, nil +} + func handleServicePRIVMSG(dc *downstreamConn, text string) { - words, err := shlex.Split(text) + words, err := splitWords(text) if err != nil { - sendServicePRIVMSG(dc, fmt.Sprintf("error: failed to parse command: %v", err)) + sendServicePRIVMSG(dc, fmt.Sprintf(`error: failed to parse command: %v`, err)) return } diff --git a/service_test.go b/service_test.go new file mode 100644 index 0000000..b2a056d --- /dev/null +++ b/service_test.go @@ -0,0 +1,54 @@ +package soju + +import ( + "testing" +) + +func assertSplit(t *testing.T, input string, expected []string) { + actual, err := splitWords(input) + if err != nil { + t.Errorf("%q: %v", input, err) + return + } + if len(actual) != len(expected) { + t.Errorf("%q: expected %d words, got %d\nexpected: %v\ngot: %v", input, len(expected), len(actual), expected, actual) + return + } + for i := 0; i < len(actual); i++ { + if actual[i] != expected[i] { + t.Errorf("%q: expected word #%d to be %q, got %q\nexpected: %v\ngot: %v", input, i, expected[i], actual[i], expected, actual) + } + } +} + +func TestSplit(t *testing.T) { + assertSplit(t, " ch 'up' #soju 'relay'-det\"ache\"d message ", []string{ + "ch", + "up", + "#soju", + "relay-detached", + "message", + }) + assertSplit(t, "net update \\\"free\\\"node -pass 'political \"stance\" desu!' -realname '' -nick lee", []string{ + "net", + "update", + "\"free\"node", + "-pass", + "political \"stance\" desu!", + "-realname", + "", + "-nick", + "lee", + }) + assertSplit(t, "Omedeto,\\ Yui! ''", []string{ + "Omedeto, Yui!", + "", + }) + + if _, err := splitWords("end of 'file"); err == nil { + t.Errorf("expected error on unterminated single quote") + } + if _, err := splitWords("end of backquote \\"); err == nil { + t.Errorf("expected error on unterminated backquote sequence") + } +}