Merge pull request #11 from slingamn/messagetags.6

ircmsg: support ratified message-tags spec
This commit is contained in:
Daniel Oaks 2019-03-07 01:47:40 +00:00 committed by GitHub
commit ca74bf6a17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 568 additions and 344 deletions

10
.check-gofmt.sh Executable file

@ -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

@ -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

@ -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"))

@ -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,
)
)
}
}
}

@ -1,4 +1,6 @@
// written by Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2016-2019 Daniel Oaks <daniel@danieloaks.net>
// Copyright (c) 2018-2019 Shivaram Lingamneni <slingamn@cs.stanford.edu>
// 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:
// <client_max> (4096) :: '@' <tag_data 4094> ' '
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."
// <combined_max> (8191) :: '@' <tag_data 4094> ';' <tag_data 4094> ' '
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
}

@ -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)
}
}

@ -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()
}

@ -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,
)
}
}
}