Compare nicks by exact case everywhere (closes #128) #141

Merged
clawbot merged 1 commits from issue-128-exact-nicks into next 2026-10-20 16:31:29 +02:00
8 changed files with 66 additions and 31 deletions
+9 -16
View File
@@ -1031,14 +1031,14 @@ e.g. `+o-v alice bob`.
| `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel | | `441` | ERR_USERNOTINCHANNEL | The `+o`/`+v` nick is not in the channel |
**User modes:** you can query and change only your own; a nick other than your **User modes:** you can query and change only your own; a nick other than your
own, compared without regard to letter case, gets ERR_USERSDONTMATCH (502). With own gets ERR_USERSDONTMATCH (502). With no mode string the server sends
no mode string the server sends RPL_UMODEIS (221) with your modes: `+`, `+o`, RPL_UMODEIS (221) with your modes: `+`, `+o`, `+w` or `+ow`. A change is a mode
`+w` or `+ow`. A change is a mode string, `body[0]` over the HTTP API or the string, `body[0]` over the HTTP API or the parameter after the nick on the IRC
parameter after the nick on the IRC listener, that starts with `+` or `-`: `w` listener, that starts with `+` or `-`: `w` (receive `WALLOPS`) can be set or
(receive `WALLOPS`) can be set or unset, and `o` (server operator) only unset, unset, and `o` (server operator) only unset, since only `OPER` sets it. Any
since only `OPER` sets it. Any other letter, or a string that names no mode, other letter, or a string that names no mode, gets ERR_UMODEUNKNOWNFLAG (501)
gets ERR_UMODEUNKNOWNFLAG (501) and changes nothing. A change that is accepted and changes nothing. A change that is accepted is answered with 221 and the new
is answered with 221 and the new modes; it is not sent to anyone else. modes; it is not sent to anyone else.
```json ```json
{ "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] } { "command": "RPL_UMODEIS", "code": 221, "to": "alice", "body": ["+w"] }
@@ -2679,8 +2679,7 @@ IRC_LISTEN_ADDR: ""
set only over the HTTP API. set only over the HTTP API.
- **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives - **User modes**: `+o` (operator, set only via `OPER`), `+w` (receives
`WALLOPS`). `MODE` for any nick other than your own is rejected with `WALLOPS`). `MODE` for any nick other than your own is rejected with
`ERR_USERSDONTMATCH` (502), for both queries and changes. Nick comparison is `ERR_USERSDONTMATCH` (502), for both queries and changes.
case-insensitive.
- **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel - **KILL**: an operator's `KILL` broadcasts the victim's `QUIT` to its channel
peers, deletes its session, and then sends the victim a `KILL` and peers, deletes its session, and then sends the victim a `KILL` and
`ERROR :Closing Link` before closing its socket. This applies to victims on `ERROR :Closing Link` before closing its socket. This applies to victims on
@@ -2701,12 +2700,6 @@ API reaches IRC listener members as an IRC `MODE` line with its value as a
separate parameter, e.g. `MODE #general +k secretpass` or separate parameter, e.g. `MODE #general +k secretpass` or
`MODE #general +o bob`. `MODE #general +o bob`.
One gap remains: the IRC listener does not pass on a `PRIVMSG`, `NOTICE`,
`JOIN`, `PART`, `NICK` or `QUIT` whose sender's nick matches the user's own nick
ignoring case, so an IRC listener user `alice` never sees these from `Alice`,
although nicks are case-sensitive. https://git.eeqj.de/sneak/neoirc/issues/128
tracks it.
### Docker Usage ### Docker Usage
To expose the IRC port in Docker (the listener is enabled by default on To expose the IRC port in Docker (the listener is enabled by default on
+3
View File
@@ -669,6 +669,9 @@ func TestCreateSessionDuplicate(t *testing.T) {
if resp.StatusCode != http.StatusConflict { if resp.StatusCode != http.StatusConflict {
t.Fatalf("expected 409, got %d", resp.StatusCode) t.Fatalf("expected 409, got %d", resp.StatusCode)
} }
// Nicks are case-sensitive: Alice is not alice.
tserver.createSession("Alice")
} }
// TestEachTestServerHasItsOwnDatabase checks that starting a // TestEachTestServerHasItsOwnDatabase checks that starting a
+2 -3
View File
@@ -254,8 +254,7 @@ func (hdlr *Handlers) handleWallops(
// handleUserMode handles MODE for a nick: a query when the // handleUserMode handles MODE for a nick: a query when the
// body is empty, otherwise a change by the mode string in // body is empty, otherwise a change by the mode string in
// the body. Only the caller's own nick, in any letter case, // the body. Only the caller's own nick is allowed.
// is allowed.
func (hdlr *Handlers) handleUserMode( func (hdlr *Handlers) handleUserMode(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
@@ -263,7 +262,7 @@ func (hdlr *Handlers) handleUserMode(
nick, target string, nick, target string,
bodyLines func() []string, bodyLines func() []string,
) { ) {
if !strings.EqualFold(target, nick) { if target != nick {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrUsersDoNotMatch, nick, nil, irc.ErrUsersDoNotMatch, nick, nil,
+3 -1
View File
@@ -290,8 +290,10 @@ func TestUserMode(t *testing.T) {
target, modeStr, numeric, modes string target, modeStr, numeric, modes string
}{ }{
{nick, "+w", "221", "+w"}, {nick, "+w", "221", "+w"},
{"Alice", "", "502", ""},
{"Alice", "-w", "502", ""},
{nick, "", "221", "+w"}, {nick, "", "221", "+w"},
{strings.ToUpper(nick), "-w", "221", "+"}, {nick, "-w", "221", "+"},
{nick, "+z", "501", ""}, {nick, "+z", "501", ""},
{nick, "+o", "501", ""}, {nick, "+o", "501", ""},
{other, "", "502", ""}, {other, "", "502", ""},
+2 -2
View File
@@ -710,12 +710,12 @@ func (c *Conn) applyChannelModes(
// handleUserMode handles MODE for a nick: a query without a // handleUserMode handles MODE for a nick: a query without a
// mode string, otherwise a change. Only the client's own // mode string, otherwise a change. Only the client's own
// nick, in any letter case, is allowed. // nick is allowed.
func (c *Conn) handleUserMode( func (c *Conn) handleUserMode(
ctx context.Context, ctx context.Context,
msg *Message, msg *Message,
) { ) {
if !strings.EqualFold(msg.Params[0], c.currentNick()) { if msg.Params[0] != c.currentNick() {
c.sendNumeric( c.sendNumeric(
irc.ErrUsersDoNotMatch, irc.ErrUsersDoNotMatch,
"Can't change mode for other users", "Can't change mode for other users",
+6 -4
View File
@@ -1084,9 +1084,9 @@ func TestIntegrationWallops(t *testing.T) {
} }
// TestIntegrationUserMode covers MODE for a nick: changes // TestIntegrationUserMode covers MODE for a nick: changes
// and queries of your own modes in any letter case, refusal // and queries of your own modes, refusal for another nick,
// for another nick, and a rejected mode string changing // even one that differs from yours only in letter case, and
// nothing. // a rejected mode string changing nothing.
func TestIntegrationUserMode(t *testing.T) { func TestIntegrationUserMode(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1106,7 +1106,9 @@ func TestIntegrationUserMode(t *testing.T) {
for _, step := range []struct { for _, step := range []struct {
line, want string line, want string
}{ }{
{"MODE ALICE +w", " 221 alice +w"}, {"MODE alice +w", " 221 alice +w"},
{"MODE Alice", notYours},
{"MODE Alice -w", notYours},
{"MODE bob", notYours}, {"MODE bob", notYours},
{"MODE bob -w", notYours}, {"MODE bob -w", notYours},
{"MODE alice xw", unknown}, {"MODE alice xw", unknown},
+5 -5
View File
@@ -195,7 +195,7 @@ func (c *Conn) deliverTextMessage(
c.mu.Unlock() c.mu.Unlock()
// Don't echo our own messages back. // Don't echo our own messages back.
if strings.EqualFold(from, nick) { if from == nick {
return return
} }
@@ -217,7 +217,7 @@ func (c *Conn) deliverJoin(msg *db.IRCMessage) {
// Don't echo our own JOINs (we already sent them // Don't echo our own JOINs (we already sent them
// during joinChannel). // during joinChannel).
if strings.EqualFold(msg.From, nick) { if msg.From == nick {
return return
} }
@@ -233,7 +233,7 @@ func (c *Conn) deliverPart(msg *db.IRCMessage, text string) {
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
if strings.EqualFold(msg.From, nick) { if msg.From == nick {
return return
} }
@@ -258,7 +258,7 @@ func (c *Conn) deliverNickChange(
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
if strings.EqualFold(msg.From, nick) { if msg.From == nick {
return return
} }
@@ -276,7 +276,7 @@ func (c *Conn) deliverQuitMsg(
nick := c.nick nick := c.nick
c.mu.Unlock() c.mu.Unlock()
if strings.EqualFold(msg.From, nick) { if msg.From == nick {
return return
} }
+36
View File
@@ -185,3 +185,39 @@ func TestRelayLineBreaks(t *testing.T) {
t.Errorf("got %q, want %q", got, want) t.Errorf("got %q, want %q", got, want)
} }
} }
// TestRelayNickDiffersOnlyInCase checks that an IRC listener
// user alice receives a JOIN, PRIVMSG, PART, NICK and QUIT
// from Alice in a channel they share: nicks are
// case-sensitive, so Alice is someone else.
func TestRelayNickDiffersOnlyInCase(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
alice := env.dial(t)
alice.register("alice")
alice.joinAndDrain("#case")
capitalAlice := env.dial(t)
capitalAlice.register("Alice")
for _, step := range []struct {
line, want string
}{
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"PRIVMSG #case :hi there", ":Alice!Alice@* PRIVMSG #case :hi there"},
{"PART #case :see you", ":Alice!Alice@* PART #case :see you"},
{"JOIN #case", ":Alice!Alice@* JOIN #case"},
{"NICK Alicia", ":Alice!Alice@* NICK Alicia"},
{"NICK Alice", ":Alicia!Alicia@* NICK Alice"}, // back for QUIT
{"QUIT", ":Alice!Alice@* QUIT :Quit: Connection closed"},
} {
capitalAlice.send(step.line)
got := alice.readLine()
if got != step.want {
t.Errorf("%s: got %q, want %q", step.line, got, step.want)
}
}
}