diff --git a/.check-gofmt.sh b/.check-gofmt.sh new file mode 100755 index 0000000..a4ffc6d --- /dev/null +++ b/.check-gofmt.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# exclude vendor/ +SOURCES="." + +if [ -n "$(gofmt -s -l $SOURCES)" ]; then + echo "Go code is not formatted correctly with \`gofmt -s\`:" + gofmt -s -d $SOURCES + exit 1 +fi diff --git a/Makefile b/Makefile index 9fa6756..86a4ab8 100644 --- a/Makefile +++ b/Makefile @@ -4,3 +4,4 @@ test: cd ircmatch && go test . && go vet . cd ircmsg && go test . && go vet . cd ircutils && go test . && go vet . + ./.check_gofmt.sh diff --git a/client/reactor_test.go b/client/reactor_test.go index c548425..314cfd0 100644 --- a/client/reactor_test.go +++ b/client/reactor_test.go @@ -88,7 +88,7 @@ func TestTLSConnection(t *testing.T) { KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, - IsCA: true, + IsCA: true, } template.IPAddresses = append(template.IPAddresses, net.ParseIP("127.0.0.1")) diff --git a/ircfmt/ircfmt_test.go b/ircfmt/ircfmt_test.go index 2f07b00..b61386b 100644 --- a/ircfmt/ircfmt_test.go +++ b/ircfmt/ircfmt_test.go @@ -27,7 +27,7 @@ var unescapetests = []testcase{ {"test$c", "test\x03"}, } -var stripTests = []testcase { +var stripTests = []testcase{ {"te\x02st", "test"}, {"te\x033st", "test"}, {"te\x034,3st", "test"}, @@ -94,7 +94,7 @@ func TestStrip(t *testing.T) { "For", pair.escaped, "expected", pair.unescaped, "got", val, - ) + ) } } } diff --git a/ircmsg/message.go b/ircmsg/message.go index 8bbcd28..7b76608 100644 --- a/ircmsg/message.go +++ b/ircmsg/message.go @@ -1,4 +1,6 @@ -// written by Daniel Oaks +// Copyright (c) 2016-2019 Daniel Oaks +// Copyright (c) 2018-2019 Shivaram Lingamneni + // released under the ISC license package ircmsg @@ -9,239 +11,370 @@ import ( "strings" ) +const ( + // "The size limit for message tags is 8191 bytes, including the leading + // '@' (0x40) and trailing space ' ' (0x20) characters." + MaxlenTags = 8191 + + // MaxlenTags - ('@' + ' ') + MaxlenTagData = MaxlenTags - 2 + + // "Clients MUST NOT send messages with tag data exceeding 4094 bytes, + // this includes tags with or without the client-only prefix." + MaxlenClientTagData = 4094 + + // "Servers MUST NOT add tag data exceeding 4094 bytes to messages." + MaxlenServerTagData = 4094 + + // '@' + MaxlenClientTagData + ' ' + // this is the analogue of MaxlenTags when the source of the message is a client + MaxlenTagsFromClient = MaxlenClientTagData + 2 +) + var ( // ErrorLineIsEmpty indicates that the given IRC line was empty. ErrorLineIsEmpty = errors.New("Line is empty") - // ErrorTagsContainsBadChar indicates that the passed tag string contains a space or newline. - ErrorTagsContainsBadChar = errors.New("Tag string contains bad character (such as a space or newline)") + // ErrorLineContainsBadChar indicates that the line contained invalid characters + ErrorLineContainsBadChar = errors.New("Line contains invalid characters") + // ErrorLineTooLong indicates that the message exceeded the maximum tag length + // (the name references 417 ERR_INPUTTOOLONG; we reserve the right to return it + // for messages that exceed the non-tag length limit) + ErrorLineTooLong = errors.New("Line could not be parsed because a specified length limit was exceeded") + + ErrorCommandMissing = errors.New("IRC messages MUST have a command") + ErrorBadParam = errors.New("Cannot have an empty param, a param with spaces, or a param that starts with ':' before the last parameter") ) // IrcMessage represents an IRC message, as defined by the RFCs and as // extended by the IRCv3 Message Tags specification with the introduction // of message tags. type IrcMessage struct { - Tags map[string]TagValue - Prefix string - Command string - Params []string - // SourceLine represents the original line that constructed this message, when created from ParseLine. - SourceLine string + Prefix string + Command string + Params []string + tags map[string]string + clientOnlyTags map[string]string } -// ParseLine creates and returns an IrcMessage from the given IRC line. -// -// Quirks: -// -// The RFCs say that last parameters with no characters MUST be a trailing. -// IE, they need to be prefixed with ":". We disagree with that and handle -// incoming last empty parameters whether they are trailing or ordinary -// parameters. However, we do follow that rule when emitting lines. -func ParseLine(line string) (IrcMessage, error) { - return parseLine(line, 0, 0, false) +// GetTag returns whether a tag is present, and if so, what its value is. +func (msg *IrcMessage) GetTag(tagName string) (present bool, value string) { + if len(tagName) == 0 { + return + } else if tagName[0] == '+' { + value, present = msg.clientOnlyTags[tagName] + return + } else { + value, present = msg.tags[tagName] + return + } } -// ParseLineMaxLen creates and returns an IrcMessage from the given IRC line, +// HasTag returns whether a tag is present. +func (msg *IrcMessage) HasTag(tagName string) (present bool) { + present, _ = msg.GetTag(tagName) + return +} + +// SetTag sets a tag. +func (msg *IrcMessage) SetTag(tagName, tagValue string) { + if len(tagName) == 0 { + return + } else if tagName[0] == '+' { + if msg.clientOnlyTags == nil { + msg.clientOnlyTags = make(map[string]string) + } + msg.clientOnlyTags[tagName] = tagValue + } else { + if msg.tags == nil { + msg.tags = make(map[string]string) + } + msg.tags[tagName] = tagValue + } +} + +// DeleteTag deletes a tag. +func (msg *IrcMessage) DeleteTag(tagName string) { + if len(tagName) == 0 { + return + } else if tagName[0] == '+' { + delete(msg.clientOnlyTags, tagName) + } else { + delete(msg.tags, tagName) + } +} + +// UpdateTags is a convenience to set multiple tags at once. +func (msg *IrcMessage) UpdateTags(tags map[string]string) { + for name, value := range tags { + msg.SetTag(name, value) + } +} + +// AllTags returns all tags as a single map. +func (msg *IrcMessage) AllTags() (result map[string]string) { + result = make(map[string]string, len(msg.tags)+len(msg.clientOnlyTags)) + for name, value := range msg.tags { + result[name] = value + } + for name, value := range msg.clientOnlyTags { + result[name] = value + } + return +} + +// ClientOnlyTags returns the client-only tags (the tags with the + prefix). +// The returned map may be internal storage of the IrcMessage object and +// should not be modified. +func (msg *IrcMessage) ClientOnlyTags() map[string]string { + return msg.clientOnlyTags +} + +// ParseLine creates and returns a message from the given IRC line. +func ParseLine(line string) (ircmsg IrcMessage, err error) { + return parseLine(line, 0, 0) +} + +// ParseLineStrict creates and returns an IrcMessage from the given IRC line, // taking the maximum length into account and truncating the message as appropriate. -// -// Quirks: -// -// The RFCs say that last parameters with no characters MUST be a trailing. -// IE, they need to be prefixed with ":". We disagree with that and handle -// incoming last empty parameters whether they are trailing or ordinary -// parameters. However, we do follow that rule when emitting lines. -func ParseLineMaxLen(line string, maxlenTags, maxlenRest int) (IrcMessage, error) { - return parseLine(line, maxlenTags, maxlenRest, true) +// If fromClient is true, it enforces the client limit on tag data length (4094 bytes), +// allowing the server to return ERR_INPUTTOOLONG as appropriate. If truncateLen is +// nonzero, it is the length at which the non-tag portion of the message is truncated. +func ParseLineStrict(line string, fromClient bool, truncateLen int) (ircmsg IrcMessage, err error) { + maxTagDataLength := MaxlenTagData + if fromClient { + maxTagDataLength = MaxlenClientTagData + } + return parseLine(line, maxTagDataLength, truncateLen) } -// parseLine does the actual line parsing for the above user-facing functions. -func parseLine(line string, maxlenTags, maxlenRest int, useMaxLen bool) (IrcMessage, error) { - line = strings.Trim(line, "\r\n") - var ircmsg IrcMessage +// slice off any amount of '\r' or '\n' from the end of the string +func trimFinalNewlines(str string) string { + var i int + for i = len(str) - 1; 0 <= i && (str[i] == '\r' || str[i] == '\n'); i -= 1 { + } + return str[:i+1] +} - ircmsg.SourceLine = line +func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg IrcMessage, err error) { + if strings.IndexByte(line, '\x00') != -1 { + err = ErrorLineContainsBadChar + return + } + + line = trimFinalNewlines(line) if len(line) < 1 { return ircmsg, ErrorLineIsEmpty } // tags - ircmsg.Tags = make(map[string]TagValue) if line[0] == '@' { - splitLine := strings.SplitN(line, " ", 2) - if len(splitLine) < 2 { + tagEnd := strings.IndexByte(line, ' ') + if tagEnd == -1 { return ircmsg, ErrorLineIsEmpty } - tags := splitLine[0][1:] - line = strings.TrimLeft(splitLine[1], " ") - - if len(line) < 1 { - return ircmsg, ErrorLineIsEmpty + tags := line[1:tagEnd] + if 0 < maxTagDataLength && maxTagDataLength < len(tags) { + return ircmsg, ErrorLineTooLong } - - var err error - ircmsg.Tags, err = parseTags(tags, maxlenTags, useMaxLen) + err = ircmsg.parseTags(tags) if err != nil { - return ircmsg, err + return } + // skip over the tags and the separating space + line = line[tagEnd+1:] } // truncate if desired - if useMaxLen && len(line) > maxlenRest { - line = line[:maxlenRest] + if 0 < truncateLen && truncateLen < len(line) { + line = line[:truncateLen] } // prefix if line[0] == ':' { - splitLine := strings.SplitN(line, " ", 2) - if len(splitLine) < 2 { + prefixEnd := strings.IndexByte(line, ' ') + if prefixEnd == -1 { return ircmsg, ErrorLineIsEmpty } - ircmsg.Prefix = splitLine[0][1:] - line = strings.TrimLeft(splitLine[1], " ") - } - - if len(line) < 1 { - return ircmsg, ErrorLineIsEmpty + ircmsg.Prefix = line[1:prefixEnd] + // skip over the prefix and the separating space + line = line[prefixEnd+1:] } // command - splitLine := strings.SplitN(line, " ", 2) - if len(splitLine[0]) == 0 { + commandEnd := strings.IndexByte(line, ' ') + paramStart := commandEnd + 1 + if commandEnd == -1 { + commandEnd = len(line) + paramStart = len(line) + } + // normalize command to uppercase: + ircmsg.Command = strings.ToUpper(strings.TrimSpace(line[:commandEnd])) + if len(ircmsg.Command) == 0 { return ircmsg, ErrorLineIsEmpty } - ircmsg.Command = strings.ToUpper(splitLine[0]) - if len(splitLine) > 1 { - line = strings.TrimLeft(splitLine[1], " ") + line = line[paramStart:] - // parameters - for { - // handle trailing - if len(line) > 0 && line[0] == ':' { - ircmsg.Params = append(ircmsg.Params, line[1:]) - break - } - - // regular params - splitLine := strings.SplitN(line, " ", 2) - if len(splitLine[0]) > 0 { - ircmsg.Params = append(ircmsg.Params, splitLine[0]) - } - - if len(splitLine) > 1 { - line = strings.TrimLeft(splitLine[1], " ") - } else { - break - } + for 0 < len(line) { + // handle trailing + if line[0] == ':' { + ircmsg.Params = append(ircmsg.Params, line[1:]) + break } + paramEnd := strings.IndexByte(line, ' ') + if paramEnd == -1 { + ircmsg.Params = append(ircmsg.Params, line) + break + } else if paramEnd == 0 { + // only a trailing parameter can be empty + return ircmsg, ErrorLineContainsBadChar + } + ircmsg.Params = append(ircmsg.Params, line[:paramEnd]) + line = line[paramEnd+1:] } return ircmsg, nil } -// MakeMessage provides a simple way to create a new IrcMessage. -func MakeMessage(tags *map[string]TagValue, prefix string, command string, params ...string) IrcMessage { - var ircmsg IrcMessage - - ircmsg.Tags = make(map[string]TagValue) - if tags != nil { - for tag, value := range *tags { - ircmsg.Tags[tag] = value +// helper to parse tags +func (ircmsg *IrcMessage) parseTags(tags string) (err error) { + for 0 < len(tags) { + tagEnd := strings.IndexByte(tags, ';') + endPos := tagEnd + nextPos := tagEnd + 1 + if tagEnd == -1 { + endPos = len(tags) + nextPos = len(tags) } + tagPair := tags[:endPos] + equalsIndex := strings.IndexByte(tagPair, '=') + var tagName, tagValue string + if equalsIndex == -1 { + // tag with no value + tagName = tagPair + } else { + tagName, tagValue = tagPair[:equalsIndex], tagPair[equalsIndex+1:] + } + ircmsg.SetTag(tagName, UnescapeTagValue(tagValue)) + // skip over the tag just processed, plus the delimiting ; if any + tags = tags[nextPos:] } + return nil +} +// MakeMessage provides a simple way to create a new IrcMessage. +func MakeMessage(tags map[string]string, prefix string, command string, params ...string) (ircmsg IrcMessage) { ircmsg.Prefix = prefix ircmsg.Command = command ircmsg.Params = params - + ircmsg.UpdateTags(tags) return ircmsg } // Line returns a sendable line created from an IrcMessage. -func (ircmsg *IrcMessage) Line() (string, error) { - bytes, err := ircmsg.line(0, 0, false) - return string(bytes), err +func (ircmsg *IrcMessage) Line() (result string, err error) { + bytes, err := ircmsg.line(0, 0, 0, 0) + if err == nil { + result = string(bytes) + } + return } -// LineBytes returns a sendable line, as a []byte, created from an IrcMessage. -func (ircmsg *IrcMessage) LineBytes() ([]byte, error) { - return ircmsg.line(0, 0, false) -} - -// LineMaxLen returns a sendable line created from an IrcMessage, limited by maxlen. -func (ircmsg *IrcMessage) LineMaxLen(maxlenTags, maxlenRest int) (string, error) { - bytes, err := ircmsg.line(maxlenTags, maxlenRest, true) - return string(bytes), err -} - -// LineMaxLen returns a sendable line created from an IrcMessage, limited by maxlen, -// as a []byte. -func (ircmsg *IrcMessage) LineMaxLenBytes(maxlenTags, maxlenRest int) ([]byte, error) { - return ircmsg.line(maxlenTags, maxlenRest, true) +// LineBytesStrict returns a sendable line, as a []byte, created from an IrcMessage. +// fromClient controls whether the server-side or client-side tag length limit +// is enforced. If truncateLen is nonzero, it is the length at which the +// non-tag portion of the message is truncated. +func (ircmsg *IrcMessage) LineBytesStrict(fromClient bool, truncateLen int) ([]byte, error) { + var tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit int + if fromClient { + // enforce client max tags: + // (4096) :: '@' ' ' + tagLimit = MaxlenTagsFromClient + } else { + // on the server side, enforce separate client-only and server-added tag budgets: + // "Servers MUST NOT add tag data exceeding 4094 bytes to messages." + // (8191) :: '@' ';' ' ' + clientOnlyTagDataLimit = MaxlenClientTagData + serverAddedTagDataLimit = MaxlenServerTagData + } + return ircmsg.line(tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit, truncateLen) } // line returns a sendable line created from an IrcMessage. -func (ircmsg *IrcMessage) line(maxlenTags, maxlenRest int, useMaxLen bool) ([]byte, error) { +func (ircmsg *IrcMessage) line(tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit, truncateLen int) ([]byte, error) { + if len(ircmsg.Command) < 1 { + return nil, ErrorCommandMissing + } + var buf bytes.Buffer - if len(ircmsg.Command) < 1 { - return nil, errors.New("irc: IRC messages MUST have a command") - } - - if len(ircmsg.Tags) > 0 { - buf.WriteString("@") - + // write the tags, computing the budgets for client-only tags and regular tags + var lenRegularTags, lenClientOnlyTags, lenTags int + if 0 < len(ircmsg.tags) || 0 < len(ircmsg.clientOnlyTags) { + buf.WriteByte('@') firstTag := true - for tag, val := range ircmsg.Tags { - if !firstTag { - buf.WriteString(";") // delimiter + writeTags := func(tags map[string]string) { + for tag, val := range tags { + if !firstTag { + buf.WriteByte(';') // delimiter + } + buf.WriteString(tag) + if val != "" { + buf.WriteByte('=') + buf.WriteString(EscapeTagValue(val)) + } + firstTag = false } - buf.WriteString(tag) - if val.HasValue { - buf.WriteString("=") - buf.WriteString(EscapeTagValue(val.Value)) - } - firstTag = false } - - // truncate if desired - if useMaxLen && buf.Len() > maxlenTags { - buf.Truncate(maxlenTags) + writeTags(ircmsg.tags) + lenRegularTags = buf.Len() - 1 // '@' is not counted + writeTags(ircmsg.clientOnlyTags) + lenClientOnlyTags = (buf.Len() - 1) - lenRegularTags // '@' is not counted + if lenRegularTags != 0 { + // semicolon between regular and client-only tags is not counted + lenClientOnlyTags -= 1 } - - buf.WriteString(" ") + buf.WriteByte(' ') } + lenTags = buf.Len() - tagsLen := buf.Len() + if 0 < tagLimit && tagLimit < buf.Len() { + return nil, ErrorLineTooLong + } + if (0 < clientOnlyTagDataLimit && clientOnlyTagDataLimit < lenClientOnlyTags) || (0 < serverAddedTagDataLimit && serverAddedTagDataLimit < lenRegularTags) { + return nil, ErrorLineTooLong + } if len(ircmsg.Prefix) > 0 { - buf.WriteString(":") + buf.WriteByte(':') buf.WriteString(ircmsg.Prefix) - buf.WriteString(" ") + buf.WriteByte(' ') } buf.WriteString(ircmsg.Command) - if len(ircmsg.Params) > 0 { - for i, param := range ircmsg.Params { - buf.WriteString(" ") - if len(param) < 1 || strings.Contains(param, " ") || param[0] == ':' { - if i != len(ircmsg.Params)-1 { - return nil, errors.New("irc: Cannot have an empty param, a param with spaces, or a param that starts with ':' before the last parameter") - } - buf.WriteString(":") + for i, param := range ircmsg.Params { + buf.WriteByte(' ') + if len(param) < 1 || strings.IndexByte(param, ' ') != -1 || param[0] == ':' { + if i != len(ircmsg.Params)-1 { + return nil, ErrorBadParam } - buf.WriteString(param) + buf.WriteByte(':') } + buf.WriteString(param) } // truncate if desired // -2 for \r\n - restLen := buf.Len() - tagsLen - if useMaxLen && restLen > maxlenRest-2 { - buf.Truncate(tagsLen + (maxlenRest - 2)) + restLen := buf.Len() - lenTags + if 0 < truncateLen && (truncateLen-2) < restLen { + buf.Truncate(lenTags + (truncateLen - 2)) } - buf.WriteString("\r\n") - return buf.Bytes(), nil + result := buf.Bytes() + if bytes.IndexByte(result, '\x00') != -1 { + return nil, ErrorLineContainsBadChar + } + return result, nil } diff --git a/ircmsg/message_test.go b/ircmsg/message_test.go index 2e99133..b18ce15 100644 --- a/ircmsg/message_test.go +++ b/ircmsg/message_test.go @@ -1,8 +1,8 @@ package ircmsg import ( + "fmt" "reflect" - "strings" "testing" ) @@ -20,9 +20,9 @@ var decodelentests = []testcodewithlen{ {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", 20, MakeMessage(nil, "dan-!d@localhost", "PR")}, {"@time=12732;re TEST *\r\n", 512, - MakeMessage(MakeTags("time", "12732", "re", nil), "", "TEST", "*")}, - {"@time=12732;re TEST *\r\n", 12, - MakeMessage(MakeTags("time", "12732", "r", nil), "", "TEST", "*")}, + MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*")}, + {"@time=12732;re TEST *\r\n", 512, + MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*")}, {":dan- TESTMSG\r\n", 2048, MakeMessage(nil, "dan-", "TESTMSG")}, {":dan- TESTMSG dan \r\n", 12, @@ -36,30 +36,61 @@ var decodelentests = []testcodewithlen{ {"TESTMSG\r\n", 9, MakeMessage(nil, "", "TESTMSG")}, } + +// map[string]string{"time": "12732", "re": ""} var decodetests = []testcode{ {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message")}, + {"@time=2848 :dan-!d@localhost LIST\r\n", + MakeMessage(map[string]string{"time": "2848"}, "dan-!d@localhost", "LIST")}, + {"@time=2848 LIST\r\n", + MakeMessage(map[string]string{"time": "2848"}, "", "LIST")}, + {"LIST\r\n", + MakeMessage(nil, "", "LIST")}, {"@time=12732;re TEST *a asda:fs :fhye tegh\r\n", - MakeMessage(MakeTags("time", "12732", "re", nil), "", "TEST", "*a", "asda:fs", "fhye tegh")}, + MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*a", "asda:fs", "fhye tegh")}, {"@time=12732;re TEST *\r\n", - MakeMessage(MakeTags("time", "12732", "re", nil), "", "TEST", "*")}, + MakeMessage(map[string]string{"time": "12732", "re": ""}, "", "TEST", "*")}, {":dan- TESTMSG\r\n", MakeMessage(nil, "dan-", "TESTMSG")}, {":dan- TESTMSG dan \r\n", MakeMessage(nil, "dan-", "TESTMSG", "dan")}, + {"@time=2019-02-28T19:30:01.727Z ping HiThere!\r\n", + MakeMessage(map[string]string{"time": "2019-02-28T19:30:01.727Z"}, "", "PING", "HiThere!")}, + {"@+draft/test=hi\\nthere PING HiThere!\r\n", + MakeMessage(map[string]string{"+draft/test": "hi\nthere"}, "", "PING", "HiThere!")}, + {"ping asdf\n", + MakeMessage(nil, "", "PING", "asdf")}, + {"list", + MakeMessage(nil, "", "LIST")}, } -var decodetesterrors = []string{ - "\r\n", - " \r\n", - "@tags=tesa\r\n", - "@tags=tested \r\n", - ":dan- \r\n", - ":dan-\r\n", + +type testparseerror struct { + raw string + err error +} + +var decodetesterrors = []testparseerror{ + {"", ErrorLineIsEmpty}, + {"\r\n", ErrorLineIsEmpty}, + {"\r\n ", ErrorLineIsEmpty}, + {"\r\n ", ErrorLineIsEmpty}, + {" \r\n", ErrorLineIsEmpty}, + {" \r\n ", ErrorLineIsEmpty}, + {" \r\n ", ErrorLineIsEmpty}, + {"@tags=tesa\r\n", ErrorLineIsEmpty}, + {"@tags=tested \r\n", ErrorLineIsEmpty}, + {":dan- \r\n", ErrorLineIsEmpty}, + {":dan-\r\n", ErrorLineIsEmpty}, + {"@tag1=1;tag2=2 :dan \r\n", ErrorLineIsEmpty}, + {"@tag1=1;tag2=2 :dan \r\n", ErrorLineIsEmpty}, + {"@tag1=1;tag2=2\x00 :dan \r\n", ErrorLineContainsBadChar}, + {"@tag1=1;tag2=2\x00 :shivaram PRIVMSG #channel hi\r\n", ErrorLineContainsBadChar}, } func TestDecode(t *testing.T) { for _, pair := range decodelentests { - ircmsg, err := ParseLineMaxLen(pair.raw, pair.length, pair.length) + ircmsg, err := ParseLineStrict(pair.raw, true, pair.length) if err != nil { t.Error( "For", pair.raw, @@ -67,9 +98,6 @@ func TestDecode(t *testing.T) { ) } - // short-circuit sourceline so tests work - pair.message.SourceLine = strings.TrimRight(pair.raw, "\r\n") - if !reflect.DeepEqual(ircmsg, pair.message) { t.Error( "For", pair.raw, @@ -87,9 +115,6 @@ func TestDecode(t *testing.T) { ) } - // short-circuit sourceline so tests work - pair.message.SourceLine = strings.TrimRight(pair.raw, "\r\n") - if !reflect.DeepEqual(ircmsg, pair.message) { t.Error( "For", pair.raw, @@ -98,11 +123,13 @@ func TestDecode(t *testing.T) { ) } } - for _, line := range decodetesterrors { - _, err := ParseLine(line) - if err == nil { + for _, pair := range decodetesterrors { + _, err := ParseLineStrict(pair.raw, true, 0) + if err != pair.err { t.Error( - "Expected to fail parsing", line, + "For", pair.raw, + "expected", pair.err, + "got", err, ) } } @@ -112,19 +139,19 @@ var encodetests = []testcode{ {":dan-!d@localhost PRIVMSG dan #test :What a cool message\r\n", MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message")}, {"@time=12732 TEST *a asda:fs :fhye tegh\r\n", - MakeMessage(MakeTags("time", "12732"), "", "TEST", "*a", "asda:fs", "fhye tegh")}, + MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*a", "asda:fs", "fhye tegh")}, {"@time=12732 TEST *\r\n", - MakeMessage(MakeTags("time", "12732"), "", "TEST", "*")}, + MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*")}, {"@re TEST *\r\n", - MakeMessage(MakeTags("re", nil), "", "TEST", "*")}, + MakeMessage(map[string]string{"re": ""}, "", "TEST", "*")}, } var encodelentests = []testcodewithlen{ {":dan-!d@lo\r\n", 12, MakeMessage(nil, "dan-!d@localhost", "PRIVMSG", "dan", "#test", "What a cool message")}, {"@time=12732 TEST *\r\n", 52, - MakeMessage(MakeTags("time", "12732"), "", "TEST", "*")}, - {"@riohwih TEST *\r\n", 8, - MakeMessage(MakeTags("riohwihowihirgowihre", nil), "", "TEST", "*", "*")}, + MakeMessage(map[string]string{"time": "12732"}, "", "TEST", "*")}, + {"@riohwihowihirgowihre TEST *\r\n", 8, + MakeMessage(map[string]string{"riohwihowihirgowihre": ""}, "", "TEST", "*", "*")}, } func TestEncode(t *testing.T) { @@ -146,7 +173,7 @@ func TestEncode(t *testing.T) { } } for _, pair := range encodelentests { - line, err := pair.message.LineMaxLen(pair.length, pair.length) + line, err := pair.message.LineBytesStrict(true, pair.length) if err != nil { t.Error( "For", pair.raw, @@ -154,7 +181,7 @@ func TestEncode(t *testing.T) { ) } - if line != pair.raw { + if string(line) != pair.raw { t.Error( "For", pair.message, "expected", pair.raw, @@ -165,7 +192,7 @@ func TestEncode(t *testing.T) { // make sure we fail on no command msg := MakeMessage(nil, "example.com", "", "*") - _, err := msg.Line() + _, err := msg.LineBytesStrict(true, 0) if err == nil { t.Error( "For", "Test Failure 1", @@ -176,7 +203,7 @@ func TestEncode(t *testing.T) { // make sure we fail with params in right way msg = MakeMessage(nil, "example.com", "TEST", "*", "t s", "", "Param after empty!") - _, err = msg.Line() + _, err = msg.LineBytesStrict(true, 0) if err == nil { t.Error( "For", "Test Failure 2", @@ -185,3 +212,154 @@ func TestEncode(t *testing.T) { ) } } + +var testMessages = []IrcMessage{ + { + tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, + clientOnlyTags: map[string]string{"+status": "typing"}, + Prefix: "dan-!~user@example.com", + Command: "TAGMSG", + }, + { + clientOnlyTags: map[string]string{"+status": "typing"}, + Command: "PING", // invalid PING command but we don't care + }, + { + tags: map[string]string{"time": "2019-02-27T04:38:57.489Z"}, + Command: "PING", // invalid PING command but we don't care + Params: []string{"12345"}, + }, + { + tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, + Prefix: "dan-!~user@example.com", + Command: "PRIVMSG", + Params: []string{"#ircv3", ":smiley:"}, + }, + { + tags: map[string]string{"time": "2019-02-27T04:38:57.489Z", "account": "dan-"}, + Prefix: "dan-!~user@example.com", + Command: "PRIVMSG", + Params: []string{"#ircv3", "\x01ACTION writes some specs!\x01"}, + }, + { + Prefix: "dan-!~user@example.com", + Command: "PRIVMSG", + Params: []string{"#ircv3", ": long trailing command with langue française in it"}, + }, + { + Prefix: "dan-!~user@example.com", + Command: "PRIVMSG", + Params: []string{"#ircv3", " : long trailing command with langue française in it "}, + }, + { + Prefix: "shivaram", + Command: "KLINE", + Params: []string{"ANDKILL", "24h", "tkadich", "your", "client", "is", "disconnecting", "too", "much"}, + }, + { + tags: map[string]string{"time": "2019-02-27T06:01:23.545Z", "draft/msgid": "xjmgr6e4ih7izqu6ehmrtrzscy"}, + Prefix: "שיברם", + Command: "PRIVMSG", + Params: []string{"ויקם מלך חדש על מצרים אשר לא ידע את יוסף"}, + }, + { + Prefix: "shivaram!~user@2001:0db8::1", + Command: "KICK", + Params: []string{"#darwin", "devilbat", ":::::::::::::: :::::::::::::"}, + }, +} + +func TestEncodeDecode(t *testing.T) { + for _, message := range testMessages { + encoded, err := message.LineBytesStrict(false, 0) + if err != nil { + t.Errorf("Couldn't encode %v: %v", message, err) + } + parsed, err := ParseLineStrict(string(encoded), true, 0) + if err != nil { + t.Errorf("Couldn't re-decode %v: %v", encoded, err) + } + if !reflect.DeepEqual(message, parsed) { + t.Errorf("After encoding and re-parsing, got different messages:\n%v\n%v", message, parsed) + } + } +} + +func TestErrorLineTooLongGeneration(t *testing.T) { + message := IrcMessage{ + tags: map[string]string{"draft/msgid": "SAXV5OYJUr18CNJzdWa1qQ"}, + Prefix: "shivaram", + Command: "PRIVMSG", + Params: []string{"aaaaaaaaaaaaaaaaaaaaa"}, + } + _, err := message.LineBytesStrict(true, 0) + if err != nil { + t.Error(err) + } + + for i := 0; i < 100; i += 1 { + message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") + } + line, err := message.LineBytesStrict(true, 0) + if err != nil { + t.Error(err) + } + if 4096 < len(line) { + t.Errorf("line is too long: %d", len(line)) + } + + // add excess tag data, pushing us over the limit + for i := 100; i < 500; i += 1 { + message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") + } + line, err = message.LineBytesStrict(true, 0) + if err != ErrorLineTooLong { + t.Error(err) + } + + message.clientOnlyTags = nil + for i := 0; i < 500; i += 1 { + message.SetTag(fmt.Sprintf("server-tag-%d", i), "ok") + } + line, err = message.LineBytesStrict(true, 0) + if err != ErrorLineTooLong { + t.Error(err) + } + + message.tags = nil + message.clientOnlyTags = nil + for i := 0; i < 200; i += 1 { + message.SetTag(fmt.Sprintf("server-tag-%d", i), "ok") + message.SetTag(fmt.Sprintf("+client-tag-%d", i), "ok") + } + // client cannot send this much tag data: + line, err = message.LineBytesStrict(true, 0) + if err != ErrorLineTooLong { + t.Error(err) + } + // but a server can, since the tags are split between client and server budgets: + line, err = message.LineBytesStrict(false, 0) + if err != nil { + t.Error(err) + } +} + +func BenchmarkGenerate(b *testing.B) { + msg := MakeMessage( + map[string]string{"time": "2019-02-28T08:12:43.480Z", "account": "shivaram"}, + "shivaram_hexchat!~user@irc.darwin.network", + "PRIVMSG", + "#darwin", "what's up guys", + ) + b.ResetTimer() + for i := 0; i < b.N; i++ { + msg.LineBytesStrict(false, 0) + } +} + +func BenchmarkParse(b *testing.B) { + line := "@account=shivaram;draft/msgid=dqhkgglocqikjqikbkcdnv5dsq;time=2019-03-01T20:11:21.833Z :shivaram!~shivaram@good-fortune PRIVMSG #darwin :you're an EU citizen, right? it's illegal for you to be here now" + for i := 0; i < b.N; i++ { + ParseLineStrict(line, false, 0) + } +} diff --git a/ircmsg/tags.go b/ircmsg/tags.go index 5c56f9d..1ef23aa 100644 --- a/ircmsg/tags.go +++ b/ircmsg/tags.go @@ -3,28 +3,34 @@ package ircmsg +import "bytes" import "strings" var ( // valtoescape replaces real characters with message tag escapes. valtoescape = strings.NewReplacer("\\", "\\\\", ";", "\\:", " ", "\\s", "\r", "\\r", "\n", "\\n") - // escapetoval contains the IRCv3 Tag Escapes and how they map to characters. - escapetoval = map[rune]byte{ - ':': ';', - 's': ' ', - '\\': '\\', - 'r': '\r', - 'n': '\n', - } + escapedCharLookupTable [256]byte ) +func init() { + // most chars escape to themselves + for i := 0; i < 256; i += 1 { + escapedCharLookupTable[i] = byte(i) + } + // these are the exceptions + escapedCharLookupTable[':'] = ';' + escapedCharLookupTable['s'] = ' ' + escapedCharLookupTable['r'] = '\r' + escapedCharLookupTable['n'] = '\n' +} + // EscapeTagValue takes a value, and returns an escaped message tag value. // // This function is automatically used when lines are created from an // IrcMessage, so you don't need to call it yourself before creating a line. -func EscapeTagValue(in string) string { - return valtoescape.Replace(in) +func EscapeTagValue(inString string) string { + return valtoescape.Replace(inString) } // UnescapeTagValue takes an escaped message tag value, and returns the raw value. @@ -32,117 +38,38 @@ func EscapeTagValue(in string) string { // This function is automatically used when lines are interpreted by ParseLine, // so you don't need to call it yourself after parsing a line. func UnescapeTagValue(inString string) string { - in := []rune(inString) - var out string - for 0 < len(in) { - if in[0] == '\\' && len(in) > 1 { - val, exists := escapetoval[in[1]] - if exists == true { - out += string(val) + // buf.Len() == 0 is the fastpath where we have not needed to unescape any chars + var buf bytes.Buffer + remainder := inString + for { + backslashPos := strings.IndexByte(remainder, '\\') + + if backslashPos == -1 { + if buf.Len() == 0 { + return inString } else { - out += string(in[1]) + buf.WriteString(remainder) + break + } + } else if backslashPos == len(remainder)-1 { + // trailing backslash, which we strip + if buf.Len() == 0 { + return inString[:len(inString)-1] + } else { + buf.WriteString(remainder[:len(remainder)-1]) + break } - in = in[2:] - } else if in[0] == '\\' { - // trailing slash - in = in[1:] - } else { - out += string(in[0]) - in = in[1:] - } - } - - return out -} - -// TagValue represents the value of a tag. This is because tags may have -// no value at all or just an empty value, and this can represent both -// using the HasValue attribute. -type TagValue struct { - HasValue bool - Value string -} - -// NoTagValue returns an empty TagValue. -func NoTagValue() TagValue { - var tag TagValue - tag.HasValue = false - return tag -} - -// MakeTagValue returns a TagValue with a defined value. -func MakeTagValue(value string) TagValue { - var tag TagValue - tag.HasValue = true - tag.Value = value - return tag -} - -// MakeTags simplifies tag creation for new messages. -// -// For example: MakeTags("intent", "PRIVMSG", "account", "bunny", "noval", nil) -func MakeTags(values ...interface{}) *map[string]TagValue { - var tags map[string]TagValue - tags = make(map[string]TagValue) - - for len(values) > 1 { - tag := values[0].(string) - value := values[1] - var val TagValue - - if value == nil { - val = NoTagValue() - } else { - val = MakeTagValue(value.(string)) } - tags[tag] = val - - values = values[2:] - } - - return &tags -} - -// ParseTags takes a tag string such as "network=freenode;buffer=#chan;joined=1;topic=some\stopic" and outputs a TagValue map. -func ParseTags(tags string) (map[string]TagValue, error) { - return parseTags(tags, 0, false) -} - -// parseTags does the actual tags parsing for the above user-facing function. -func parseTags(tags string, maxlenTags int, useMaxLen bool) (map[string]TagValue, error) { - tagMap := make(map[string]TagValue) - - // confirm no bad strings exist - if strings.ContainsAny(tags, " \r\n") { - return tagMap, ErrorTagsContainsBadChar - } - - // truncate if desired - if useMaxLen && len(tags) > maxlenTags { - tags = tags[:maxlenTags] - } - - for _, fulltag := range strings.Split(tags, ";") { - // skip empty tag string values - if len(fulltag) < 1 { - continue + // non-trailing backslash detected; we're now on the slowpath + // where we modify the string + if buf.Len() == 0 { + buf.Grow(len(inString)) // just an optimization } - - var name string - var val TagValue - if strings.Contains(fulltag, "=") { - val.HasValue = true - splittag := strings.SplitN(fulltag, "=", 2) - name = splittag[0] - val.Value = UnescapeTagValue(splittag[1]) - } else { - name = fulltag - val.HasValue = false - } - - tagMap[name] = val + buf.WriteString(remainder[:backslashPos]) + buf.WriteByte(escapedCharLookupTable[remainder[backslashPos+1]]) + remainder = remainder[backslashPos+2:] } - return tagMap, nil + return buf.String() } diff --git a/ircmsg/tags_test.go b/ircmsg/tags_test.go index 6337d6a..e7dde0c 100644 --- a/ircmsg/tags_test.go +++ b/ircmsg/tags_test.go @@ -1,6 +1,7 @@ package ircmsg import ( + "fmt" "reflect" "testing" ) @@ -21,6 +22,14 @@ var unescapeTests = []testcase{ {"te\\n\\kst\\", "te\nkst"}, {"te\\\\nst", "te\\nst"}, {"te😃st", "te😃st"}, + {"0\\n1\\n2\\n3\\n4\\n5\\n6\\n\\", "0\n1\n2\n3\n4\n5\n6\n"}, + {"test\\", "test"}, + {"te\\:st\\", "te;st"}, + {"te\\:\\st\\", "te; t"}, + {"\\\\te\\:\\st", "\\te; t"}, + {"test\\", "test"}, + {"\\", ""}, + {"", ""}, } func TestEscape(t *testing.T) { @@ -65,50 +74,24 @@ func TestUnescape(t *testing.T) { // tag string tests type testtags struct { raw string - tags map[string]TagValue -} -type testtagswithlen struct { - raw string - length int - tags map[string]TagValue + tags map[string]string } -var tagdecodelentests = []testtagswithlen{ - {"time=12732;re", 512, *MakeTags("time", "12732", "re", nil)}, - {"time=12732;re", 12, *MakeTags("time", "12732", "r", nil)}, - {"", 512, *MakeTags()}, -} var tagdecodetests = []testtags{ - {"", *MakeTags()}, - {"time=12732;re", *MakeTags("time", "12732", "re", nil)}, + {"", map[string]string{}}, + {"time=12732;re", map[string]string{"time": "12732", "re": ""}}, + {"time=12732;re=;asdf=5678", map[string]string{"time": "12732", "re": "", "asdf": "5678"}}, + {"=these;time=12732;=shouldbe;re=;asdf=5678;=ignored", map[string]string{"time": "12732", "re": "", "asdf": "5678"}}, } -var tagdecodetesterrors = []string{ - "\r\n", - " \r\n", - "tags=tesa\r\n", - "tags=tested \r\n", + +func parseTags(rawTags string) (map[string]string, error) { + message, err := ParseLineStrict(fmt.Sprintf("@%s :shivaram TAGMSG #darwin\r\n", rawTags), true, 0) + return message.AllTags(), err } func TestDecodeTags(t *testing.T) { - for _, pair := range tagdecodelentests { - tags, err := parseTags(pair.raw, pair.length, true) - if err != nil { - t.Error( - "For", pair.raw, - "Failed to parse tags:", err, - ) - } - - if !reflect.DeepEqual(tags, pair.tags) { - t.Error( - "For", pair.raw, - "expected", pair.tags, - "got", tags, - ) - } - } for _, pair := range tagdecodetests { - tags, err := ParseTags(pair.raw) + tags, err := parseTags(pair.raw) if err != nil { t.Error( "For", pair.raw, @@ -124,12 +107,4 @@ func TestDecodeTags(t *testing.T) { ) } } - for _, line := range tagdecodetesterrors { - _, err := ParseTags(line) - if err == nil { - t.Error( - "Expected to fail parsing", line, - ) - } - } }