123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463 |
- // 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
-
- import (
- "bytes"
- "errors"
- "strings"
- "unicode/utf8"
- )
-
- 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")
-
- // ErrorLineContainsBadChar indicates that the line contained invalid characters
- ErrorLineContainsBadChar = errors.New("Line contains invalid characters")
-
- // ErrorBodyTooLong indicates that the message body exceeded the specified
- // length limit (typically 512 bytes). This error is non-fatal; if encountered
- // when parsing a message, the message is parsed up to the length limit, and
- // if encountered when serializing a message, the message is truncated to the limit.
- ErrorBodyTooLong = errors.New("Line body exceeded the specified length limit; outgoing messages will be truncated")
-
- // ErrorTagsTooLong indicates that the message exceeded the maximum tag length
- // (the specified response on the server side is 417 ERR_INPUTTOOLONG).
- ErrorTagsTooLong = errors.New("Line could not be processed because its tag data exceeded the length limit")
-
- // ErrorInvalidTagContent indicates that a tag name or value was invalid
- ErrorInvalidTagContent = errors.New("Line could not be processed because it contained an invalid tag name or value")
-
- // ErrorCommandMissing indicates that an IRC message was invalid because it lacked a command.
- ErrorCommandMissing = errors.New("IRC messages MUST have a command")
-
- // ErrorBadParam indicates that an IRC message could not be serialized because
- // its parameters violated the syntactic constraints on IRC parameters:
- // non-final parameters cannot be empty, contain a space, or start with `:`.
- ErrorBadParam = errors.New("Cannot have an empty param, a param with spaces, or a param that starts with ':' before the last parameter")
- )
-
- // Message 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 Message struct {
- Prefix string
- Command string
- Params []string
- forceTrailing bool
- tags map[string]string
- clientOnlyTags map[string]string
- }
-
- // ForceTrailing ensures that when the message is serialized, the final parameter
- // will be encoded as a "trailing parameter" (preceded by a colon). This is
- // almost never necessary and should not be used except when having to interact
- // with broken implementations that don't correctly interpret IRC messages.
- func (msg *Message) ForceTrailing() {
- msg.forceTrailing = true
- }
-
- // GetTag returns whether a tag is present, and if so, what its value is.
- func (msg *Message) 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
- }
- }
-
- // HasTag returns whether a tag is present.
- func (msg *Message) HasTag(tagName string) (present bool) {
- present, _ = msg.GetTag(tagName)
- return
- }
-
- // SetTag sets a tag.
- func (msg *Message) 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 *Message) 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 *Message) UpdateTags(tags map[string]string) {
- for name, value := range tags {
- msg.SetTag(name, value)
- }
- }
-
- // AllTags returns all tags as a single map.
- func (msg *Message) 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 Message object and
- // should not be modified.
- func (msg *Message) ClientOnlyTags() map[string]string {
- return msg.clientOnlyTags
- }
-
- // ParseLine creates and returns a message from the given IRC line.
- func ParseLine(line string) (ircmsg Message, err error) {
- return parseLine(line, 0, 0)
- }
-
- // ParseLineStrict creates and returns an Message from the given IRC line,
- // taking the maximum length into account and truncating the message as appropriate.
- // 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 Message, err error) {
- maxTagDataLength := MaxlenTagData
- if fromClient {
- maxTagDataLength = MaxlenClientTagData
- }
- return parseLine(line, maxTagDataLength, truncateLen)
- }
-
- // slice off any amount of ' ' from the front of the string
- func trimInitialSpaces(str string) string {
- var i int
- for i = 0; i < len(str) && str[i] == ' '; i++ {
- }
- return str[i:]
- }
-
- func parseLine(line string, maxTagDataLength int, truncateLen int) (ircmsg Message, err error) {
- // remove either \n or \r\n from the end of the line:
- line = strings.TrimSuffix(line, "\n")
- line = strings.TrimSuffix(line, "\r")
- // whether we removed them ourselves, or whether they were removed previously,
- // they count against the line limit:
- if truncateLen != 0 {
- if truncateLen <= 2 {
- return ircmsg, ErrorLineIsEmpty
- }
- truncateLen -= 2
- }
- // now validate for the 3 forbidden bytes:
- if strings.IndexByte(line, '\x00') != -1 || strings.IndexByte(line, '\n') != -1 || strings.IndexByte(line, '\r') != -1 {
- return ircmsg, ErrorLineContainsBadChar
- }
-
- if len(line) < 1 {
- return ircmsg, ErrorLineIsEmpty
- }
-
- // tags
- if line[0] == '@' {
- tagEnd := strings.IndexByte(line, ' ')
- if tagEnd == -1 {
- return ircmsg, ErrorLineIsEmpty
- }
- tags := line[1:tagEnd]
- if 0 < maxTagDataLength && maxTagDataLength < len(tags) {
- return ircmsg, ErrorTagsTooLong
- }
- err = ircmsg.parseTags(tags)
- if err != nil {
- return
- }
- // skip over the tags and the separating space
- line = line[tagEnd+1:]
- }
-
- // truncate if desired
- if truncateLen != 0 && truncateLen < len(line) {
- err = ErrorBodyTooLong
- line = line[:truncateLen]
- }
-
- // modern: "These message parts, and parameters themselves, are separated
- // by one or more ASCII SPACE characters"
- line = trimInitialSpaces(line)
-
- // prefix
- if 0 < len(line) && line[0] == ':' {
- prefixEnd := strings.IndexByte(line, ' ')
- if prefixEnd == -1 {
- return ircmsg, ErrorLineIsEmpty
- }
- ircmsg.Prefix = line[1:prefixEnd]
- // skip over the prefix and the separating space
- line = line[prefixEnd+1:]
- }
-
- line = trimInitialSpaces(line)
-
- // command
- commandEnd := strings.IndexByte(line, ' ')
- paramStart := commandEnd + 1
- if commandEnd == -1 {
- commandEnd = len(line)
- paramStart = len(line)
- }
- // normalize command to uppercase:
- ircmsg.Command = strings.ToUpper(line[:commandEnd])
- if len(ircmsg.Command) == 0 {
- return ircmsg, ErrorLineIsEmpty
- }
- line = line[paramStart:]
-
- for {
- line = trimInitialSpaces(line)
- if len(line) == 0 {
- break
- }
- // 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
- }
- ircmsg.Params = append(ircmsg.Params, line[:paramEnd])
- line = line[paramEnd+1:]
- }
-
- return ircmsg, err
- }
-
- // helper to parse tags
- func (ircmsg *Message) 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:]
- }
- // "Implementations [...] MUST NOT perform any validation that would
- // reject the message if an invalid tag key name is used."
- if validateTagName(tagName) {
- if !validateTagValue(tagValue) {
- return ErrorInvalidTagContent
- }
- 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 Message.
- func MakeMessage(tags map[string]string, prefix string, command string, params ...string) (ircmsg Message) {
- ircmsg.Prefix = prefix
- ircmsg.Command = command
- ircmsg.Params = params
- ircmsg.UpdateTags(tags)
- return ircmsg
- }
-
- // Line returns a sendable line created from an Message.
- func (ircmsg *Message) 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 created from an Message.
- func (ircmsg *Message) LineBytes() (result []byte, err error) {
- result, err = ircmsg.line(0, 0, 0, 0)
- return
- }
-
- // LineBytesStrict returns a sendable line, as a []byte, created from an Message.
- // 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 *Message) 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)
- }
-
- func paramRequiresTrailing(param string) bool {
- return len(param) == 0 || strings.IndexByte(param, ' ') != -1 || param[0] == ':'
- }
-
- // line returns a sendable line created from an Message.
- func (ircmsg *Message) line(tagLimit, clientOnlyTagDataLimit, serverAddedTagDataLimit, truncateLen int) (result []byte, err error) {
- if len(ircmsg.Command) == 0 {
- return nil, ErrorCommandMissing
- }
-
- var buf bytes.Buffer
-
- // 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) {
- var tagError error
- buf.WriteByte('@')
- firstTag := true
- writeTags := func(tags map[string]string) {
- for tag, val := range tags {
- if !(validateTagName(tag) && validateTagValue(val)) {
- tagError = ErrorInvalidTagContent
- }
- if !firstTag {
- buf.WriteByte(';') // delimiter
- }
- buf.WriteString(tag)
- if val != "" {
- buf.WriteByte('=')
- buf.WriteString(EscapeTagValue(val))
- }
- firstTag = false
- }
- }
- 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.WriteByte(' ')
- if tagError != nil {
- return nil, tagError
- }
- }
- lenTags = buf.Len()
-
- if 0 < tagLimit && tagLimit < buf.Len() {
- return nil, ErrorTagsTooLong
- }
- if (0 < clientOnlyTagDataLimit && clientOnlyTagDataLimit < lenClientOnlyTags) || (0 < serverAddedTagDataLimit && serverAddedTagDataLimit < lenRegularTags) {
- return nil, ErrorTagsTooLong
- }
-
- if len(ircmsg.Prefix) > 0 {
- buf.WriteByte(':')
- buf.WriteString(ircmsg.Prefix)
- buf.WriteByte(' ')
- }
-
- buf.WriteString(ircmsg.Command)
-
- for i, param := range ircmsg.Params {
- buf.WriteByte(' ')
- requiresTrailing := paramRequiresTrailing(param)
- lastParam := i == len(ircmsg.Params)-1
- if (requiresTrailing || ircmsg.forceTrailing) && lastParam {
- buf.WriteByte(':')
- } else if requiresTrailing && !lastParam {
- return nil, ErrorBadParam
- }
- buf.WriteString(param)
- }
-
- // truncate if desired; leave 2 bytes over for \r\n:
- if truncateLen != 0 && (truncateLen-2) < (buf.Len()-lenTags) {
- err = ErrorBodyTooLong
- newBufLen := lenTags + (truncateLen - 2)
- buf.Truncate(newBufLen)
- // XXX: we may have truncated in the middle of a UTF8-encoded codepoint;
- // if so, remove additional bytes, stopping when the sequence either
- // ends in a valid codepoint, or we have removed 3 bytes (the maximum
- // length of the remnant of a once-valid, truncated codepoint; we don't
- // want to truncate the entire message if it wasn't UTF8 in the first
- // place).
- for i := 0; i < (utf8.UTFMax - 1); i++ {
- r, n := utf8.DecodeLastRune(buf.Bytes())
- if r == utf8.RuneError && n <= 1 {
- newBufLen--
- buf.Truncate(newBufLen)
- } else {
- break
- }
- }
- }
- buf.WriteString("\r\n")
-
- result = buf.Bytes()
- toValidate := result[:len(result)-2]
- if bytes.IndexByte(toValidate, '\x00') != -1 || bytes.IndexByte(toValidate, '\r') != -1 || bytes.IndexByte(toValidate, '\n') != -1 {
- return nil, ErrorLineContainsBadChar
- }
- return result, err
- }
|