Bug 1446689 - Support IRC CAP negotiation v3.2. r=clokep DONTBUILD
authorMartin Giger <martin@humanoids.be>
Sat, 26 Oct 2019 11:53:43 +0200
changeset 37364 b2b10252b7913be853ffceb5a37e4a26e3c431f4
parent 37363 216ae52769a8675888c33b953e972c3d03e1fda8
child 37365 0af98d8c569d935828e58c2542d3011efb65ef21
push id396
push userclokep@gmail.com
push dateMon, 06 Jan 2020 23:11:57 +0000
reviewersclokep
bugs1446689
Bug 1446689 - Support IRC CAP negotiation v3.2. r=clokep DONTBUILD
chat/protocols/irc/irc.js
chat/protocols/irc/ircBase.jsm
chat/protocols/irc/ircCAP.jsm
chat/protocols/irc/ircMultiPrefix.jsm
chat/protocols/irc/ircSASL.jsm
chat/protocols/irc/ircServerTime.jsm
chat/protocols/irc/test/test_ircCAP.js
--- a/chat/protocols/irc/irc.js
+++ b/chat/protocols/irc/irc.js
@@ -996,17 +996,20 @@ function ircAccount(aProtocol, aImAccoun
 
   this._nickname = this._accountNickname;
   this._requestedNickname = this._nickname;
 
   // For more information, see where these are defined in the prototype below.
   this.trackQueue = [];
   this.pendingIsOnQueue = [];
   this.whoisInformation = new NormalizedMap(this.normalizeNick.bind(this));
-  this._caps = new Set();
+  this._requestedCAPs = new Set();
+  this._availableCAPs = new Set();
+  this._activeCAPs = new Set();
+  this._queuedCAPs = [];
   this._commandBuffers = new Map();
   this._roomInfoCallbacks = new Set();
 }
 ircAccount.prototype = {
   __proto__: GenericAccountPrototype,
   _socket: null,
   _MODE_WALLOPS: 1 << 2, // mode 'w'
   _MODE_INVISIBLE: 1 << 3, // mode 'i'
@@ -1831,44 +1834,50 @@ ircAccount.prototype = {
     this._socket = new ircSocket(this);
     this._socket.connect(this._server, this._port, this._ssl ? ["ssl"] : []);
   },
 
   // Functions for keeping track of whether the Client Capabilities is done.
   // If a cap is to be handled, it should be registered with addCAP, where aCAP
   // is a "unique" string defining what is being handled. When the cap is done
   // being handled removeCAP should be called with the same string.
-  _caps: new Set(),
+  _availableCAPs: new Set(),
+  _activeCAPs: new Set(),
+  _requestedCAPs: new Set(),
   _capTimeout: null,
+  _negotiatedCAPs: false,
+  _queuedCAPs: [],
   addCAP(aCAP) {
     if (this.connected) {
       this.ERROR("Trying to add CAP " + aCAP + " after connection.");
       return;
     }
 
-    this._caps.add(aCAP);
+    this._requestedCAPs.add(aCAP);
   },
   removeCAP(aDoneCAP) {
-    if (!this._caps.has(aDoneCAP)) {
+    if (!this._requestedCAPs.has(aDoneCAP)) {
       this.ERROR(
         "Trying to remove a CAP (" + aDoneCAP + ") which isn't added."
       );
       return;
     }
     if (this.connected) {
       this.ERROR("Trying to remove CAP " + aDoneCAP + " after connection.");
       return;
     }
 
     // Remove any reference to the given capability.
-    this._caps.delete(aDoneCAP);
+    this._requestedCAPs.delete(aDoneCAP);
 
-    // If no more CAP messages are being handled, notify the server.
-    if (!this._caps.size) {
+    // However only notify the server the first time during cap negotiation, not
+    // when the server exposes a new cap.
+    if (!this._requestedCAPs.size && !this._negotiatedCAPs) {
       this.sendMessage("CAP", "END");
+      this._negotiatedCAPs = true;
     }
   },
 
   // Used to wait for a response from the server.
   _quitTimer: null,
   // RFC 2812 Section 3.1.7.
   quit(aMessage) {
     this._reportDisconnecting(Ci.prplIAccount.NO_ERROR);
@@ -2139,18 +2148,18 @@ ircAccount.prototype = {
     return this.sendMessage(aIsNotice ? "NOTICE" : "PRIVMSG", [
       aTarget,
       ircParam,
     ]);
   },
 
   // Implement section 3.1 of RFC 2812
   _connectionRegistration() {
-    // Send the Client Capabilities list command.
-    this.sendMessage("CAP", "LS");
+    // Send the Client Capabilities list command version 3.2.
+    this.sendMessage("CAP", ["LS", "302"]);
 
     if (this.prefs.prefHasUserValue("serverPassword")) {
       this.sendMessage(
         "PASS",
         this.getString("serverPassword"),
         "PASS <password not logged>"
       );
     }
@@ -2188,17 +2197,20 @@ ircAccount.prototype = {
     // is when the server acknowledges our disconnection.
     // Otherwise it's because we lost the connection.
     if (!this.disconnecting) {
       this._reportDisconnecting(aError, aErrorMessage);
     }
     this._socket.disconnect();
     delete this._socket;
 
-    this._caps.clear();
+    this._requestedCAPs.clear();
+    this._availableCAPs.clear();
+    this._activeCAPs.clear();
+    this._queuedCAPs.length = 0;
 
     clearTimeout(this._isOnTimer);
     delete this._isOnTimer;
 
     // No need to call gotDisconnected a second time.
     clearTimeout(this._quitTimer);
     delete this._quitTimer;
 
@@ -2290,16 +2302,18 @@ function ircProtocol() {
   ircHandlers.registerHandler(tempScope.ircServices);
   // Register default ISUPPORT handler (ISUPPORT base).
   ircHandlers.registerISUPPORTHandler(tempScope.isupportBase);
   // Register default CTCP handlers (CTCP base, DCC).
   ircHandlers.registerCTCPHandler(tempScope.ctcpBase);
   ircHandlers.registerCTCPHandler(tempScope.ctcpDCC);
   // Register default IRC Services handlers (IRC Services base).
   ircHandlers.registerServicesHandler(tempScope.servicesBase);
+  // Register default CAP handlers for base features (CAP basics).
+  ircHandlers.registerCAPHandler(tempScope.capNotify);
 
   // Register extra features.
   ircHandlers.registerISUPPORTHandler(tempScope.isupportNAMESX);
   ircHandlers.registerCAPHandler(tempScope.capMultiPrefix);
   ircHandlers.registerHandler(tempScope.ircNonStandard);
   ircHandlers.registerHandler(tempScope.ircWATCH);
   ircHandlers.registerISUPPORTHandler(tempScope.isupportWATCH);
   ircHandlers.registerHandler(tempScope.ircMONITOR);
--- a/chat/protocols/irc/ircBase.jsm
+++ b/chat/protocols/irc/ircBase.jsm
@@ -444,18 +444,20 @@ var ircBase = {
         this.observe(null, "status-changed");
       }
 
       // Check if any of our buddies are online!
       const kInitialIsOnDelay = 1000;
       this._isOnTimer = setTimeout(this.sendIsOn.bind(this), kInitialIsOnDelay);
 
       // If we didn't handle all the CAPs we added, something is wrong.
-      if (this._caps.size) {
-        this.ERROR("Connected without removing CAPs: " + [...this._caps]);
+      if (this._requestedCAPs.size) {
+        this.ERROR(
+          "Connected without removing CAPs: " + [...this._requestedCAPs]
+        );
       }
 
       // Done!
       this.reportConnected();
       return serverMessage(this, aMessage);
     },
     "002": function(aMessage) {
       // RPL_YOURHOST
--- a/chat/protocols/irc/ircCAP.jsm
+++ b/chat/protocols/irc/ircCAP.jsm
@@ -5,82 +5,116 @@
 /*
  * This implements the IRC Client Capabilities sub-protocol.
  *   Client Capab Proposal
  *     http://www.leeh.co.uk/ircd/client-cap.txt
  *   RFC Drafts: IRC Client Capabilities
  *     http://tools.ietf.org/html/draft-baudis-irc-capab-00
  *     http://tools.ietf.org/html/draft-mitchell-irc-capabilities-01
  *   IRCv3
- *     http://ircv3.net/specs/core/capability-negotiation-3.1.html
- *     http://ircv3.net/specs/core/capability-negotiation-3.2.html
+ *     https://ircv3.net/specs/core/capability-negotiation.html
  *
  * Note that this doesn't include any implementation as these RFCs do not even
  * include example parameters.
  */
 
-this.EXPORTED_SYMBOLS = ["ircCAP"];
+this.EXPORTED_SYMBOLS = ["ircCAP", "capNotify"];
 
 const { ircHandlers } = ChromeUtils.import(
   "resource:///modules/ircHandlers.jsm"
 );
 
 /*
  * Parses a CAP message of the form:
- *   CAP <subcommand> [<parameters>]
+ *   CAP [*|<user>] <subcommand> [*] [<parameters>]
  * The cap field is added to the message and it has the following fields:
  *   subcommand
  *   parameters A list of capabilities.
  */
-function capMessage(aMessage) {
+function capMessage(aMessage, aAccount) {
   // The CAP parameters are space separated as the last parameter.
   let parameters = aMessage.params
     .slice(-1)[0]
     .trim()
     .split(" ");
   // The subcommand is the second parameter...although sometimes it's the first
   // parameter.
   aMessage.cap = {
-    subcommand: aMessage.params[aMessage.params.length == 3 ? 1 : 0],
+    subcommand: aMessage.params[aMessage.params.length >= 3 ? 1 : 0],
   };
 
-  return parameters.map(function(aParameter) {
+  const messages = parameters.map(function(aParameter) {
     // Clone the original object.
     let message = Object.assign({}, aMessage);
     message.cap = Object.assign({}, aMessage.cap);
 
     // If there's a modifier...pull it off. (This is pretty much unused, but we
     // have to pull it off for backward compatibility.)
     if ("-=~".includes(aParameter[0])) {
       message.cap.modifier = aParameter[0];
       aParameter = aParameter.substr(1);
     } else {
       message.cap.modifier = undefined;
     }
 
+    // CAP v3.2 capability value
+    if (aParameter.includes("=")) {
+      let paramParts = aParameter.split("=");
+      aParameter = paramParts[0];
+      // The value itself may contain an = sign, join the rest of the parts back together.
+      message.cap.value = paramParts.slice(1).join("=");
+    }
+
     // The names are case insensitive, arbitrarily choose lowercase.
     message.cap.parameter = aParameter.toLowerCase();
     message.cap.disable = message.cap.modifier == "-";
     message.cap.sticky = message.cap.modifier == "=";
     message.cap.ack = message.cap.modifier == "~";
 
     return message;
   });
+
+  // Queue up messages if the server is indicating multiple lines of caps to list.
+  if (
+    (aMessage.cap.subcommand === "LS" || aMessage.cap.subcommand === "LIST") &&
+    aMessage.params.length == 4
+  ) {
+    aAccount._queuedCAPs = aAccount._queuedCAPs.concat(messages);
+    return [];
+  }
+
+  const retMessages = aAccount._queuedCAPs.concat(messages);
+  aAccount._queuedCAPs.length = 0;
+  return retMessages;
 }
 
 var ircCAP = {
   name: "Client Capabilities",
   // Slightly above default RFC 2812 priority.
   priority: ircHandlers.DEFAULT_PRIORITY + 10,
   isEnabled: () => true,
 
   commands: {
     CAP(aMessage) {
       // [* | <nick>] <subcommand> :<parameters>
-      let messages = capMessage(aMessage);
+      let messages = capMessage(aMessage, this);
+
+      for (const message of messages) {
+        if (
+          message.cap.subcommand === "LS" ||
+          message.cap.subcommand === "NEW"
+        ) {
+          this._availableCAPs.add(message.cap.parameter);
+        } else if (message.cap.subcommand === "ACK") {
+          this._activeCAPs.add(message.cap.parameter);
+        } else if (message.cap.subcommand === "DEL") {
+          this._availableCAPs.delete(message.cap.parameter);
+          this._activeCAPs.delete(message.cap.parameter);
+        }
+      }
 
       messages = messages.filter(
         aMessage => !ircHandlers.handleCAPMessage(this, aMessage)
       );
       if (messages.length) {
         // Display the list of unhandled CAP messages.
         let unhandledMessages = messages
           .map(aMsg => aMsg.cap.parameter)
@@ -89,22 +123,55 @@ var ircCAP = {
           "Unhandled CAP messages: " +
             unhandledMessages +
             "\nRaw message: " +
             aMessage.rawMessage
         );
       }
 
       // If no CAP handlers were added, just tell the server we're done.
-      if (aMessage.cap.subcommand == "LS" && !this._caps.size) {
+      if (
+        aMessage.cap.subcommand == "LS" &&
+        !this._requestedCAPs.size &&
+        !this._queuedCAPs.length
+      ) {
         this.sendMessage("CAP", "END");
+        this._negotiatedCAPs = true;
       }
       return true;
     },
 
     "410": function(aMessage) {
       // ERR_INVALIDCAPCMD
       // <unrecognized subcommand> :Invalid CAP subcommand
       this.WARN("Invalid subcommand: " + aMessage.params[1]);
       return true;
     },
   },
 };
+
+var capNotify = {
+  name: "Client Capabilities",
+  priority: ircHandlers.DEFAULT_PRIORITY,
+  // This is implicitly enabled as part of CAP v3.2, so always enable it.
+  isEnabled: () => true,
+
+  commands: {
+    "cap-notify": function(aMessage) {
+      // This negotiation is entirely optional. cap-notify may thus never be formally registered.
+      if (
+        aMessage.cap.subcommand === "LS" ||
+        aMessage.cap.subcommand === "NEW"
+      ) {
+        this.addCAP("cap-notify");
+        this.sendMessage("CAP", ["REQ", "cap-notify"]);
+      } else if (
+        aMessage.cap.subcommand === "ACK" ||
+        aMessage.cap.subcommand === "NAK"
+      ) {
+        this.removeCAP("cap-notify");
+      } else {
+        return false;
+      }
+      return true;
+    },
+  },
+};
--- a/chat/protocols/irc/ircMultiPrefix.jsm
+++ b/chat/protocols/irc/ircMultiPrefix.jsm
@@ -39,20 +39,26 @@ var capMultiPrefix = {
   name: "CAP multi-prefix",
   // Slightly above default ISUPPORT priority.
   priority: ircHandlers.HIGH_PRIORITY,
   isEnabled: () => true,
 
   commands: {
     "multi-prefix": function(aMessage) {
       // Request to use multi-prefix if it is supported.
-      if (aMessage.cap.subcommand == "LS") {
+      if (
+        aMessage.cap.subcommand === "LS" ||
+        aMessage.cap.subcommand === "NEW"
+      ) {
         this.addCAP("multi-prefix");
         this.sendMessage("CAP", ["REQ", "multi-prefix"]);
-      } else if (aMessage.cap.subcommand == "ACK") {
+      } else if (
+        aMessage.cap.subcommand == "ACK" ||
+        aMessage.cap.subcommand === "NAK"
+      ) {
         this.removeCAP("multi-prefix");
       } else {
         return false;
       }
       return true;
     },
   },
 };
--- a/chat/protocols/irc/ircSASL.jsm
+++ b/chat/protocols/irc/ircSASL.jsm
@@ -12,17 +12,19 @@ this.EXPORTED_SYMBOLS = ["ircSASL", "cap
 
 const { ircHandlers } = ChromeUtils.import(
   "resource:///modules/ircHandlers.jsm"
 );
 
 var ircSASL = {
   name: "SASL AUTHENTICATE",
   priority: ircHandlers.DEFAULT_PRIORITY,
-  isEnabled: () => true,
+  isEnabled() {
+    return this._activeCAPs.has("sasl");
+  },
 
   commands: {
     AUTHENTICATE(aMessage) {
       // Expect an empty response, if something different is received abort.
       if (aMessage.params[0] != "+") {
         this.sendMessage("AUTHENTICATE", "*");
         this.WARN(
           "Aborting SASL authentication, unexpected message " +
@@ -105,20 +107,24 @@ var ircSASL = {
       this.removeCAP("sasl");
       return true;
     },
 
     "906": function(aMessage) {
       // ERR_SASLABORTED
       // The client completed registration before SASL authentication completed,
       // or because we sent `AUTHENTICATE` with `*` as the parameter.
-      this.ERROR(
-        "Registration completed before SASL authentication completed."
-      );
-      this.removeCAP("sasl");
+      //
+      // Freenode sends 906 in addition to 904, ignore 906 in this case.
+      if (this._requestedCAPs.has("sasl")) {
+        this.ERROR(
+          "Registration completed before SASL authentication completed."
+        );
+        this.removeCAP("sasl");
+      }
       return true;
     },
 
     "907": function(aMessage) {
       // ERR_SASLALREADY
       // Response if client attempts to AUTHENTICATE after successful
       // authentication.
       this.ERROR("Attempting SASL authentication twice?!");
@@ -138,22 +144,40 @@ var ircSASL = {
 
 var capSASL = {
   name: "SASL CAP",
   priority: ircHandlers.DEFAULT_PRIORITY,
   isEnabled: () => true,
 
   commands: {
     sasl(aMessage) {
-      if (aMessage.cap.subcommand == "LS" && this.imAccount.password) {
+      // Return early if we are already authenticated (can happen due to cap-notify)
+      if (this.isAuthenticated) {
+        return true;
+      }
+
+      if (
+        (aMessage.cap.subcommand === "LS" ||
+          aMessage.cap.subcommand === "NEW") &&
+        this.imAccount.password
+      ) {
+        if (aMessage.cap.value) {
+          const mechanisms = aMessage.cap.value.split(",");
+          // We only support the plain authentication mechanism for now, abort if it's not available.
+          if (!mechanisms.includes("PLAIN")) {
+            return true;
+          }
+        }
         // If it supports SASL, let the server know we're requiring SASL.
+        this.addCAP("sasl");
         this.sendMessage("CAP", ["REQ", "sasl"]);
-        this.addCAP("sasl");
       } else if (aMessage.cap.subcommand == "ACK") {
         // The server acknowledges our choice to use SASL, send the first
         // message.
         this.sendMessage("AUTHENTICATE", "PLAIN");
+      } else if (aMessage.cap.subcommand == "NAK") {
+        this.removeCAP("sasl");
       }
 
       return true;
     },
   },
 };
--- a/chat/protocols/irc/ircServerTime.jsm
+++ b/chat/protocols/irc/ircServerTime.jsm
@@ -20,36 +20,65 @@ function handleServerTimeTag(aMsg) {
     aMsg.message.time = Math.floor(Date.parse(time) / 1000);
     aMsg.message.delayed = true;
   }
 }
 
 var tagServerTime = {
   name: "server-time Tags",
   priority: ircHandlers.DEFAULT_PRIORITY,
-  isEnabled: () => true,
+  isEnabled() {
+    return (
+      this._activeCAPs.has("server-time") ||
+      this._activeCAPs.has("znc.in/server-time-iso")
+    );
+  },
 
   commands: {
     time: handleServerTimeTag,
     "znc.in/server-time-iso": handleServerTimeTag,
   },
 };
 
 var capServerTime = {
   name: "server-time CAP",
   priority: ircHandlers.DEFAULT_PRIORITY,
   isEnabled: () => true,
 
   commands: {
     "server-time": function(aMessage) {
-      if (aMessage.cap.subcommand == "LS") {
+      if (
+        aMessage.cap.subcommand === "LS" ||
+        aMessage.cap.subcommand === "NEW"
+      ) {
+        this.addCAP("server-time");
         this.sendMessage("CAP", ["REQ", "server-time"]);
+      } else if (
+        aMessage.cap.subcommand == "ACK" ||
+        aMessage.cap.subcommand === "NAK"
+      ) {
+        this.removeCAP("server-time");
+      } else {
+        return false;
       }
       return true;
     },
     "znc.in/server-time-iso": function(aMessage) {
-      if (aMessage.cap.subcommand == "LS") {
+      // Only request legacy server time CAP if the standard one is not available.
+      if (
+        (aMessage.cap.subcommand === "LS" ||
+          aMessage.cap.subcommand === "NEW") &&
+        !this._availableCAPs.has("server-time")
+      ) {
+        this.addCAP("znc.in/server-time-iso");
         this.sendMessage("CAP", ["REQ", "znc.in/server-time-iso"]);
+      } else if (
+        aMessage.cap.subcommand == "ACK" ||
+        aMessage.cap.subcommand === "NAK"
+      ) {
+        this.removeCAP("znc.in/server-time-iso");
+      } else {
+        return false;
       }
       return true;
     },
   },
 };
--- a/chat/protocols/irc/test/test_ircCAP.js
+++ b/chat/protocols/irc/test/test_ircCAP.js
@@ -89,47 +89,133 @@ var testData = [
       {
         subcommand: "LS",
         parameter: "ack",
         modifier: "~",
         ack: true,
       },
     ],
   ],
+
+  // IRC v3.2 multi-line LS response
+  [
+    ["*", "LS", "*", "sasl"],
+    ["*", "LS", "server-time"],
+    [
+      {
+        subcommand: "LS",
+        parameter: "sasl",
+      },
+      {
+        subcommand: "LS",
+        parameter: "server-time",
+      },
+    ],
+  ],
+
+  // IRC v3.2 multi-line LIST response
+  [
+    ["*", "LIST", "*", "sasl"],
+    ["*", "LIST", "server-time"],
+    [
+      {
+        subcommand: "LIST",
+        parameter: "sasl",
+      },
+      {
+        subcommand: "LIST",
+        parameter: "server-time",
+      },
+    ],
+  ],
+
+  // IRC v3.2 cap value
+  [
+    ["*", "LS", "multi-prefix sasl=EXTERNAL sts=port=6697"],
+    [
+      {
+        subcommand: "LS",
+        parameter: "multi-prefix",
+      },
+      {
+        subcommand: "LS",
+        parameter: "sasl",
+        value: "EXTERNAL",
+      },
+      {
+        subcommand: "LS",
+        parameter: "sts",
+        value: "port=6697",
+      },
+    ],
+  ],
+
+  // cap-notify new cap
+  [
+    ["*", "NEW", "batch"],
+    [
+      {
+        subcommand: "NEW",
+        parameter: "batch",
+      },
+    ],
+  ],
+
+  // cap-notify delete cap
+  [
+    ["*", "DEL", "multi-prefix"],
+    [
+      {
+        subcommand: "DEL",
+        parameter: "multi-prefix",
+      },
+    ],
+  ],
 ];
 
 function run_test() {
   add_test(testCapMessages);
 
   run_next_test();
 }
 
 /*
  * Test round tripping parsing and then rebuilding the messages from RFC 2812.
  */
 function testCapMessages() {
   for (let data of testData) {
     // Generate an ircMessage to send into capMessage.
-    let message = {
-      params: data[0],
+    let i = 0;
+    let message;
+    let outputs;
+    const account = {
+      _queuedCAPs: [],
     };
 
-    // Create the CAP message.
-    let outputs = cap.capMessage(message);
+    // Generate an ircMessage to send into capMessage.
+    while (typeof data[i][0] == "string") {
+      message = {
+        params: data[i],
+      };
+
+      // Create the CAP message.
+      outputs = cap.capMessage(message, account);
+      ++i;
+    }
 
     // The original message should get a cap object added with the subcommand
     // set.
     ok(message.cap);
-    equal(message.cap.subcommand, data[1][0].subcommand);
+    equal(message.cap.subcommand, data[i][0].subcommand);
 
     // We only care about the "cap" part of each return message.
     outputs = outputs.map(o => o.cap);
 
     // Ensure the expected output is an array.
-    let expectedCaps = data[1];
+    let expectedCaps = data[i];
     if (!Array.isArray(expectedCaps)) {
       expectedCaps = [expectedCaps];
     }
 
     // Add defaults to the expected output.
     for (let expectedCap of expectedCaps) {
       // By default there's no modifier.
       if (!("modifier" in expectedCap)) {