From 658eba5c3b09e1794f55610519fc931836550532 Mon Sep 17 00:00:00 2001 From: Liam Stanley Date: Tue, 2 May 2023 22:47:12 -0400 Subject: [PATCH] Split messages (#43) All of the below should apply: 1. Input limit uses multi-byte check. One thing I'm not sure about is if servers that provide their limits are based off runes, or just based off bytes, but the current implementation uses rune length (to a single multi-byte character counts as single character). 2. Tries to keep colors/backgrounds/color resets/etc between splits. It's not perfect, but it should work "good enough". 3. If a very long single word is split, it will prefer to keep it together (but on another line) rather than splitting off small chunks at the end (e.g. URLs). 4. Default is word spliting, and if a word is above 30 characters, I try to split be special characters (e.g. `lalkmadlkmasdlka-adasdasdasdasd` would split at `-`). 5. Worst case, it splits at the exact point necessary to make it fit, if none of the above apply and the entire word is above the maximum length. As far as logic changes: 1. girc sets a default max line length of **510**, but some of this is subtracted with prefix padding, user length, host length, etc. Some of the subtractions aren't smart checks, just "assume a host will be this long" rather than keeping track of the actual sizes. There might be room for improvement on this item. 2. Servers can raise this limit with ISUPPORT or similar -- girc should respect that raised limit. 3. During the connection `Send` method, we invoke `Event.split` -- note that not all events are split (only `PRIVMSG` and `NOTICE` for now, `JOIN`'s have always had custom logic to split already). Could apply the logic to all commands, but there are some commands that **will not support splitting**, so prefer to be conservative for now. Also merged in changes from branch `bugfix/issue-50`, which includes the swap to a ctx group for keeping track of the core handlers (ping/read/send/etc). --- builtin.go | 42 ++++ cap.go | 4 +- cap_sasl.go | 6 +- cap_tags.go | 7 +- client.go | 81 +++++-- client_test.go | 10 +- commands.go | 8 +- conn.go | 203 +++++++++------- conn_test.go | 16 +- ctcp_test.go | 72 ++++-- event.go | 146 +++++++++--- event_test.go | 6 - format.go | 216 ++++++++++++++++-- format_test.go | 24 ++ internal/ctxgroup/ctxgroup.go | 67 ++++++ modes.go | 15 +- state.go | 15 +- ...93992236c39c7db227dc95932739fabf9f34c0a577 | 2 + ...6c599dfadb3a696064e0b0c06f5950492f743e4b86 | 2 + ...65f6a8e3376992cf56c7c8d3e88f448384f414c9fb | 2 + ...01e1a6e2e89070acf8e767fd5cf945ffde5a4e3bd4 | 2 + ...d54764c2dfd969a1001dc7dd57598a5f4b293fa3f6 | 2 + ...e92a6036b1982e34a0e60fb8064aed3ebf3fc6ab0b | 2 + ...bfd55da7512e2757047733e94da6392e0e194d59af | 2 + 24 files changed, 743 insertions(+), 209 deletions(-) create mode 100644 internal/ctxgroup/ctxgroup.go create mode 100644 testdata/fuzz/FuzzSplit/01687ab5c328878e7c422b93992236c39c7db227dc95932739fabf9f34c0a577 create mode 100644 testdata/fuzz/FuzzSplit/0302061680309c38682da36c599dfadb3a696064e0b0c06f5950492f743e4b86 create mode 100644 testdata/fuzz/FuzzSplit/418f7abc672f01b6f11f6265f6a8e3376992cf56c7c8d3e88f448384f414c9fb create mode 100644 testdata/fuzz/FuzzSplit/66f3fa3227d7428c80cc1701e1a6e2e89070acf8e767fd5cf945ffde5a4e3bd4 create mode 100644 testdata/fuzz/FuzzSplit/ca23233b2658f2fa5f63dfd54764c2dfd969a1001dc7dd57598a5f4b293fa3f6 create mode 100644 testdata/fuzz/FuzzSplit/dde7a120ccdf11af18793ee92a6036b1982e34a0e60fb8064aed3ebf3fc6ab0b create mode 100644 testdata/fuzz/FuzzSplit/ed0ca8ee1ecc6872d4b0eabfd55da7512e2757047733e94da6392e0e194d59af diff --git a/builtin.go b/builtin.go index e60c577..345452e 100644 --- a/builtin.go +++ b/builtin.go @@ -408,6 +408,48 @@ func handleISUPPORT(c *Client, e Event) { c.state.serverOptions[name] = val } c.state.Unlock() + + // Check for max line/nick/user/host lengths here. + c.state.RLock() + maxLineLength := c.state.maxLineLength + c.state.RUnlock() + maxNickLength := defaultNickLength + maxUserLength := defaultUserLength + maxHostLength := defaultHostLength + + var ok bool + var tmp int + + if tmp, ok = c.GetServerOptionInt("LINELEN"); ok { + maxLineLength = tmp + c.state.Lock() + c.state.maxLineLength = maxTagLength - 2 // -2 for CR-LF. + c.state.Unlock() + } + + if tmp, ok = c.GetServerOptionInt("NICKLEN"); ok { + maxNickLength = tmp + } + if tmp, ok = c.GetServerOptionInt("MAXNICKLEN"); ok && tmp > maxNickLength { + maxNickLength = tmp + } + if tmp, ok = c.GetServerOptionInt("USERLEN"); ok && tmp > maxUserLength { + maxUserLength = tmp + } + if tmp, ok = c.GetServerOptionInt("HOSTLEN"); ok && tmp > maxHostLength { + maxHostLength = tmp + } + + prefixLen := defaultPrefixPadding + maxNickLength + maxUserLength + maxHostLength + if prefixLen >= maxLineLength { + // Give up and go with defaults. + c.state.notify(c, UPDATE_GENERAL) + return + } + c.state.Lock() + c.state.maxPrefixLength = prefixLen + c.state.Unlock() + c.state.notify(c, UPDATE_GENERAL) } diff --git a/cap.go b/cap.go index 631b925..f35f2ec 100644 --- a/cap.go +++ b/cap.go @@ -267,9 +267,9 @@ func handleCAP(c *Client, e Event) { } if isError { - c.rx <- &Event{Command: ERROR, Params: []string{ + c.receive(&Event{Command: ERROR, Params: []string{ fmt.Sprintf("closing connection: strict transport policy provided by server is invalid; possible MITM? config: %#v", sts), - }} + }}) return } diff --git a/cap_sasl.go b/cap_sasl.go index d880316..2a1e841 100644 --- a/cap_sasl.go +++ b/cap_sasl.go @@ -95,9 +95,9 @@ func handleSASL(c *Client, e Event) { // some reason. The SASL spec and IRCv3 spec do not define a clear // way to abort a SASL exchange, other than to disconnect, or proceed // with CAP END. - c.rx <- &Event{Command: ERROR, Params: []string{ + c.receive(&Event{Command: ERROR, Params: []string{ fmt.Sprintf("closing connection: SASL %s failed: %s", c.Config.SASL.Method(), e.Last()), - }} + }}) return } @@ -131,5 +131,5 @@ func handleSASLError(c *Client, e Event) { // Authentication failed. The SASL spec and IRCv3 spec do not define a // clear way to abort a SASL exchange, other than to disconnect, or // proceed with CAP END. - c.rx <- &Event{Command: ERROR, Params: []string{"closing connection: " + e.Last()}} + c.receive(&Event{Command: ERROR, Params: []string{"closing connection: " + e.Last()}}) } diff --git a/cap_tags.go b/cap_tags.go index 42599f3..3cc8887 100644 --- a/cap_tags.go +++ b/cap_tags.go @@ -52,9 +52,12 @@ type Tags map[string]string // ParseTags parses out the key-value map of tags. raw should only be the tag // data, not a full message. For example: -// @aaa=bbb;ccc;example.com/ddd=eee +// +// @aaa=bbb;ccc;example.com/ddd=eee +// // NOT: -// @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello +// +// @aaa=bbb;ccc;example.com/ddd=eee :nick!ident@host.com PRIVMSG me :Hello // // Technically, there is a length limit of 4096, but the server should reject // tag messages longer than this. diff --git a/client.go b/client.go index 672a4fd..fa2d7f6 100644 --- a/client.go +++ b/client.go @@ -183,13 +183,13 @@ type Config struct { // server. // // Client expectations: -// - Perform any proxy resolution. -// - Check the reverse DNS and forward DNS match. -// - Check the IP against suitable access controls (ipaccess, dnsbl, etc). +// - Perform any proxy resolution. +// - Check the reverse DNS and forward DNS match. +// - Check the IP against suitable access controls (ipaccess, dnsbl, etc). // // More information: -// - https://ircv3.net/specs/extensions/webirc.html -// - https://kiwiirc.com/docs/webirc +// - https://ircv3.net/specs/extensions/webirc.html +// - https://kiwiirc.com/docs/webirc type WebIRC struct { // Password that authenticates the WEBIRC command from this client. Password string @@ -308,6 +308,23 @@ func New(config Config) *Client { return c } +// receive is a wrapper for sending to the Client.rx channel. It will timeout if +// the event can't be sent within 30s. +func (c *Client) receive(e *Event) { + t := time.NewTimer(30 * time.Second) + defer func() { + if !t.Stop() { + <-t.C + } + }() + + select { + case c.rx <- e: + case <-t.C: + c.debugLogEvent(e, true) + } +} + // String returns a brief description of the current client state. func (c *Client) String() string { connected := c.IsConnected() @@ -388,7 +405,7 @@ func (e *ErrEvent) Error() string { return e.Event.Last() } -func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) { +func (c *Client) execLoop(ctx context.Context) error { c.debug.Print("starting execLoop") defer c.debug.Print("closing execLoop") @@ -411,9 +428,10 @@ func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGro } done: - wg.Done() - return + return nil case event = <-c.rx: + c.RunHandlers(event) + if event != nil && event.Command == ERROR { // Handles incoming ERROR responses. These are only ever sent // by the server (with the exception that this library may use @@ -423,13 +441,9 @@ func (c *Client) execLoop(ctx context.Context, errs chan error, wg *sync.WaitGro // some reason the server doesn't disconnect the client, or // if this library is the source of the error, this should // signal back up to the main connect loop, to disconnect. - errs <- &ErrEvent{Event: event} - // Make sure to not actually exit, so we can let any handlers - // actually handle the ERROR event. + return &ErrEvent{Event: event} } - - c.RunHandlers(event) } } } @@ -677,8 +691,7 @@ func (c *Client) IsInChannel(channel string) (in bool) { // during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL). // Will panic if used when tracking has been disabled. Examples of usage: // -// nickLen, success := GetServerOption("MAXNICKLEN") -// +// nickLen, success := GetServerOption("MAXNICKLEN") func (c *Client) GetServerOption(key string) (result string, ok bool) { c.panicIfNotTracking() @@ -688,6 +701,42 @@ func (c *Client) GetServerOption(key string) (result string, ok bool) { return result, ok } +// GetServerOptionInt retrieves a server capability setting (as an integer) that was +// retrieved during client connection. This is also known as ISUPPORT (or RPL_PROTOCTL). +// Will panic if used when tracking has been disabled. Examples of usage: +// +// nickLen, success := GetServerOption("MAXNICKLEN") +func (c *Client) GetServerOptionInt(key string) (result int, ok bool) { + var data string + var err error + + data, ok = c.GetServerOption(key) + if !ok { + return result, ok + } + result, err = strconv.Atoi(data) + if err != nil { + ok = false + } + + return result, ok +} + +// MaxEventLength returns the maximum supported server length of an event. This is the +// maximum length of the command and arguments, excluding the source/prefix supported +// by the protocol. If state tracking is enabled, this will utilize ISUPPORT/IRCv3 +// information to more accurately calculate the maximum supported length (i.e. extended +// length events). +func (c *Client) MaxEventLength() (max int) { + if !c.Config.disableTracking { + c.state.RLock() + max = c.state.maxLineLength - c.state.maxPrefixLength + c.state.RUnlock() + return max + } + return DefaultMaxLineLength - DefaultMaxPrefixLength +} + // NetworkName returns the network identifier. E.g. "EsperNet", "ByteIRC". // May be empty if the server does not support RPL_ISUPPORT (or RPL_PROTOCTL). // Will panic if used when tracking has been disabled. @@ -781,7 +830,7 @@ func (c *Client) debugLogEvent(e *Event, dropped bool) { var prefix string if dropped { - prefix = "dropping event (disconnected):" + prefix = "dropping event (disconnected or timeout):" } else { prefix = ">" } diff --git a/client_test.go b/client_test.go index 907b39f..bdf0c87 100644 --- a/client_test.go +++ b/client_test.go @@ -179,8 +179,12 @@ func TestClientClose(t *testing.T) { errchan := make(chan error, 1) done := make(chan struct{}, 1) - c.Handlers.AddBg(CLOSED, func(c *Client, e Event) { close(done) }) - c.Handlers.AddBg(INITIALIZED, func(c *Client, e Event) { c.Close() }) + c.Handlers.AddBg(CLOSED, func(c *Client, e Event) { + close(done) + }) + c.Handlers.AddBg(INITIALIZED, func(c *Client, e Event) { + c.Close() + }) go func() { errchan <- c.MockConnect(server) }() @@ -193,7 +197,7 @@ func TestClientClose(t *testing.T) { } t.Fatalf("connect returned with error when close was invoked: %s", err) - case <-time.After(5 * time.Second): + case <-time.After(35 * time.Second): t.Fatal("Client.Close() timed out") case <-done: } diff --git a/commands.go b/commands.go index 91a8b96..a3bec87 100644 --- a/commands.go +++ b/commands.go @@ -25,8 +25,8 @@ func (cmd *Commands) Nick(name string) { // prevent sending extensive JOIN commands. func (cmd *Commands) Join(channels ...string) { // We can join multiple channels at once, however we need to ensure that - // we are not exceeding the line length. (see maxLength) - max := maxLength - len(JOIN) - 1 + // we are not exceeding the line length (see Client.MaxEventLength()). + max := cmd.c.MaxEventLength() - len(JOIN) - 1 var buffer string @@ -329,8 +329,8 @@ func (cmd *Commands) List(channels ...string) { } // We can LIST multiple channels at once, however we need to ensure that - // we are not exceeding the line length. (see maxLength) - max := maxLength - len(JOIN) - 1 + // we are not exceeding the line length (see Client.MaxEventLength()). + max := cmd.c.MaxEventLength() - len(JOIN) - 1 var buffer string diff --git a/conn.go b/conn.go index 00229d3..c32eca6 100644 --- a/conn.go +++ b/conn.go @@ -12,6 +12,8 @@ import ( "net" "sync" "time" + + "github.com/lrstanley/girc/internal/ctxgroup" ) // Messages are delimited with CR and LF line endings, we're using the last @@ -142,17 +144,44 @@ type ErrParseEvent struct { func (e ErrParseEvent) Error() string { return "unable to parse event: " + e.Line } -func (c *ircConn) decode() (event *Event, err error) { - line, err := c.io.ReadString(delim) - if err != nil { - return nil, err - } +type decodedEvent struct { + event *Event + err error +} - if event = ParseEvent(line); event == nil { - return nil, ErrParseEvent{line} - } +func (c *ircConn) decode() <-chan decodedEvent { + ch := make(chan decodedEvent) + + go func() { + defer close(ch) + + line, err := c.io.ReadString(delim) + if err != nil { + select { + case ch <- decodedEvent{err: err}: + default: + } + + return + } + + event := ParseEvent(line) + if event == nil { + select { + case ch <- decodedEvent{err: ErrParseEvent{Line: line}}: + default: + } + + return + } - return event, nil + select { + case ch <- decodedEvent{event: event}: + default: + } + }() + + return ch } func (c *ircConn) encode(event *Event) error { @@ -291,20 +320,17 @@ startConn: } else { c.conn = newMockConn(mock) } + c.mu.Unlock() var ctx context.Context ctx, c.stop = context.WithCancel(context.Background()) - c.mu.Unlock() - errs := make(chan error, 4) - var wg sync.WaitGroup - // 4 being the number of goroutines we need to finish when this function - // returns. - wg.Add(4) - go c.execLoop(ctx, errs, &wg) - go c.readLoop(ctx, errs, &wg) - go c.sendLoop(ctx, errs, &wg) - go c.pingLoop(ctx, errs, &wg) + group := ctxgroup.New(ctx) + + group.Go(c.execLoop) + group.Go(c.readLoop) + group.Go(c.sendLoop) + group.Go(c.pingLoop) // Passwords first. @@ -338,16 +364,15 @@ startConn: c.RunHandlers(&Event{Command: INITIALIZED, Params: []string{addr}}) // Wait for the first error. - var result error - select { - case <-ctx.Done(): + err := group.Wait() + if err != nil { + c.debug.Printf("received error, beginning cleanup: %v", err) + } else { if !c.state.sts.beginUpgrade { c.debug.Print("received request to close, beginning clean up") } + c.RunHandlers(&Event{Command: CLOSED, Params: []string{addr}}) - case err := <-errs: - c.debug.Printf("received error, beginning cleanup: %v", err) - result = err } // Make sure that the connection is closed if not already. @@ -363,20 +388,13 @@ startConn: c.RunHandlers(&Event{Command: DISCONNECTED, Params: []string{addr}}) - // Once we have our error/result, let all other functions know we're done. - c.debug.Print("waiting for all routines to finish") - - // Wait for all goroutines to finish. - wg.Wait() - close(errs) - // This helps ensure that the end user isn't improperly using the client // more than once. If they want to do this, they should be using multiple // clients, not multiple instances of Connect(). c.mu.Lock() c.conn = nil - if result == nil { + if err == nil { if c.state.sts.beginUpgrade { c.state.sts.beginUpgrade = false c.mu.Unlock() @@ -389,76 +407,85 @@ startConn: } c.mu.Unlock() - return result + return err } // readLoop sets a timeout of 300 seconds, and then attempts to read from the // IRC server. If there is an error, it calls Reconnect. -func (c *Client) readLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) { +func (c *Client) readLoop(ctx context.Context) error { c.debug.Print("starting readLoop") defer c.debug.Print("closing readLoop") - var event *Event - var err error + var de decodedEvent for { select { case <-ctx.Done(): - wg.Done() - return + return nil default: _ = c.conn.sock.SetReadDeadline(time.Now().Add(300 * time.Second)) - event, err = c.conn.decode() - if err != nil { - errs <- err - wg.Done() - return + + select { + case <-ctx.Done(): + return nil + case de = <-c.conn.decode(): + } + + if de.err != nil { + return de.err } // Check if it's an echo-message. if !c.Config.disableTracking { - event.Echo = (event.Command == PRIVMSG || event.Command == NOTICE) && - event.Source != nil && event.Source.ID() == c.GetID() + de.event.Echo = (de.event.Command == PRIVMSG || de.event.Command == NOTICE) && + de.event.Source != nil && de.event.Source.ID() == c.GetID() } - c.rx <- event + c.receive(de.event) } } } -// Send sends an event to the server. Use Client.RunHandlers() if you are -// simply looking to trigger handlers with an event. +// Send sends an event to the server. Send will split events if the event is longer +// than what the server supports, and is an event that supports splitting. Use +// Client.RunHandlers() if you are simply looking to trigger handlers with an event. func (c *Client) Send(event *Event) { var delay time.Duration - if !c.Config.AllowFlood { - c.mu.RLock() - - // Drop the event early as we're disconnected, this way we don't have to wait - // the (potentially long) rate limit delay before dropping. - if c.conn == nil { - c.debugLogEvent(event, true) - c.mu.RUnlock() - return - } - - c.conn.mu.Lock() - delay = c.conn.rate(event.Len()) - c.conn.mu.Unlock() - c.mu.RUnlock() - } - if c.Config.GlobalFormat && len(event.Params) > 0 && event.Params[len(event.Params)-1] != "" && (event.Command == PRIVMSG || event.Command == TOPIC || event.Command == NOTICE) { event.Params[len(event.Params)-1] = Fmt(event.Params[len(event.Params)-1]) } - <-time.After(delay) - c.write(event) + var events []*Event + events = event.split(c.MaxEventLength()) + + for _, e := range events { + if !c.Config.AllowFlood { + c.mu.RLock() + + // Drop the event early as we're disconnected, this way we don't have to wait + // the (potentially long) rate limit delay before dropping. + if c.conn == nil { + c.debugLogEvent(e, true) + c.mu.RUnlock() + return + } + + c.conn.mu.Lock() + delay = c.conn.rate(e.Len()) + c.conn.mu.Unlock() + c.mu.RUnlock() + } + + <-time.After(delay) + c.write(e) + } } // write is the lower level function to write an event. It does not have a -// write-delay when sending events. +// write-delay when sending events. write will timeout after 30s if the event +// can't be sent. func (c *Client) write(event *Event) { c.mu.RLock() defer c.mu.RUnlock() @@ -468,7 +495,19 @@ func (c *Client) write(event *Event) { c.debugLogEvent(event, true) return } - c.tx <- event + + t := time.NewTimer(30 * time.Second) + defer func() { + if !t.Stop() { + <-t.C + } + }() + + select { + case c.tx <- event: + case <-t.C: + c.debugLogEvent(event, true) + } } // rate allows limiting events based on how frequent the event is being sent, @@ -487,7 +526,7 @@ func (c *ircConn) rate(chars int) time.Duration { return 0 } -func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) { +func (c *Client) sendLoop(ctx context.Context) error { c.debug.Print("starting sendLoop") defer c.debug.Print("closing sendLoop") @@ -537,18 +576,14 @@ func (c *Client) sendLoop(ctx context.Context, errs chan error, wg *sync.WaitGro if event.Command == QUIT { c.Close() - wg.Done() - return + return nil } if err != nil { - errs <- err - wg.Done() - return + return err } case <-ctx.Done(): - wg.Done() - return + return nil } } } @@ -568,11 +603,10 @@ type ErrTimedOut struct { func (ErrTimedOut) Error() string { return "timed out waiting for a requested PING response" } -func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGroup) { +func (c *Client) pingLoop(ctx context.Context) error { // Don't run the pingLoop if they want to disable it. if c.Config.PingDelay <= 0 { - wg.Done() - return + return nil } c.debug.Print("starting pingLoop") @@ -614,9 +648,7 @@ func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGro } c.conn.mu.RUnlock() - errs <- err - wg.Done() - return + return err } c.conn.mu.RUnlock() @@ -627,8 +659,7 @@ func (c *Client) pingLoop(ctx context.Context, errs chan error, wg *sync.WaitGro c.Cmd.Ping(fmt.Sprintf("%d", time.Now().UnixNano())) pingSent = true case <-ctx.Done(): - wg.Done() - return + return nil } } } diff --git a/conn_test.go b/conn_test.go index 2df9e1f..b46b2c0 100644 --- a/conn_test.go +++ b/conn_test.go @@ -31,20 +31,20 @@ func TestDecode(t *testing.T) { in.Write(e.Bytes()) in.Write(endline) - event, err := c.decode() - if err != nil { - t.Fatalf("received error during decode: %s", err) + de := <-c.decode() + if de.err != nil { + t.Fatalf("received error during decode: %s", de.err) } - if event.String() != e.String() { - t.Fatalf("event returned from decode not the same as mock event. want %#v, got %#v", e, event) + if de.event.String() != e.String() { + t.Fatalf("event returned from decode not the same as mock event. want %#v, got %#v", e, de.event) } // Test a failure. in.WriteString("::abcd\r\n") - event, err = c.decode() - if err == nil { - t.Fatalf("should have failed to parse decoded event. got: %#v", event) + de = <-c.decode() + if de.err == nil { + t.Fatalf("should have failed to parse decoded event. got: %#v", de.event) } } diff --git a/ctcp_test.go b/ctcp_test.go index fee6474..72a4a88 100644 --- a/ctcp_test.go +++ b/ctcp_test.go @@ -66,41 +66,65 @@ func TestDecodeCTCP(t *testing.T) { args args want *CTCPEvent }{ - {name: "non-ctcp", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "this is a test"}}, + {name: "non-ctcp", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "this is a test"}, + }, }, want: nil}, - {name: "empty trailing", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", ""}}, + {name: "empty trailing", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", ""}, + }, }, want: nil}, - {name: "too many args", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "user2", "this is a test"}}, + {name: "too many args", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "user2", "this is a test"}, + }, }, want: nil}, - {name: "missing delim", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST this is a test"}}, + {name: "missing delim", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST this is a test"}, + }, }, want: nil}, - {name: "missing delim", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "TEST this is a test\001"}}, + {name: "missing delim", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "TEST this is a test\001"}, + }, }, want: nil}, - {name: "invalid command", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST-1 this is a test\001"}}, + {name: "invalid command", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST-1 this is a test\001"}, + }, }, want: nil}, - {name: "invalid command", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST-1\001"}}, + {name: "invalid command", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST-1\001"}, + }, }, want: nil}, - {name: "is reply", args: args{event: &Event{ - Command: "NOTICE", Params: []string{"user1", "\001TEST this is a test\001"}}, + {name: "is reply", args: args{ + event: &Event{ + Command: "NOTICE", Params: []string{"user1", "\001TEST this is a test\001"}, + }, }, want: &CTCPEvent{Command: "TEST", Text: "this is a test", Reply: true}}, - {name: "is reply, tag only", args: args{event: &Event{ - Command: "NOTICE", Params: []string{"user1", "\001TEST\001"}}, + {name: "is reply, tag only", args: args{ + event: &Event{ + Command: "NOTICE", Params: []string{"user1", "\001TEST\001"}, + }, }, want: &CTCPEvent{Command: "TEST", Text: "", Reply: true}}, - {name: "is reply", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST\001"}}, + {name: "is reply", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST\001"}, + }, }, want: &CTCPEvent{Command: "TEST", Text: ""}}, - {name: "has args", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST 1 2 3 4\001"}}, + {name: "has args", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST 1 2 3 4\001"}, + }, }, want: &CTCPEvent{Command: "TEST", Text: "1 2 3 4"}}, - {name: "has args", args: args{event: &Event{ - Command: "PRIVMSG", Params: []string{"user1", "\001TEST :1 2 3 4\001"}}, + {name: "has args", args: args{ + event: &Event{ + Command: "PRIVMSG", Params: []string{"user1", "\001TEST :1 2 3 4\001"}, + }, }, want: &CTCPEvent{Command: "TEST", Text: ":1 2 3 4"}}, } diff --git a/event.go b/event.go index 7801615..2622f89 100644 --- a/event.go +++ b/event.go @@ -13,7 +13,41 @@ import ( const ( eventSpace byte = ' ' // Separator. - maxLength int = 510 // Maximum length is 510 (2 for line endings). + + // TODO: if state tracking is enabled, we SHOULD be able to use it's known length. + + // Can be overridden by the NICKLEN (or MAXNICKLEN) ISUPPORT parameter. 30 or 31 + // are typical values for this parameter advertised by servers today. + defaultNickLength = 30 + // The maximum length of may be specified by the USERLEN RPL_ISUPPORT + // parameter. If this length is advertised, the username MUST be silently truncated + // to the given length before being used. + defaultUserLength = 18 + // If a looked-up domain name is longer than this length (or overridden by the + // HOSTLEN ISUPPORT parameter), the server SHOULD opt to use the IP address instead, + // so that the hostname is underneath this length. + defaultHostLength = 63 + + // defaultPrefixPadding defaults the estimated prefix padding length of a given + // event. See also: + // [ ":" ( servername / ( nickname [ [ "!" user ] "@" host ] ) ) SPACE ] + defaultPrefixPadding = 4 +) + +var ( + // DefaultMaxLineLength is the default maximum length for an event. 510 (+2 for line endings) + // is used as a default as this is used by many older implementations. + // + // See also: RFC 2812 + // IRC messages are always lines of characters terminated with a CR-LF + // (Carriage Return - Line Feed) pair, and these messages SHALL NOT + // exceed 512 characters in length, counting all characters including + // the trailing CR-LF. + DefaultMaxLineLength = 510 + + // DefaultMaxPrefixLength defines the default max ":nickname!user@host " length + // that's used to calculate line splitting. + DefaultMaxPrefixLength = defaultPrefixPadding + defaultNickLength + defaultUserLength + defaultHostLength ) // cutCRFunc is used to trim CR characters from prefixes/messages. @@ -125,16 +159,16 @@ func ParseEvent(raw string) (e *Event) { // Event represents an IRC protocol message, see RFC1459 section 2.3.1 // -// :: [':' ] -// :: | ['!' ] ['@' ] -// :: {} | -// :: ' '{' '} -// :: [':' | ] -// :: -// :: -// :: CR LF +// :: [':' ] +// :: | ['!' ] ['@' ] +// :: {} | +// :: ' '{' '} +// :: [':' | ] +// :: +// :: +// :: CR LF type Event struct { // Source is the origin of the event. Source *Source `json:"source"` @@ -223,11 +257,80 @@ func (e *Event) Equals(ev *Event) bool { return true } -// Len calculates the length of the string representation of event. Note that -// this will return the true length (even if longer than what IRC supports), -// which may be useful if you are trying to check and see if a message is -// too long, to trim it down yourself. +// split will split a potentially large event that is larger than what the server +// supports, into multiple events. split will ignore events that cannot be split, and +// if the event isn't longer than what the server supports, it will just return an array +// with 1 entry, the original event. +func (e *Event) split(maxLength int) []*Event { + if len(e.Params) < 1 || (e.Command != PRIVMSG && e.Command != NOTICE) { + return []*Event{e} + } + + // Exclude source, even if it does exist, because the server will likely ignore the + // sent source anyway. + event := e.Copy() + event.Source = nil + + if event.LenOpts(false) < maxLength { + return []*Event{e} + } + + results := []*Event{} + + // Will force the length check to include " :". This will allow us to get the length + // of the commands and necessary prefixes. + text := event.Last() + event.Params[len(event.Params)-1] = "" + cmdLen := event.LenOpts(false) + + var ok bool + var ctcp *CTCPEvent + if ok, ctcp = e.IsCTCP(); ok { + if text == "" { + return []*Event{e} + } + + text = ctcp.Text + + // ctcpDelim's at start and end, and space between command and trailing text. + maxLength -= len(ctcp.Command) + 4 + } + + // If the command itself is longer than the limit, there is a problem. PRIVMSG should + // be 1->1 per RFC. Just return the original message and let it be the user of the + // libraries problem. + if cmdLen > maxLength { + return []*Event{e} + } + + // Split the text into correctly size segments, and make the necessary number of + // events that duplicate the original event. + for _, split := range splitMessage(text, maxLength-cmdLen) { + if ctcp != nil { + split = string(ctcpDelim) + ctcp.Command + string(eventSpace) + split + string(ctcpDelim) + } + clonedEvent := event.Copy() + clonedEvent.Source = e.Source + clonedEvent.Params[len(e.Params)-1] = split + results = append(results, clonedEvent) + } + + return results +} + +// Len calculates the length of the string representation of event (including tags). +// Note that this will return the true length (even if longer than what IRC supports), +// which may be useful if you are trying to check and see if a message is too long, to +// trim it down yourself. func (e *Event) Len() (length int) { + return e.LenOpts(true) +} + +// LenOpts calculates the length of the string representation of event (with a toggle +// for tags). Note that this will return the true length (even if longer than what IRC +// supports), which may be useful if you are trying to check and see if a message is +// too long, to trim it down yourself. +func (e *Event) LenOpts(includeTags bool) (length int) { if e.Tags != nil { // Include tags and trailing space. length = e.Tags.Len() + 1 @@ -248,7 +351,7 @@ func (e *Event) Len() (length int) { // If param contains a space or it's empty, it's trailing, so it should be // prefixed with a colon (:). - if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") { + if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) { length++ } } @@ -259,10 +362,6 @@ func (e *Event) Len() (length int) { // Bytes returns a []byte representation of event. Strips all newlines and // carriage returns. -// -// Per RFC2812 section 2.3, messages should not exceed 512 characters in -// length. This method forces that limit by discarding any characters -// exceeding the length limit. func (e *Event) Bytes() []byte { buffer := new(bytes.Buffer) @@ -284,7 +383,7 @@ func (e *Event) Bytes() []byte { // Space separated list of arguments. if len(e.Params) > 0 { for i := 0; i < len(e.Params); i++ { - if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || strings.HasPrefix(e.Params[i], ":") || e.Params[i] == "") { + if i == len(e.Params)-1 && (strings.Contains(e.Params[i], " ") || e.Params[i] == "" || strings.HasPrefix(e.Params[i], ":")) { buffer.WriteString(string(eventSpace) + string(messagePrefix) + e.Params[i]) continue } @@ -292,11 +391,6 @@ func (e *Event) Bytes() []byte { } } - // We need the limit the buffer length. - if buffer.Len() > (maxLength) { - buffer.Truncate(maxLength) - } - // If we truncated in the middle of a utf8 character, we need to remove // the other (now invalid) bytes. out := bytes.ToValidUTF8(buffer.Bytes(), nil) diff --git a/event_test.go b/event_test.go index 7780a08..73597fc 100644 --- a/event_test.go +++ b/event_test.go @@ -131,11 +131,6 @@ var testsParseEvent = []struct { {in: ":host.domain.com TEST arg1 arg=:10 :test1", want: ":host.domain.com TEST arg1 arg=:10 test1"}, {in: ":nick!user@host TEST :test1", want: ":nick!user@host TEST test1"}, {in: ":nick!user@host TEST :test1 test2", want: ":nick!user@host TEST :test1 test2"}, - // This should succeeded even though "want" has a colon (even though - // there are no spaces in the target). This is because the truncating - // happens after the event is encoded, not during. - {in: ":nick!user@host TEST :test0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000LONG TEXT TRUNCATED HERE", want: ":nick!user@host TEST :test0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, - {in: "@aaa=bbb;ccc;example.com/ddd=eee :nick!user@host TEST :test 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000LONG TEXT TRUNCATED HERE", want: "@aaa=bbb;ccc;example.com/ddd=eee :nick!user@host TEST :test 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"}, {in: "@aaa=bbb :nick!user@host TEST :test1", want: "@aaa=bbb :nick!user@host TEST test1"}, {in: "@aaa=bbb;+ccc;example.com/ddd=eee :nick!user@host TEST :test1", want: "@aaa=bbb;+ccc;example.com/ddd=eee :nick!user@host TEST test1"}, {in: "@bbb=aaa;aaa :nick!user@host TEST :test1 test2", want: "@aaa;bbb=aaa :nick!user@host TEST :test1 test2"}, @@ -151,7 +146,6 @@ func FuzzParseEvent(f *testing.F) { } f.Fuzz(func(t *testing.T, orig string) { - got := ParseEvent(orig) if got == nil { diff --git a/format.go b/format.go index 85e3e38..3b9d60a 100644 --- a/format.go +++ b/format.go @@ -7,13 +7,21 @@ package girc import ( "bytes" "fmt" + "net/url" "regexp" "strings" + "unicode/utf8" ) const ( - fmtOpenChar = '{' - fmtCloseChar = '}' + fmtOpenChar = '{' + fmtCloseChar = '}' + maxWordSplitLength = 30 +) + +var ( + reCode = regexp.MustCompile(`(\x02|\x1d|\x0f|\x03|\x16|\x1f|\x01)`) + reColor = regexp.MustCompile(`\x03([019]?\d(,[019]?\d)?)`) ) var fmtColors = map[string]int{ @@ -66,9 +74,9 @@ var fmtCodes = map[string]string{ // // For example: // -// client.Message("#channel", Fmt("{red}{b}Hello {red,blue}World{c}")) +// client.Message("#channel", Fmt("{red}{b}Hello {red,blue}World{c}")) func Fmt(text string) string { - var last = -1 + last := -1 for i := 0; i < len(text); i++ { if text[i] == fmtOpenChar { last = i @@ -136,16 +144,12 @@ func TrimFmt(text string) string { return text } -// This is really the only fastest way of doing this (marginally better than -// actually trying to parse it manually.) -var reStripColor = regexp.MustCompile(`\x03([019]?\d(,[019]?\d)?)?`) - // StripRaw tries to strip all ASCII format codes that are used for IRC. // Primarily, foreground/background colors, and other control bytes like // reset, bold, italic, reverse, etc. This also is done in a specific way // in order to ensure no truncation of other non-irc formatting. func StripRaw(text string) string { - text = reStripColor.ReplaceAllString(text, "") + text = reColor.ReplaceAllString(text, "") for _, code := range fmtCodes { text = strings.ReplaceAll(text, code, "") @@ -164,12 +168,12 @@ func StripRaw(text string) string { // all ASCII printable chars. This function will NOT do that for // compatibility reasons. // -// channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring -// [ ":" chanstring ] -// chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B -// chanstring = / 0x2D-0x39 / 0x3B-0xFF -// ; any octet except NUL, BELL, CR, LF, " ", "," and ":" -// channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 ) +// channel = ( "#" / "+" / ( "!" channelid ) / "&" ) chanstring +// [ ":" chanstring ] +// chanstring = 0x01-0x07 / 0x08-0x09 / 0x0B-0x0C / 0x0E-0x1F / 0x21-0x2B +// chanstring = / 0x2D-0x39 / 0x3B-0xFF +// ; any octet except NUL, BELL, CR, LF, " ", "," and ":" +// channelid = 5( 0x41-0x5A / digit ) ; 5( A-Z / 0-9 ) func IsValidChannel(channel string) bool { if len(channel) <= 1 || len(channel) > 50 { return false @@ -214,10 +218,10 @@ func IsValidChannel(channel string) bool { // IsValidNick validates an IRC nickname. Note that this does not validate // IRC nickname length. // -// nickname = ( letter / special ) *8( letter / digit / special / "-" ) -// letter = 0x41-0x5A / 0x61-0x7A -// digit = 0x30-0x39 -// special = 0x5B-0x60 / 0x7B-0x7D +// nickname = ( letter / special ) *8( letter / digit / special / "-" ) +// letter = 0x41-0x5A / 0x61-0x7A +// digit = 0x30-0x39 +// special = 0x5B-0x60 / 0x7B-0x7D func IsValidNick(nick string) bool { if nick == "" { return false @@ -253,8 +257,9 @@ func IsValidNick(nick string) bool { // not be supported on all networks. Some limit this to only a single period. // // Per RFC: -// user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF ) -// ; any octet except NUL, CR, LF, " " and "@" +// +// user = 1*( %x01-09 / %x0B-0C / %x0E-1F / %x21-3F / %x41-FF ) +// ; any octet except NUL, CR, LF, " " and "@" func IsValidUser(name string) bool { if name == "" { return false @@ -350,3 +355,172 @@ func Glob(input, match string) bool { // Check suffix last. return trailingGlob || strings.HasSuffix(input, parts[last]) } + +// sliceInsert inserts a string into a slice at a specific index, while trying +// to avoid as many allocations as possible. +func sliceInsert(input []string, i int, v ...string) []string { + total := len(input) + len(v) + if total <= cap(input) { + output := input[:total] + copy(output[i+len(v):], input[i:]) + copy(output[i:], v) + return output + } + output := make([]string, total) + copy(output, input[:i]) + copy(output[i:], v) + copy(output[i+len(v):], input[i:]) + return output +} + +// splitMessage is a text splitter that takes into consideration a few things: +// - Ensuring the returned text is no longer than maxWidth. +// - Attempting to split at the closest word boundary, while still staying inside +// of the specific maxWidth. +// - if there is no good word boundary for longer words (or e.g. links, raw data, etc) +// that are above maxWordSplitLength characters, split the word into chunks to fit the +// +// maximum width. +func splitMessage(input string, maxWidth int) (output []string) { + input = strings.ToValidUTF8(input, "?") + + words := strings.FieldsFunc(strings.TrimSpace(input), func(r rune) bool { + switch r { // Same as unicode.IsSpace, but without ctrl/lf. + case '\t', '\v', '\f', ' ', 0x85, 0xA0: + return true + } + return false + }) + + output = []string{""} + codes := []string{} + + var lastColor string + var match []string + + for i := 0; i < len(words); i++ { + j := strings.IndexAny(words[i], "\n\r") + if j == -1 { + continue + } + + word := words[i] + words[i] = word[:j] + + words = sliceInsert(words, i+1, "", strings.TrimLeft(word[j:], "\n\r")) + } + + for _, word := range words { + // Used in place of a single newline. + if word == "" { + // Last line was already empty or already only had control characters. + if output[len(output)-1] == "" || output[len(output)-1] == lastColor+word { + continue + } + + output = append(output, strings.Join(codes, "")+lastColor+word) + continue + } + + // Keep track of the last used color codes. + match = reColor.FindAllString(word, -1) + if len(match) > 0 { + lastColor = match[len(match)-1] + } + + // Find all sequence codes -- this approach isn't perfect (ideally, a lexer + // should be used to track each exact type of code), but it's good enough for + // most cases. + match = reCode.FindAllString(word, -1) + if len(match) > 0 { + for _, m := range match { + // Reset was used, so clear all codes. + if m == fmtCodes["reset"] { + lastColor = "" + codes = []string{} + continue + } + + // Check if we already have the code, and if so, remove it (closing). + contains := false + for i := 0; i < len(codes); i++ { + if m == codes[i] { + contains = true + codes = append(codes[:i], codes[i+1:]...) + + // If it's a closing color code, reset the last used color + // as well. + if m == fmtCodes["clear"] { + lastColor = "" + } + + break + } + } + + // Track the new code, unless it's a color clear but we aren't + // tracking a color right now. + if !contains && (lastColor == "" || m != fmtCodes["clear"]) { + codes = append(codes, m) + } + } + } + + checkappend: + + // Check if we can append, otherwise we must split. + if 1+utf8.RuneCountInString(word)+utf8.RuneCountInString(output[len(output)-1]) < maxWidth { + if output[len(output)-1] != "" { + output[len(output)-1] += " " + } + output[len(output)-1] += word + continue + } + + // If the word can fit on a line by itself, check if it's a url. If it is, + // put it on it's own line. + if utf8.RuneCountInString(word+strings.Join(codes, "")+lastColor) < maxWidth { + if _, err := url.Parse(word); err == nil { + output = append(output, strings.Join(codes, "")+lastColor+word) + continue + } + } + + // Check to see if we can split by misc symbols, but must be at least a few + // characters long to be split by it. + if j := strings.IndexAny(word, "-+_=|/~:;,."); j > 3 && 1+utf8.RuneCountInString(word[0:j])+utf8.RuneCountInString(output[len(output)-1]) < maxWidth { + if output[len(output)-1] != "" { + output[len(output)-1] += " " + } + output[len(output)-1] += word[0:j] + word = word[j+1:] + goto checkappend + } + + // If the word is longer than is acceptable to just put on the next line, + // split it into chunks. Also don't split the word if only a few characters + // left of the word would be on the next line. + if 1+utf8.RuneCountInString(word) > maxWordSplitLength && maxWidth-utf8.RuneCountInString(output[len(output)-1]) > 5 { + left := maxWidth - utf8.RuneCountInString(output[len(output)-1]) - 1 // -1 for the space + + if output[len(output)-1] != "" { + output[len(output)-1] += " " + } + output[len(output)-1] += word[0:left] + word = word[left:] + goto checkappend + } + + left := maxWidth - utf8.RuneCountInString(output[len(output)-1]) + output[len(output)-1] += word[0:left] + + output = append(output, strings.Join(codes, "")+lastColor) + word = word[left:] + goto checkappend + } + + for i := 0; i < len(output); i++ { + output[i] = strings.ToValidUTF8(output[i], "?") + } + return output +} diff --git a/format_test.go b/format_test.go index 196c11a..2041c90 100644 --- a/format_test.go +++ b/format_test.go @@ -66,6 +66,30 @@ var testsFormat = []struct { {name: "just cyan", test: "{cyan}test", want: "\x0311test"}, } +func FuzzSplit(f *testing.F) { + for _, tc := range testsFormat { + f.Add(tc.want) + } + + maxSize := 128 + + f.Fuzz(func(t *testing.T, orig string) { + got := splitMessage(orig, maxSize) + + if utf8.ValidString(orig) { + if !utf8.ValidString(strings.Join(got, "")) { + t.Errorf("produced invalid UTF-8 string %q", got) + } + } + + for _, s := range got { + if utf8.RuneCountInString(s) > maxSize { + t.Errorf("splitMessage(%q, %d) = got %q, %d runes, want <= %d for %q", orig, maxSize, got, utf8.RuneCountInString(s), maxSize, s) + } + } + }) +} + func FuzzFormat(f *testing.F) { for _, tc := range testsFormat { f.Add(tc.test) diff --git a/internal/ctxgroup/ctxgroup.go b/internal/ctxgroup/ctxgroup.go new file mode 100644 index 0000000..e66ed2b --- /dev/null +++ b/internal/ctxgroup/ctxgroup.go @@ -0,0 +1,67 @@ +// Copyright (c) Liam Stanley . All rights reserved. Use +// of this source code is governed by the MIT license that can be found in +// the LICENSE file. + +package ctxgroup + +import ( + "context" + "sync" +) + +// A Group is a collection of goroutines working on subtasks that are part of +// the same overall task. +type Group struct { + ctx context.Context + cancel func() + + wg sync.WaitGroup + + errOnce sync.Once + err error +} + +// New returns a new Group and an associated context derived from ctx. +// Obtain the derived context from calling Group.Context(). +// +// The derived context is canceled the first time a function passed to Go +// returns a non-nil error or the first time Wait returns, whichever occurs +// first. +func New(ctx context.Context) *Group { + nctx, cancel := context.WithCancel(ctx) + return &Group{ctx: nctx, cancel: cancel} +} + +// Context returns the context for this group. It may be canceled by the first +// function to return a non-nil error. +func (g *Group) Context() context.Context { + return g.ctx +} + +// Wait blocks until all function calls from the Go method have returned, then +// returns the first non-nil error (if any) from them. +func (g *Group) Wait() error { + g.wg.Wait() + if g.cancel != nil { + g.cancel() + } + return g.err +} + +// Go calls the given function in a new goroutine. The first call to return a +// non-nil error cancels the group; its error will be returned by Wait. +func (g *Group) Go(f func(ctx context.Context) error) { + g.wg.Add(1) + go func() { + defer g.wg.Done() + + if err := f(g.ctx); err != nil { + g.errOnce.Do(func() { + g.err = err + if g.cancel != nil { + g.cancel() + } + }) + } + }() +} diff --git a/modes.go b/modes.go index 35ff103..127b0a7 100644 --- a/modes.go +++ b/modes.go @@ -118,13 +118,14 @@ func (c *CModes) Get(mode string) (args string, ok bool) { } // hasArg checks to see if the mode supports arguments. What ones support this?: -// A = Mode that adds or removes a nick or address to a list. Always has a parameter. -// B = Mode that changes a setting and always has a parameter. -// C = Mode that changes a setting and only has a parameter when set. -// D = Mode that changes a setting and never has a parameter. -// Note: Modes of type A return the list when there is no parameter present. -// Note: Some clients assumes that any mode not listed is of type D. -// Note: Modes in PREFIX are not listed but could be considered type B. +// +// A = Mode that adds or removes a nick or address to a list. Always has a parameter. +// B = Mode that changes a setting and always has a parameter. +// C = Mode that changes a setting and only has a parameter when set. +// D = Mode that changes a setting and never has a parameter. +// Note: Modes of type A return the list when there is no parameter present. +// Note: Some clients assumes that any mode not listed is of type D. +// Note: Modes in PREFIX are not listed but could be considered type B. func (c *CModes) hasArg(set bool, mode byte) (hasArgs, isSetting bool) { if len(c.raw) < 1 { return false, true diff --git a/state.go b/state.go index d9e7298..96d2ef8 100644 --- a/state.go +++ b/state.go @@ -28,10 +28,21 @@ type state struct { // last capability check. These will get sent once we have received the // last capability list command from the server. tmpCap map[string]map[string]string + // serverOptions are the standard capabilities and configurations // supported by the server at connection time. This also includes // RPL_ISUPPORT entries. serverOptions map[string]string + + // maxLineLength defines how long before we truncate (or split) messages. + // DefaultMaxLineLength is what is used by default, as this is going to be a common + // standard. However, protocols like IRCv3, or ISUPPORT can override this. + maxLineLength int + + // maxPrefixLength defines the estimated prefix length (":nick!user@host ") that + // we can use to calculate line splits. + maxPrefixLength int + // motd is the servers message of the day. motd string @@ -51,9 +62,11 @@ func (s *state) reset(initial bool) { s.host = "" s.channels = make(map[string]*Channel) s.users = make(map[string]*User) - s.serverOptions = make(map[string]string) s.enabledCap = make(map[string]map[string]string) s.tmpCap = make(map[string]map[string]string) + s.serverOptions = make(map[string]string) + s.maxLineLength = DefaultMaxLineLength + s.maxPrefixLength = DefaultMaxPrefixLength s.motd = "" if initial { diff --git a/testdata/fuzz/FuzzSplit/01687ab5c328878e7c422b93992236c39c7db227dc95932739fabf9f34c0a577 b/testdata/fuzz/FuzzSplit/01687ab5c328878e7c422b93992236c39c7db227dc95932739fabf9f34c0a577 new file mode 100644 index 0000000..5c0fd24 --- /dev/null +++ b/testdata/fuzz/FuzzSplit/01687ab5c328878e7c422b93992236c39c7db227dc95932739fabf9f34c0a577 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0000000000000000000000000000000000000000000000000000 +++0000000000%AA000000000000%AA0000000000000000000000000#000000000000000000%") diff --git a/testdata/fuzz/FuzzSplit/0302061680309c38682da36c599dfadb3a696064e0b0c06f5950492f743e4b86 b/testdata/fuzz/FuzzSplit/0302061680309c38682da36c599dfadb3a696064e0b0c06f5950492f743e4b86 new file mode 100644 index 0000000..85e0e7c --- /dev/null +++ b/testdata/fuzz/FuzzSplit/0302061680309c38682da36c599dfadb3a696064e0b0c06f5950492f743e4b86 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("00000000000000000000000 +++++++++++00++++++++++++++++++++++++++++++++++++++++++++++++++++0000000000000000000000000000000000%0X000") diff --git a/testdata/fuzz/FuzzSplit/418f7abc672f01b6f11f6265f6a8e3376992cf56c7c8d3e88f448384f414c9fb b/testdata/fuzz/FuzzSplit/418f7abc672f01b6f11f6265f6a8e3376992cf56c7c8d3e88f448384f414c9fb new file mode 100644 index 0000000..4df48e8 --- /dev/null +++ b/testdata/fuzz/FuzzSplit/418f7abc672f01b6f11f6265f6a8e3376992cf56c7c8d3e88f448384f414c9fb @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\x02 \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n0\n0\n \n0\n \n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n \n0\n0\n0\n0\n \n0") diff --git a/testdata/fuzz/FuzzSplit/66f3fa3227d7428c80cc1701e1a6e2e89070acf8e767fd5cf945ffde5a4e3bd4 b/testdata/fuzz/FuzzSplit/66f3fa3227d7428c80cc1701e1a6e2e89070acf8e767fd5cf945ffde5a4e3bd4 new file mode 100644 index 0000000..1c2ae8b --- /dev/null +++ b/testdata/fuzz/FuzzSplit/66f3fa3227d7428c80cc1701e1a6e2e89070acf8e767fd5cf945ffde5a4e3bd4 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\x03̃ \u058b߶ ܤ \u05ee0ߎ000000000 000000000\x0f0ʔ00000000000000000000߹0000\x030000000000000 00000000000000000 0000000000 00000 000000000000ݱ0000000000000000000000000000000000000000Ũ0000000000000000%0X000000ؔ00000000000 0000000000000000ۖ00000000000000000̆ߧ\u0090σ00000000000%0X00000000֍000000000NJ00000000000000000000 000000000؛0000000000000000000000000000000000000000ގ0000000000000000000 000000000\u03800000000000000000000000Ŋ000000000%0X0000000000000000000´000000ʍ0000000000Ҽ0000000ҝ0 0000000ˎ0ӓ000000͞Ƃ0Պ00000") diff --git a/testdata/fuzz/FuzzSplit/ca23233b2658f2fa5f63dfd54764c2dfd969a1001dc7dd57598a5f4b293fa3f6 b/testdata/fuzz/FuzzSplit/ca23233b2658f2fa5f63dfd54764c2dfd969a1001dc7dd57598a5f4b293fa3f6 new file mode 100644 index 0000000..a275ab6 --- /dev/null +++ b/testdata/fuzz/FuzzSplit/ca23233b2658f2fa5f63dfd54764c2dfd969a1001dc7dd57598a5f4b293fa3f6 @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0\n \n \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n0\n0\n \n0\n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n \n0\n0\n0\n \n0\n0\n \n0\n0\n0\n0\n \n0") diff --git a/testdata/fuzz/FuzzSplit/dde7a120ccdf11af18793ee92a6036b1982e34a0e60fb8064aed3ebf3fc6ab0b b/testdata/fuzz/FuzzSplit/dde7a120ccdf11af18793ee92a6036b1982e34a0e60fb8064aed3ebf3fc6ab0b new file mode 100644 index 0000000..2e299a0 --- /dev/null +++ b/testdata/fuzz/FuzzSplit/dde7a120ccdf11af18793ee92a6036b1982e34a0e60fb8064aed3ebf3fc6ab0b @@ -0,0 +1,2 @@ +go test fuzz v1 +string("0\r0") diff --git a/testdata/fuzz/FuzzSplit/ed0ca8ee1ecc6872d4b0eabfd55da7512e2757047733e94da6392e0e194d59af b/testdata/fuzz/FuzzSplit/ed0ca8ee1ecc6872d4b0eabfd55da7512e2757047733e94da6392e0e194d59af new file mode 100644 index 0000000..d72e75f --- /dev/null +++ b/testdata/fuzz/FuzzSplit/ed0ca8ee1ecc6872d4b0eabfd55da7512e2757047733e94da6392e0e194d59af @@ -0,0 +1,2 @@ +go test fuzz v1 +string("\x1d\x0300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000%0X00000000")