diff --git a/.travis.yml b/.travis.yml index 9bdd9a6..0b5d58e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,8 +2,10 @@ language: go go: - - 1.14 - - 1.13.8 + - 1.18 + - 1.17.8 + - 1.16.15 + - 1.15.15 sudo : false diff --git a/README.md b/README.md index 75081eb..5c1108c 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,11 @@ and `DisableStateTracking()` respectively. Doing this while connected to an IRC server will probably result in an inconsistent state and a lot of warnings to STDERR ;-) +### Projects using GoIRC + +- [xdcc-cli](https://github.com/ostafen/xdcc-cli): A command line tool for searching and downloading files from the IRC network. + + ### Misc. Sorry the documentation is crap. Use the source, Luke. @@ -92,7 +97,7 @@ indebted to Matt Gruen for his work on the re-organisation and channel-based communication structure of `*Conn.send()` and `*Conn.recv()`. I'm sure things could be more asynchronous, still. -This code is (c) 2009-15 Alex Bramley, and released under the same licence terms +This code is (c) 2009-23 Alex Bramley, and released under the same licence terms as Go itself. Contributions gratefully received from: @@ -109,6 +114,9 @@ Contributions gratefully received from: - [soul9](https://github.com/soul9) - [jakebailey](https://github.com/jakebailey) - [stapelberg](https://github.com/stapelberg) + - [shammash](https://github.com/shammash) + - [ostafen](https://github.com/ostafen) + - [supertassu](https://github.com/supertassu) And thanks to the following for minor doc/fix PRs: diff --git a/client/commands.go b/client/commands.go index 101c7d3..f5d4c58 100644 --- a/client/commands.go +++ b/client/commands.go @@ -10,6 +10,7 @@ const ( CONNECTED = "CONNECTED" DISCONNECTED = "DISCONNECTED" ACTION = "ACTION" + AUTHENTICATE = "AUTHENTICATE" AWAY = "AWAY" CAP = "CAP" CTCP = "CTCP" @@ -84,6 +85,23 @@ func splitMessage(msg string, splitLen int) (msgs []string) { return append(msgs, msg) } +func splitArgs(args []string, maxLen int) []string { + res := make([]string, 0) + + i := 0 + for i < len(args) { + currArg := args[i] + i++ + + for i < len(args) && len(currArg)+len(args[i])+1 < maxLen { + currArg += " " + args[i] + i++ + } + res = append(res, currArg) + } + return res +} + // Raw sends a raw line to the server, should really only be used for // debugging purposes but may well come in handy. func (conn *Conn) Raw(rawline string) { @@ -299,6 +317,14 @@ func (conn *Conn) Cap(subcommmand string, capabilities ...string) { if len(capabilities) == 0 { conn.Raw(CAP + " " + subcommmand) } else { - conn.Raw(CAP + " " + subcommmand + " :" + strings.Join(capabilities, " ")) + cmdPrefix := CAP + " " + subcommmand + " :" + for _, args := range splitArgs(capabilities, defaultSplit-len(cmdPrefix)) { + conn.Raw(cmdPrefix + args) + } } } + +// Authenticate send an AUTHENTICATE command to the server. +func (conn *Conn) Authenticate(message string) { + conn.Raw(AUTHENTICATE + " " + message) +} diff --git a/client/commands_test.go b/client/commands_test.go index 15a8a05..25af371 100644 --- a/client/commands_test.go +++ b/client/commands_test.go @@ -2,6 +2,8 @@ package client import ( "reflect" + "strconv" + "strings" "testing" ) @@ -203,3 +205,20 @@ func TestClientCommands(t *testing.T) { c.VHost("user", "pass") s.nc.Expect("VHOST user pass") } + +func TestSplitCommand(t *testing.T) { + nArgs := 100 + + args := make([]string, 0) + for i := 0; i < nArgs; i++ { + args = append(args, "arg"+strconv.Itoa(i)) + } + + for maxLen := 1; maxLen <= defaultSplit; maxLen *= 2 { + for _, argStr := range splitArgs(args, maxLen) { + if len(argStr) > maxLen && len(strings.Split(argStr, " ")) > 1 { + t.Errorf("maxLen = %d, but len(cmd) = %d", maxLen, len(argStr)) + } + } + } +} diff --git a/client/connection.go b/client/connection.go index 20a7fa3..8087a38 100644 --- a/client/connection.go +++ b/client/connection.go @@ -2,6 +2,7 @@ package client import ( "bufio" + "context" "crypto/tls" "fmt" "io" @@ -11,6 +12,7 @@ import ( "sync" "time" + sasl "github.com/emersion/go-sasl" "github.com/fluffle/goirc/logging" "github.com/fluffle/goirc/state" "golang.org/x/net/proxy" @@ -43,8 +45,17 @@ type Conn struct { out chan string connected bool - // Control channel and WaitGroup for goroutines - die chan struct{} + // Capabilities supported by the server + supportedCaps *capSet + + // Capabilites currently enabled + currCaps *capSet + + // SASL internals + saslRemainingData []byte + + // CancelFunc and WaitGroup for goroutines + die context.CancelFunc wg sync.WaitGroup // Internal counters for flood protection @@ -87,8 +98,18 @@ type Config struct { // Passed through to https://golang.org/pkg/net/#Dialer DualStack bool + // Enable IRCv3 capability negotiation. + EnableCapabilityNegotiation bool + + // A list of capabilities to request to the server during registration. + Capabilites []string + + // SASL configuration to use to authenticate the connection. + Sasl sasl.Client + // Replaceable function to customise the 433 handler's new nick. - // By default an underscore "_" is appended to the current nick. + // By default the current nick's last character is "incremented". + // See DefaultNewNick implementation below for details. NewNick func(string) string // Client->server ping frequency, in seconds. Defaults to 3m. @@ -123,12 +144,13 @@ type Config struct { // name, but these are optional. func NewConfig(nick string, args ...string) *Config { cfg := &Config{ - Me: &state.Nick{Nick: nick}, - PingFreq: 3 * time.Minute, - NewNick: DefaultNewNick, - Recover: (*Conn).LogPanic, // in dispatch.go - SplitLen: defaultSplit, - Timeout: 60 * time.Second, + Me: &state.Nick{Nick: nick}, + PingFreq: 3 * time.Minute, + NewNick: DefaultNewNick, + Recover: (*Conn).LogPanic, // in dispatch.go + SplitLen: defaultSplit, + Timeout: 60 * time.Second, + EnableCapabilityNegotiation: false, } cfg.Me.Ident = "goirc" if len(args) > 0 && args[0] != "" { @@ -201,14 +223,22 @@ func Client(cfg *Config) *Conn { } } + if cfg.Sasl != nil && !cfg.EnableCapabilityNegotiation { + logging.Warn("Enabling capability negotiation as it's required for SASL") + cfg.EnableCapabilityNegotiation = true + } + conn := &Conn{ - cfg: cfg, - dialer: dialer, - intHandlers: handlerSet(), - fgHandlers: handlerSet(), - bgHandlers: handlerSet(), - stRemovers: make([]Remover, 0, len(stHandlers)), - lastsent: time.Now(), + cfg: cfg, + dialer: dialer, + intHandlers: handlerSet(), + fgHandlers: handlerSet(), + bgHandlers: handlerSet(), + stRemovers: make([]Remover, 0, len(stHandlers)), + lastsent: time.Now(), + supportedCaps: capabilitySet(), + currCaps: capabilitySet(), + saslRemainingData: nil, } conn.addIntHandlers() return conn @@ -228,10 +258,9 @@ func (conn *Conn) Connected() bool { // affect client behaviour. To disable flood protection temporarily, // for example, a handler could do: // -// conn.Config().Flood = true -// // Send many lines to the IRC server, risking "excess flood" -// conn.Config().Flood = false -// +// conn.Config().Flood = true +// // Send many lines to the IRC server, risking "excess flood" +// conn.Config().Flood = false func (conn *Conn) Config() *Config { return conn.cfg } @@ -287,13 +316,23 @@ func (conn *Conn) DisableStateTracking() { } } +// SupportsCapability returns true if the server supports the given capability. +func (conn *Conn) SupportsCapability(cap string) bool { + return conn.supportedCaps.Has(cap) +} + +// HasCapability returns true if the given capability has been acked by the server during negotiation. +func (conn *Conn) HasCapability(cap string) bool { + return conn.currCaps.Has(cap) +} + // Per-connection state initialisation. func (conn *Conn) initialise() { conn.io = nil conn.sock = nil conn.in = make(chan *Line, 32) conn.out = make(chan string, 32) - conn.die = make(chan struct{}) + conn.die = nil if conn.st != nil { conn.st.Wipe() } @@ -304,11 +343,16 @@ func (conn *Conn) initialise() { // Config.Server to host, Config.Pass to pass if one is provided, and then // calls Connect. func (conn *Conn) ConnectTo(host string, pass ...string) error { + return conn.ConnectToContext(context.Background(), host, pass...) +} + +// ConnectToContext works like ConnectTo but uses the provided context. +func (conn *Conn) ConnectToContext(ctx context.Context, host string, pass ...string) error { conn.cfg.Server = host if len(pass) > 0 { conn.cfg.Pass = pass[0] } - return conn.Connect() + return conn.ConnectContext(ctx) } // Connect connects the IRC client to the server configured in Config.Server. @@ -323,10 +367,15 @@ func (conn *Conn) ConnectTo(host string, pass ...string) error { // handler for the CONNECTED event is used to perform any initial client work // like joining channels and sending messages. func (conn *Conn) Connect() error { + return conn.ConnectContext(context.Background()) +} + +// ConnectContext works like Connect but uses the provided context. +func (conn *Conn) ConnectContext(ctx context.Context) error { // We don't want to hold conn.mu while firing the REGISTER event, // and it's much easier and less error prone to defer the unlock, // so the connect mechanics have been delegated to internalConnect. - err := conn.internalConnect() + err := conn.internalConnect(ctx) if err == nil { conn.dispatch(&Line{Cmd: REGISTER, Time: time.Now()}) } @@ -334,7 +383,7 @@ func (conn *Conn) Connect() error { } // internalConnect handles the work of actually connecting to the server. -func (conn *Conn) internalConnect() error { +func (conn *Conn) internalConnect(ctx context.Context) error { conn.mu.Lock() defer conn.mu.Unlock() conn.initialise() @@ -355,24 +404,16 @@ func (conn *Conn) internalConnect() error { } if conn.cfg.Proxy != "" { - proxyURL, err := url.Parse(conn.cfg.Proxy) + s, err := conn.dialProxy(ctx) if err != nil { + logging.Info("irc.Connect(): Connecting via proxy %q: %v", + conn.cfg.Proxy, err) return err } - conn.proxyDialer, err = proxy.FromURL(proxyURL, conn.dialer) - if err != nil { - return err - } - - logging.Info("irc.Connect(): Connecting to %s.", conn.cfg.Server) - if s, err := conn.proxyDialer.Dial("tcp", conn.cfg.Server); err == nil { - conn.sock = s - } else { - return err - } + conn.sock = s } else { logging.Info("irc.Connect(): Connecting to %s.", conn.cfg.Server) - if s, err := conn.dialer.Dial("tcp", conn.cfg.Server); err == nil { + if s, err := conn.dialer.DialContext(ctx, "tcp", conn.cfg.Server); err == nil { conn.sock = s } else { return err @@ -388,24 +429,47 @@ func (conn *Conn) internalConnect() error { conn.sock = s } - conn.postConnect(true) + conn.postConnect(ctx, true) conn.connected = true return nil } +// dialProxy handles dialling via a proxy +func (conn *Conn) dialProxy(ctx context.Context) (net.Conn, error) { + proxyURL, err := url.Parse(conn.cfg.Proxy) + if err != nil { + return nil, fmt.Errorf("parsing url: %v", err) + } + proxyDialer, err := proxy.FromURL(proxyURL, conn.dialer) + if err != nil { + return nil, fmt.Errorf("creating dialer: %v", err) + } + conn.proxyDialer = proxyDialer + contextProxyDialer, ok := proxyDialer.(proxy.ContextDialer) + if ok { + logging.Info("irc.Connect(): Connecting to %s.", conn.cfg.Server) + return contextProxyDialer.DialContext(ctx, "tcp", conn.cfg.Server) + } else { + logging.Warn("Dialer for proxy does not support context, please implement DialContext") + logging.Info("irc.Connect(): Connecting to %s.", conn.cfg.Server) + return conn.proxyDialer.Dial("tcp", conn.cfg.Server) + } +} + // postConnect performs post-connection setup, for ease of testing. -func (conn *Conn) postConnect(start bool) { +func (conn *Conn) postConnect(ctx context.Context, start bool) { conn.io = bufio.NewReadWriter( bufio.NewReader(conn.sock), bufio.NewWriter(conn.sock)) if start { + ctx, conn.die = context.WithCancel(ctx) conn.wg.Add(3) - go conn.send() + go conn.send(ctx) go conn.recv() - go conn.runLoop() + go conn.runLoop(ctx) if conn.cfg.PingFreq > 0 { conn.wg.Add(1) - go conn.ping() + go conn.ping(ctx) } } } @@ -418,8 +482,8 @@ func hasPort(s string) bool { // send is started as a goroutine after a connection is established. // It shuttles data from the output channel to write(), and is killed -// when Conn.die is closed. -func (conn *Conn) send() { +// when the context is cancelled. +func (conn *Conn) send(ctx context.Context) { for { select { case line := <-conn.out: @@ -430,7 +494,7 @@ func (conn *Conn) send() { conn.Close() return } - case <-conn.die: + case <-ctx.Done(): // control channel closed, bail out conn.wg.Done() return @@ -467,14 +531,14 @@ func (conn *Conn) recv() { // ping is started as a goroutine after a connection is established, as // long as Config.PingFreq >0. It pings the server every PingFreq seconds. -func (conn *Conn) ping() { +func (conn *Conn) ping(ctx context.Context) { defer conn.wg.Done() tick := time.NewTicker(conn.cfg.PingFreq) for { select { case <-tick.C: conn.Ping(fmt.Sprintf("%d", time.Now().UnixNano())) - case <-conn.die: + case <-ctx.Done(): // control channel closed, bail out tick.Stop() return @@ -485,14 +549,18 @@ func (conn *Conn) ping() { // runLoop is started as a goroutine after a connection is established. // It pulls Lines from the input channel and dispatches them to any // handlers that have been registered for that IRC verb. -func (conn *Conn) runLoop() { - defer conn.wg.Done() +func (conn *Conn) runLoop(ctx context.Context) { for { select { case line := <-conn.in: conn.dispatch(line) - case <-conn.die: - // control channel closed, bail out + case <-ctx.Done(): + // control channel closed, trigger Cancel() to clean + // things up properly and bail out + + // We can't defer this, because Close() waits for it. + conn.wg.Done() + conn.Close() return } } @@ -556,7 +624,9 @@ func (conn *Conn) Close() error { logging.Info("irc.Close(): Disconnected from server.") conn.connected = false err := conn.sock.Close() - close(conn.die) + if conn.die != nil { + conn.die() + } // Drain both in and out channels to avoid a deadlock if the buffers // have filled. See TestSendDeadlockOnFullBuffer in connection_test.go. conn.drainIn() diff --git a/client/connection_test.go b/client/connection_test.go index 3b7e5b7..50d32bc 100644 --- a/client/connection_test.go +++ b/client/connection_test.go @@ -1,6 +1,7 @@ package client import ( + "context" "runtime" "strings" "testing" @@ -23,6 +24,10 @@ func (c checker) call() { c.c <- struct{}{} } +func (c checker) Handle(_ *Conn, _ *Line) { + c.call() +} + func (c checker) assertNotCalled(fmt string, args ...interface{}) { select { case <-c.c: @@ -54,6 +59,7 @@ func setUp(t *testing.T, start ...bool) (*Conn, *testState) { nc := MockNetConn(t) c := SimpleClient("test", "test", "Testing IRC") c.initialise() + ctx := context.Background() c.st = st c.sock = nc @@ -61,7 +67,7 @@ func setUp(t *testing.T, start ...bool) (*Conn, *testState) { c.connected = true // If a second argument is passed to setUp, we tell postConnect not to // start the various goroutines that shuttle data around. - c.postConnect(len(start) == 0) + c.postConnect(ctx, len(start) == 0) // Sleep 1ms to allow background routines to start. <-time.After(time.Millisecond) @@ -83,9 +89,7 @@ func TestEOF(t *testing.T) { // Set up a handler to detect whether disconnected handlers are called dcon := callCheck(t) - c.HandleFunc(DISCONNECTED, func(conn *Conn, line *Line) { - dcon.call() - }) + c.Handle(DISCONNECTED, dcon) // Simulate EOF from server s.nc.Close() @@ -99,6 +103,28 @@ func TestEOF(t *testing.T) { } } +func TestCleanupOnContextDone(t *testing.T) { + c, s := setUp(t) + // Since we're not using tearDown() here, manually call Finish() + defer s.ctrl.Finish() + + // Close() triggers DISCONNECT handler after cleaning up the state + // use this as a proxy to check that Close() was indeed called + dcon := callCheck(t) + c.Handle(DISCONNECTED, dcon) + + // Simulate context cancelation using our cancel func + c.die() + + // Verify that disconnected handler was called + dcon.assertWasCalled("Conn did not call disconnected handlers.") + + // Verify that the connection no longer thinks it's connected + if c.Connected() { + t.Errorf("Conn still thinks it's connected to the server.") + } +} + func TestClientAndStateTracking(t *testing.T) { ctrl := gomock.NewController(t) st := state.NewMockTracker(ctrl) @@ -166,7 +192,7 @@ func TestClientAndStateTracking(t *testing.T) { ctrl.Finish() } -func TestSendExitsOnDie(t *testing.T) { +func TestSendExitsOnCancel(t *testing.T) { // Passing a second value to setUp stops goroutines from starting c, s := setUp(t, false) defer s.tearDown() @@ -178,10 +204,11 @@ func TestSendExitsOnDie(t *testing.T) { // We want to test that the a goroutine calling send will exit correctly. exited := callCheck(t) + ctx, cancel := context.WithCancel(context.Background()) // send() will decrement the WaitGroup, so we must increment it. c.wg.Add(1) go func() { - c.send() + c.send(ctx) exited.call() }() @@ -194,13 +221,9 @@ func TestSendExitsOnDie(t *testing.T) { c.out <- "SENT AFTER START" s.nc.Expect("SENT AFTER START") - // Now, use the control channel to exit send and kill the goroutine. - // This sneakily uses the fact that the other two goroutines that would - // normally be waiting for die to close are not running, so we only send - // to the goroutine started above. Normally Close() closes c.die and - // signals to all three goroutines (send, ping, runLoop) to exit. + // Now, cancel the context to exit send and kill the goroutine. exited.assertNotCalled("Exited before signal sent.") - c.die <- struct{}{} + cancel() exited.assertWasCalled("Didn't exit after signal.") s.nc.ExpectNothing() @@ -221,7 +244,7 @@ func TestSendExitsOnWriteError(t *testing.T) { // send() will decrement the WaitGroup, so we must increment it. c.wg.Add(1) go func() { - c.send() + c.send(context.Background()) exited.call() }() @@ -251,6 +274,7 @@ func TestSendDeadlockOnFullBuffer(t *testing.T) { // We want to test that the a goroutine calling send will exit correctly. loopExit := callCheck(t) sendExit := callCheck(t) + ctx, cancel := context.WithCancel(context.Background()) // send() and runLoop() will decrement the WaitGroup, so we must increment it. c.wg.Add(2) @@ -274,13 +298,13 @@ func TestSendDeadlockOnFullBuffer(t *testing.T) { }) // And trigger it by starting runLoop and inserting a line into conn.in: go func() { - c.runLoop() + c.runLoop(ctx) loopExit.call() }() c.in <- &Line{Cmd: PRIVMSG, Raw: "WRITE THAT CAUSES DEADLOCK"} // At this point the handler should be blocked on a write to conn.out, - // preventing runLoop from looping and thus noticing conn.die is closed. + // preventing runLoop from looping and thus noticng the cancelled context. // // The next part is to force send() to call conn.Close(), which can // be done by closing the fake net.Conn so that it returns an error on @@ -292,8 +316,9 @@ func TestSendDeadlockOnFullBuffer(t *testing.T) { // to write it to the socket. It should immediately receive an error and // call conn.Close(), triggering the deadlock as it waits forever for // runLoop to call conn.wg.Done. + c.die = cancel // Close needs to cancel the context for us. go func() { - c.send() + c.send(ctx) sendExit.call() }() @@ -407,10 +432,11 @@ func TestPing(t *testing.T) { // Start ping loop. exited := callCheck(t) + ctx, cancel := context.WithCancel(context.Background()) // ping() will decrement the WaitGroup, so we must increment it. c.wg.Add(1) go func() { - c.ping() + c.ping(ctx) exited.call() }() @@ -438,13 +464,9 @@ func TestPing(t *testing.T) { t.Errorf("Line not output after another %s.", 2*res) } - // Now kill the ping loop. - // This sneakily uses the fact that the other two goroutines that would - // normally be waiting for die to close are not running, so we only send - // to the goroutine started above. Normally Close() closes c.die and - // signals to all three goroutines (send, ping, runLoop) to exit. + // Now kill the ping loop by cancelling the context. exited.assertNotCalled("Exited before signal sent.") - c.die <- struct{}{} + cancel() exited.assertWasCalled("Didn't exit after signal.") // Make sure we're no longer pinging by waiting >2x PingFreq <-time.After(2*c.cfg.PingFreq + res) @@ -458,50 +480,45 @@ func TestRunLoop(t *testing.T) { c, s := setUp(t, false) defer s.tearDown() - // Set up a handler to detect whether 001 handler is called - h001 := callCheck(t) - c.HandleFunc("001", func(conn *Conn, line *Line) { - h001.call() - }) + // Set up a handler to detect whether 002 handler is called. + // Don't use 001 here, since there's already a handler for that + // and it hangs this test unless we mock the state tracker calls. h002 := callCheck(t) + c.Handle("002", h002) + h003 := callCheck(t) // Set up a handler to detect whether 002 handler is called - c.HandleFunc("002", func(conn *Conn, line *Line) { - h002.call() - }) + c.Handle("003", h003) - l1 := ParseLine(":irc.server.org 001 test :First test line.") - c.in <- l1 - h001.assertNotCalled("001 handler called before runLoop started.") + l2 := ParseLine(":irc.server.org 002 test :First test line.") + c.in <- l2 + h002.assertNotCalled("002 handler called before runLoop started.") // We want to test that the a goroutine calling runLoop will exit correctly. // Now, we can expect the call to Dispatch to take place as runLoop starts. exited := callCheck(t) + ctx, cancel := context.WithCancel(context.Background()) // runLoop() will decrement the WaitGroup, so we must increment it. c.wg.Add(1) go func() { - c.runLoop() + c.runLoop(ctx) exited.call() }() - h001.assertWasCalled("001 handler not called after runLoop started.") + h002.assertWasCalled("002 handler not called after runLoop started.") // Send another line, just to be sure :-) - h002.assertNotCalled("002 handler called before expected.") - l2 := ParseLine(":irc.server.org 002 test :Second test line.") - c.in <- l2 - h002.assertWasCalled("002 handler not called while runLoop started.") + h003.assertNotCalled("003 handler called before expected.") + l3 := ParseLine(":irc.server.org 003 test :Second test line.") + c.in <- l3 + h003.assertWasCalled("003 handler not called while runLoop started.") - // Now, use the control channel to exit send and kill the goroutine. - // This sneakily uses the fact that the other two goroutines that would - // normally be waiting for die to close are not running, so we only send - // to the goroutine started above. Normally Close() closes c.die and - // signals to all three goroutines (send, ping, runLoop) to exit. + // Now, cancel the context to exit runLoop and kill the goroutine. exited.assertNotCalled("Exited before signal sent.") - c.die <- struct{}{} + cancel() exited.assertWasCalled("Didn't exit after signal.") // Sending more on c.in shouldn't dispatch any further events - c.in <- l1 - h001.assertNotCalled("001 handler called after runLoop ended.") + c.in <- l2 + h002.assertNotCalled("002 handler called after runLoop ended.") } func TestWrite(t *testing.T) { diff --git a/client/handlers.go b/client/handlers.go index b538579..3920c02 100644 --- a/client/handlers.go +++ b/client/handlers.go @@ -4,20 +4,37 @@ package client // to manage tracking an irc connection etc. import ( + "sort" "strings" + "sync" "time" + + "encoding/base64" + "github.com/fluffle/goirc/logging" ) +// saslCap is the IRCv3 capability used for SASL authentication. +const saslCap = "sasl" + // sets up the internal event handlers to do essential IRC protocol things var intHandlers = map[string]HandlerFunc{ - REGISTER: (*Conn).h_REGISTER, - "001": (*Conn).h_001, - "433": (*Conn).h_433, - CTCP: (*Conn).h_CTCP, - NICK: (*Conn).h_NICK, - PING: (*Conn).h_PING, + REGISTER: (*Conn).h_REGISTER, + "001": (*Conn).h_001, + "433": (*Conn).h_433, + CTCP: (*Conn).h_CTCP, + NICK: (*Conn).h_NICK, + PING: (*Conn).h_PING, + CAP: (*Conn).h_CAP, + "410": (*Conn).h_410, + AUTHENTICATE: (*Conn).h_AUTHENTICATE, + "903": (*Conn).h_903, + "904": (*Conn).h_904, + "908": (*Conn).h_908, } +// set up the ircv3 capabilities supported by this client which will be requested by default to the server. +var defaultCaps = []string{} + func (conn *Conn) addIntHandlers() { for n, h := range intHandlers { // internal handlers are essential for the IRC client @@ -33,6 +50,10 @@ func (conn *Conn) h_PING(line *Line) { // Handler for initial registration with server once tcp connection is made. func (conn *Conn) h_REGISTER(line *Line) { + if conn.cfg.EnableCapabilityNegotiation { + conn.Cap(CAP_LS) + } + if conn.cfg.Pass != "" { conn.Pass(conn.cfg.Pass) } @@ -40,21 +61,241 @@ func (conn *Conn) h_REGISTER(line *Line) { conn.User(conn.cfg.Me.Ident, conn.cfg.Me.Name) } +func (conn *Conn) getRequestCapabilities() *capSet { + s := capabilitySet() + + // add capabilites supported by the client + s.Add(defaultCaps...) + + if conn.cfg.Sasl != nil { + // add the SASL cap if enabled + s.Add(saslCap) + } + + // add capabilites requested by the user + s.Add(conn.cfg.Capabilites...) + + return s +} + +func (conn *Conn) negotiateCapabilities(supportedCaps []string) { + conn.supportedCaps.Add(supportedCaps...) + + reqCaps := conn.getRequestCapabilities() + reqCaps.Intersect(conn.supportedCaps) + + if reqCaps.Size() > 0 { + conn.Cap(CAP_REQ, reqCaps.Slice()...) + } else { + conn.Cap(CAP_END) + } +} + +func (conn *Conn) handleCapAck(caps []string) { + gotSasl := false + for _, cap := range caps { + conn.currCaps.Add(cap) + + if conn.cfg.Sasl != nil && cap == saslCap { + mech, ir, err := conn.cfg.Sasl.Start() + + if err != nil { + logging.Warn("SASL authentication failed: %v", err) + continue + } + + // TODO: when IRC 3.2 capability negotiation is supported, ensure the + // capability value is used to match the chosen mechanism + + gotSasl = true + conn.saslRemainingData = ir + + conn.Authenticate(mech) + } + } + + if !gotSasl { + conn.Cap(CAP_END) + } +} + +func (conn *Conn) handleCapNak(caps []string) { + conn.Cap(CAP_END) +} + +const ( + CAP_LS = "LS" + CAP_REQ = "REQ" + CAP_ACK = "ACK" + CAP_NAK = "NAK" + CAP_END = "END" +) + +type capSet struct { + caps map[string]bool + mu sync.RWMutex +} + +func capabilitySet() *capSet { + return &capSet{ + caps: make(map[string]bool), + } +} + +func (c *capSet) Add(caps ...string) { + c.mu.Lock() + for _, cap := range caps { + if strings.HasPrefix(cap, "-") { + c.caps[cap[1:]] = false + } else { + c.caps[cap] = true + } + } + c.mu.Unlock() +} + +func (c *capSet) Has(cap string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.caps[cap] +} + +// Intersect computes the intersection of two sets. +func (c *capSet) Intersect(other *capSet) { + c.mu.Lock() + + for cap := range c.caps { + if !other.Has(cap) { + delete(c.caps, cap) + } + } + + c.mu.Unlock() +} + +func (c *capSet) Slice() []string { + c.mu.RLock() + defer c.mu.RUnlock() + + capSlice := make([]string, 0, len(c.caps)) + for cap := range c.caps { + capSlice = append(capSlice, cap) + } + + // make output predictable for testing + sort.Strings(capSlice) + return capSlice +} + +func (c *capSet) Size() int { + c.mu.RLock() + defer c.mu.RUnlock() + return len(c.caps) +} + +// This handler is triggered when an invalid cap command is received by the server. +func (conn *Conn) h_410(line *Line) { + logging.Warn("Invalid cap subcommand: ", line.Args[1]) +} + +// Handler for capability negotiation commands. +// Note that even if multiple CAP_END commands may be sent to the server during negotiation, +// only the first will be considered. +func (conn *Conn) h_CAP(line *Line) { + subcommand := line.Args[1] + + caps := strings.Fields(line.Text()) + switch subcommand { + case CAP_LS: + conn.negotiateCapabilities(caps) + case CAP_ACK: + conn.handleCapAck(caps) + case CAP_NAK: + conn.handleCapNak(caps) + } +} + +// Handler for SASL authentication +func (conn *Conn) h_AUTHENTICATE(line *Line) { + if conn.cfg.Sasl == nil { + return + } + + if conn.saslRemainingData != nil { + data := "+" // plus sign representing empty data + if len(conn.saslRemainingData) > 0 { + data = base64.StdEncoding.EncodeToString(conn.saslRemainingData) + } + + // TODO: batch data into chunks of 400 bytes per the spec + + conn.Authenticate(data) + conn.saslRemainingData = nil + return + } + + // TODO: handle data over 400 bytes long (which will be chunked into multiple messages per the spec) + challenge, err := base64.StdEncoding.DecodeString(line.Args[0]) + if err != nil { + logging.Error("Failed to decode SASL challenge: %v", err) + return + } + + response, err := conn.cfg.Sasl.Next(challenge) + if err != nil { + logging.Error("Failed to generate response for SASL challenge: %v", err) + return + } + + // TODO: batch data into chunks of 400 bytes per the spec + data := base64.StdEncoding.EncodeToString(response) + conn.Authenticate(data) +} + +// Handler for RPL_SASLSUCCESS. +func (conn *Conn) h_903(line *Line) { + conn.Cap(CAP_END) +} + +// Handler for RPL_SASLFAILURE. +func (conn *Conn) h_904(line *Line) { + logging.Warn("SASL authentication failed") + conn.Cap(CAP_END) +} + +// Handler for RPL_SASLMECHS. +func (conn *Conn) h_908(line *Line) { + logging.Warn("SASL mechanism not supported, supported mechanisms are: %v", line.Args[1]) + conn.Cap(CAP_END) +} + // Handler to trigger a CONNECTED event on receipt of numeric 001 +// : 001 :Welcome message !@ func (conn *Conn) h_001(line *Line) { - // we're connected! - conn.dispatch(&Line{Cmd: CONNECTED, Time: time.Now()}) - // and we're being given our hostname (from the server's perspective) - t := line.Args[len(line.Args)-1] + // We're connected! Defer this for control flow reasons. + defer conn.dispatch(&Line{Cmd: CONNECTED, Time: time.Now()}) + + // Accept the server's opinion of what our nick actually is + // and record our ident and hostname (from the server's perspective) + me, nick, t := conn.Me(), line.Target(), line.Text() if idx := strings.LastIndex(t, " "); idx != -1 { t = t[idx+1:] - if idx = strings.Index(t, "@"); idx != -1 { - if conn.st != nil { - me := conn.Me() - conn.st.NickInfo(me.Nick, me.Ident, t[idx+1:], me.Name) - } else { - conn.cfg.Me.Host = t[idx+1:] - } + } + _, ident, host, ok := parseUserHost(t) + + if me.Nick != nick { + logging.Warn("Server changed our nick on connect: old=%q new=%q", me.Nick, nick) + } + if conn.st != nil { + if ok { + conn.st.NickInfo(me.Nick, ident, host, me.Name) + } + conn.cfg.Me = conn.st.ReNick(me.Nick, nick) + } else { + conn.cfg.Me.Nick = nick + if ok { + conn.cfg.Me.Ident = ident + conn.cfg.Me.Host = host } } } diff --git a/client/handlers_test.go b/client/handlers_test.go index d706ae6..1e374d5 100644 --- a/client/handlers_test.go +++ b/client/handlers_test.go @@ -44,7 +44,7 @@ func Test001(t *testing.T) { c, s := setUp(t) defer s.tearDown() - l := ParseLine(":irc.server.org 001 test :Welcome to IRC test!ident@somehost.com") + l := ParseLine(":irc.server.org 001 newnick :Welcome to IRC newnick!ident@somehost.com") // Set up a handler to detect whether connected handler is called from 001 hcon := false c.HandleFunc("connected", func(conn *Conn, line *Line) { @@ -54,7 +54,12 @@ func Test001(t *testing.T) { // Test state tracking first. gomock.InOrder( s.st.EXPECT().Me().Return(c.cfg.Me), - s.st.EXPECT().NickInfo("test", "test", "somehost.com", "Testing IRC"), + s.st.EXPECT().NickInfo("test", "ident", "somehost.com", "Testing IRC"), + s.st.EXPECT().ReNick("test", "newnick").Return(&state.Nick{ + Nick: "newnick", + Ident: c.cfg.Me.Ident, + Name: c.cfg.Me.Name, + }), ) // Call handler with a valid 001 line c.h_001(l) @@ -451,3 +456,54 @@ func Test671(t *testing.T) { s.st.EXPECT().GetNick("user2").Return(nil) c.h_671(ParseLine(":irc.server.org 671 test user2 :some ignored text")) } + +func TestCap(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().EnableCapabilityNegotiation = true + c.Config().Capabilites = []string{"cap1", "cap2", "cap3", "cap4"} + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + + // Ensure that capabilities not supported by the server are not requested + s.nc.Send("CAP * LS :cap2 cap4") + s.nc.Expect("CAP REQ :cap2 cap4") + + s.nc.Send("CAP * ACK :cap2 cap4") + s.nc.Expect("CAP END") + + for _, cap := range []string{"cap2", "cap4"} { + if !c.SupportsCapability(cap) { + t.Fail() + } + + if !c.HasCapability(cap) { + t.Fail() + } + } + + for _, cap := range []string{"cap1", "cap3"} { + if c.HasCapability(cap) { + t.Fail() + } + } + + // test disable capability after registration + s.c.Cap("REQ", "-cap4") + s.nc.Expect("CAP REQ :-cap4") + + s.nc.Send("CAP * ACK :-cap4") + s.nc.Expect("CAP END") + + if !c.HasCapability("cap2") { + t.Fail() + } + + if c.HasCapability("cap4") { + t.Fail() + } +} diff --git a/client/line.go b/client/line.go index bfa473a..5275767 100644 --- a/client/line.go +++ b/client/line.go @@ -154,11 +154,10 @@ func ParseLine(s string) *Line { // src can be the hostname of the irc server or a nick!user@host line.Host = line.Src - nidx, uidx := strings.Index(line.Src, "!"), strings.Index(line.Src, "@") - if uidx != -1 && nidx != -1 { - line.Nick = line.Src[:nidx] - line.Ident = line.Src[nidx+1 : uidx] - line.Host = line.Src[uidx+1:] + if n, i, h, ok := parseUserHost(line.Src); ok { + line.Nick = n + line.Ident = i + line.Host = h } } @@ -205,6 +204,15 @@ func ParseLine(s string) *Line { return line } +func parseUserHost(uh string) (nick, ident, host string, ok bool) { + uh = strings.TrimSpace(uh) + nidx, uidx := strings.Index(uh, "!"), strings.Index(uh, "@") + if uidx == -1 || nidx == -1 { + return "", "", "", false + } + return uh[:nidx], uh[nidx+1 : uidx], uh[uidx+1:], true +} + func (line *Line) argslen(minlen int) bool { pc, _, _, _ := runtime.Caller(1) fn := runtime.FuncForPC(pc) diff --git a/client/line_test.go b/client/line_test.go index 88b758d..7316525 100644 --- a/client/line_test.go +++ b/client/line_test.go @@ -184,3 +184,31 @@ func TestLineTags(t *testing.T) { } } } + +func TestParseUserHost(t *testing.T) { + tests := []struct { + in, nick, ident, host string + ok bool + }{ + {"", "", "", "", false}, + {" ", "", "", "", false}, + {"somestring", "", "", "", false}, + {" s p ", "", "", "", false}, + {"foo!bar", "", "", "", false}, + {"foo@baz.com", "", "", "", false}, + {"foo!bar@baz.com", "foo", "bar", "baz.com", true}, + {" foo!bar@baz.com", "foo", "bar", "baz.com", true}, + {" foo!bar@baz.com ", "foo", "bar", "baz.com", true}, + } + + for i, test := range tests { + nick, ident, host, ok := parseUserHost(test.in) + if test.nick != nick || + test.ident != ident || + test.host != host || + test.ok != ok { + t.Errorf("%d: parseUserHost(%q) = %q, %q, %q, %t; want %q, %q, %q, %t", + i, test.in, nick, ident, host, ok, test.nick, test.ident, test.host, test.ok) + } + } +} diff --git a/client/mocknetconn_test.go b/client/mocknetconn_test.go index e736c88..fc80de4 100644 --- a/client/mocknetconn_test.go +++ b/client/mocknetconn_test.go @@ -1,6 +1,7 @@ package client import ( + "context" "io" "net" "os" @@ -14,7 +15,7 @@ type mockNetConn struct { In, Out chan string in, out chan []byte - die chan struct{} + die context.CancelFunc closed bool rt, wt time.Time @@ -22,7 +23,8 @@ type mockNetConn struct { func MockNetConn(t *testing.T) *mockNetConn { // Our mock connection is a testing object - m := &mockNetConn{T: t, die: make(chan struct{})} + ctx, cancel := context.WithCancel(context.Background()) + m := &mockNetConn{T: t, die: cancel} // buffer input m.In = make(chan string, 20) @@ -30,7 +32,7 @@ func MockNetConn(t *testing.T) *mockNetConn { go func() { for { select { - case <-m.die: + case <-ctx.Done(): return case s := <-m.In: m.in <- []byte(s) @@ -44,7 +46,7 @@ func MockNetConn(t *testing.T) *mockNetConn { go func() { for { select { - case <-m.die: + case <-ctx.Done(): return case b := <-m.out: m.Out <- string(b) @@ -89,15 +91,12 @@ func (m *mockNetConn) Read(b []byte) (int, error) { if m.Closed() { return 0, os.ErrInvalid } - l := 0 - select { - case s := <-m.in: - l = len(s) - copy(b, s) - case <-m.die: - return 0, io.EOF + s, ok := <-m.in + copy(b, s) + if !ok { + return len(s), io.EOF } - return l, nil + return len(s), nil } func (m *mockNetConn) Write(s []byte) (int, error) { @@ -114,19 +113,16 @@ func (m *mockNetConn) Close() error { if m.Closed() { return os.ErrInvalid } + m.closed = true // Shut down *ALL* the goroutines! // This will trigger an EOF event in Read() too - close(m.die) + m.die() + close(m.in) return nil } func (m *mockNetConn) Closed() bool { - select { - case <-m.die: - return true - default: - return false - } + return m.closed } func (m *mockNetConn) LocalAddr() net.Addr { diff --git a/client/sasl_test.go b/client/sasl_test.go new file mode 100644 index 0000000..073a074 --- /dev/null +++ b/client/sasl_test.go @@ -0,0 +1,103 @@ +package client + +import ( + "github.com/emersion/go-sasl" + "testing" +) + +func TestSaslPlainSuccessWorkflow(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().Sasl = sasl.NewPlainClient("", "example", "password") + c.Config().EnableCapabilityNegotiation = true + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + s.nc.Send("CAP * LS :sasl foobar") + s.nc.Expect("CAP REQ :sasl") + s.nc.Send("CAP * ACK :sasl") + s.nc.Expect("AUTHENTICATE PLAIN") + s.nc.Send("AUTHENTICATE +") + s.nc.Expect("AUTHENTICATE AGV4YW1wbGUAcGFzc3dvcmQ=") + s.nc.Send("904 test :SASL authentication successful") + s.nc.Expect("CAP END") +} + +func TestSaslPlainWrongPassword(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().Sasl = sasl.NewPlainClient("", "example", "password") + c.Config().EnableCapabilityNegotiation = true + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + s.nc.Send("CAP * LS :sasl foobar") + s.nc.Expect("CAP REQ :sasl") + s.nc.Send("CAP * ACK :sasl") + s.nc.Expect("AUTHENTICATE PLAIN") + s.nc.Send("AUTHENTICATE +") + s.nc.Expect("AUTHENTICATE AGV4YW1wbGUAcGFzc3dvcmQ=") + s.nc.Send("904 test :SASL authentication failed") + s.nc.Expect("CAP END") +} + +func TestSaslExternalSuccessWorkflow(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().Sasl = sasl.NewExternalClient("") + c.Config().EnableCapabilityNegotiation = true + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + s.nc.Send("CAP * LS :sasl foobar") + s.nc.Expect("CAP REQ :sasl") + s.nc.Send("CAP * ACK :sasl") + s.nc.Expect("AUTHENTICATE EXTERNAL") + s.nc.Send("AUTHENTICATE +") + s.nc.Expect("AUTHENTICATE +") + s.nc.Send("904 test :SASL authentication successful") + s.nc.Expect("CAP END") +} + +func TestSaslNoSaslCap(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().Sasl = sasl.NewPlainClient("", "example", "password") + c.Config().EnableCapabilityNegotiation = true + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + s.nc.Send("CAP * LS :foobar") + s.nc.Expect("CAP END") +} + +func TestSaslUnsupportedMechanism(t *testing.T) { + c, s := setUp(t) + defer s.tearDown() + + c.Config().Sasl = sasl.NewPlainClient("", "example", "password") + c.Config().EnableCapabilityNegotiation = true + + c.h_REGISTER(&Line{Cmd: REGISTER}) + s.nc.Expect("CAP LS") + s.nc.Expect("NICK test") + s.nc.Expect("USER test 12 * :Testing IRC") + s.nc.Send("CAP * LS :sasl foobar") + s.nc.Expect("CAP REQ :sasl") + s.nc.Send("CAP * ACK :sasl") + s.nc.Expect("AUTHENTICATE PLAIN") + s.nc.Send("908 test external :are available SASL mechanisms") + s.nc.Expect("CAP END") +} diff --git a/go.mod b/go.mod index f1da5df..c87e5fb 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,11 @@ module github.com/fluffle/goirc require ( - github.com/golang/mock v1.1.1 - golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3 + github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead + github.com/fluffle/golog/logging v0.0.0-20180928190033-7d99e85061cb + github.com/golang/glog v1.0.0 + github.com/golang/mock v1.5.0 + golang.org/x/net v0.18.0 ) go 1.13 diff --git a/go.sum b/go.sum index 42e780a..6b12740 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,57 @@ -github.com/golang/mock v1.1.1 h1:G5FRp8JnTd7RQH5kemVNlMeyXQAztQ3mOWV95KxsXH8= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y= +github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/fluffle/golog/logging v0.0.0-20180928190033-7d99e85061cb h1:r//eMD/5sdzxVy34UP5fFvRWIL2L8QZtaDgVCKfVQLI= +github.com/fluffle/golog/logging v0.0.0-20180928190033-7d99e85061cb/go.mod h1:w8+az2+kPHMcsaKnTnGapWTNToJK8BogkHiAncvqKsM= +github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= -golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3 h1:dgd4x4kJt7G4k4m93AYLzM8Ni6h2qLTfh9n9vXJT3/0= +github.com/golang/mock v1.5.0 h1:jlYHihg//f7RRwuPfptm04yp4s7O6Kw8EZiVYIGcH0g= +github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20180926154720-4dfa2610cdf3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/state/nick.go b/state/nick.go index b29d98a..24a03bd 100644 --- a/state/nick.go +++ b/state/nick.go @@ -71,7 +71,7 @@ func (nk *nick) Nick() *Nick { Host: nk.host, Name: nk.name, Modes: nk.modes.Copy(), - Channels: make(map[string]*ChanPrivs), + Channels: make(map[string]*ChanPrivs, len(nk.chans)), } for c, cp := range nk.chans { n.Channels[c.name] = cp.Copy() @@ -157,6 +157,7 @@ func (nm *NickMode) Equals(other *NickMode) bool { } // Returns a string representing the nick. Looks like: +// // Nick: e.g. CowMaster // Hostmask: e.g. moo@cows.org // Real Name: e.g. Steve "CowMaster" Bush @@ -181,6 +182,7 @@ func (nk *nick) String() string { } // Returns a string representing the nick modes. Looks like: +// // +iwx func (nm *NickMode) String() string { if nm == nil { diff --git a/state/nick_test.go b/state/nick_test.go index 1344400..717c8a0 100644 --- a/state/nick_test.go +++ b/state/nick_test.go @@ -1,6 +1,9 @@ package state -import "testing" +import ( + "fmt" + "testing" +) func compareNick(t *testing.T, nk *nick) { n := nk.Nick() @@ -86,3 +89,30 @@ func TestNickParseModes(t *testing.T) { t.Errorf("Modes not flipped correctly by ParseModes.") } } + +func BenchmarkNickSingleChan(b *testing.B) { + nk := newNick("test1") + ch := newChannel("#test1") + cp := new(ChanPrivs) + nk.addChannel(ch, cp) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + nk.Nick() + } +} + +func BenchmarkNickManyChan(b *testing.B) { + const numChans = 1000 + nk := newNick("test1") + for i := 0; i < numChans; i++ { + ch := newChannel(fmt.Sprintf("#test%d", i)) + cp := new(ChanPrivs) + nk.addChannel(ch, cp) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + nk.Nick() + } +}