From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- comm/mailnews/test/fakeserver/Auth.jsm | 209 +++ comm/mailnews/test/fakeserver/Binaryd.jsm | 250 +++ comm/mailnews/test/fakeserver/Imapd.jsm | 2544 +++++++++++++++++++++++++++++ comm/mailnews/test/fakeserver/Ldapd.jsm | 665 ++++++++ comm/mailnews/test/fakeserver/Maild.jsm | 566 +++++++ comm/mailnews/test/fakeserver/Nntpd.jsm | 631 +++++++ comm/mailnews/test/fakeserver/Pop3d.jsm | 454 +++++ comm/mailnews/test/fakeserver/Smtpd.jsm | 274 ++++ 8 files changed, 5593 insertions(+) create mode 100644 comm/mailnews/test/fakeserver/Auth.jsm create mode 100644 comm/mailnews/test/fakeserver/Binaryd.jsm create mode 100644 comm/mailnews/test/fakeserver/Imapd.jsm create mode 100644 comm/mailnews/test/fakeserver/Ldapd.jsm create mode 100644 comm/mailnews/test/fakeserver/Maild.jsm create mode 100644 comm/mailnews/test/fakeserver/Nntpd.jsm create mode 100644 comm/mailnews/test/fakeserver/Pop3d.jsm create mode 100644 comm/mailnews/test/fakeserver/Smtpd.jsm (limited to 'comm/mailnews/test/fakeserver') diff --git a/comm/mailnews/test/fakeserver/Auth.jsm b/comm/mailnews/test/fakeserver/Auth.jsm new file mode 100644 index 0000000000..4bd240b509 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Auth.jsm @@ -0,0 +1,209 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * This file implements the authentication mechanisms + * - AUTH LOGIN + * - AUTH PLAIN + * - AUTH CRAM-MD5 + * for all the server implementations, i.e. in a generic way. + * In fact, you could use this to implement a real server in JS :-) . + * + * @author Ben Bucksch + */ + +var EXPORTED_SYMBOLS = ["AuthPLAIN", "AuthLOGIN", "AuthCRAM"]; + +/** + * Implements AUTH PLAIN + * + * @see RFC 4616 + */ +var AuthPLAIN = { + /** + * Takes full PLAIN auth line, and decodes it. + * + * @param line {string} + * @returns {Object { username : value, password : value } } + * @throws {string} error to return to client + */ + decodeLine(line) { + dump("AUTH PLAIN line -" + line + "-\n"); + line = atob(line); // base64 decode + let aap = line.split("\u0000"); // 0-charater is delimiter + if (aap.length != 3) { + throw new Error("Expected three parts"); + } + /* aap is: authorize-id, authenticate-id, password. + Generally, authorize-id = authenticate-id = username. + authorize-id may thus be empty and then defaults to authenticate-id. */ + var result = {}; + var authzid = aap[0]; + result.username = aap[1]; + result.password = aap[2]; + dump( + "authorize-id: -" + + authzid + + "-, username: -" + + result.username + + "-, password: -" + + result.password + + "-\n" + ); + if (authzid && authzid != result.username) { + throw new Error( + "Expecting a authorize-id that's either the same as authenticate-id or empty" + ); + } + return result; + }, + + /** + * Create an AUTH PLAIN line, to allow a client to authenticate to a server. + * Useful for tests. + */ + encodeLine(username, password) { + username = username.substring(0, 255); + password = password.substring(0, 255); + return btoa("\u0000" + username + "\u0000" + password); // base64 encode + }, +}; + +var AuthLOGIN = { + /** + * Takes full LOGIN auth line, and decodes it. + * It may contain either username or password, + * depending on state/step (first username, then pw). + * + * @param line {string} + * @returns {string} username or password + * @throws {string} error to return to client + */ + decodeLine(line) { + dump("AUTH LOGIN -" + atob(line) + "-\n"); + return atob(line); // base64 decode + }, +}; + +/** + * Implements AUTH CRAM-MD5 + * + * @see RFC 2195, RFC 2104 + */ +var AuthCRAM = { + /** + * First part of CRAM exchange is that the server sends + * a challenge to the client. The client response depends on + * the challenge. (This prevents replay attacks, I think.) + * This function generates the challenge. + * + * You need to store it, you'll need it to check the client response. + * + * @param domain {string} - Your hostname or domain, + * e.g. "example.com", "mx.example.com" or just "localhost". + * @returns {string} The challenge. + * It's already base64-encoded. Send it as-is to the client. + */ + createChallenge(domain) { + var timestamp = new Date().getTime(); // unixtime + var challenge = "<" + timestamp + "@" + domain + ">"; + dump("CRAM challenge unencoded: " + challenge + "\n"); + return btoa(challenge); + }, + /** + * Takes full CRAM-MD5 auth line, and decodes it. + * + * Compare the returned |digest| to the result of + * encodeCRAMMD5(). If they match, the |username| + * returned here is authenticated. + * + * @param line {string} + * @returns {Object { username : value, digest : value } } + * @throws {string} error to return to client + */ + decodeLine(line) { + dump("AUTH CRAM-MD5 line -" + line + "-\n"); + line = atob(line); + dump("base64 decoded -" + line + "-\n"); + var sp = line.split(" "); + if (sp.length != 2) { + throw new Error("Expected one space"); + } + var result = {}; + result.username = sp[0]; + result.digest = sp[1]; + return result; + }, + /** + * @param text {string} - server challenge (base64-encoded) + * @param key {string} - user's password + * @returns {string} digest as hex string + */ + encodeCRAMMD5(text, key) { + text = atob(text); // createChallenge() returns it already encoded + dump("encodeCRAMMD5(text: -" + text + "-, key: -" + key + "-)\n"); + const kInputLen = 64; + // const kHashLen = 16; + const kInnerPad = 0x36; // per spec + const kOuterPad = 0x5c; + + key = this.textToNumberArray(key); + text = this.textToNumberArray(text); + // Make sure key is exactly kDigestLen bytes long. Algo per spec. + if (key.length > kInputLen) { + // (results in kHashLen) + key = this.md5(key); + } + while (key.length < kInputLen) { + // Fill up with zeros. + key.push(0); + } + + // MD5((key XOR outerpad) + MD5((key XOR innerpad) + text)) , per spec + var digest = this.md5( + this.xor(key, kOuterPad).concat( + this.md5(this.xor(key, kInnerPad).concat(text)) + ) + ); + return this.arrayToHexString(digest); + }, + // Utils + xor(binary, value) { + var result = []; + for (var i = 0; i < binary.length; i++) { + result.push(binary[i] ^ value); + } + return result; + }, + md5(binary) { + var md5 = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + md5.init(Ci.nsICryptoHash.MD5); + md5.update(binary, binary.length); + return this.textToNumberArray(md5.finish(false)); + }, + textToNumberArray(text) { + var array = []; + for (var i = 0; i < text.length; i++) { + // Convert string (only lower byte) to array. + array.push(text.charCodeAt(i) & 0xff); + } + return array; + }, + arrayToHexString(binary) { + var result = ""; + for (var i = 0; i < binary.length; i++) { + if (binary[i] > 255) { + throw new Error("unexpected that value > 255"); + } + let hex = binary[i].toString(16); + if (hex.length < 2) { + hex = "0" + hex; + } + result += hex; + } + return result; + }, +}; diff --git a/comm/mailnews/test/fakeserver/Binaryd.jsm b/comm/mailnews/test/fakeserver/Binaryd.jsm new file mode 100644 index 0000000000..7502ef1e55 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Binaryd.jsm @@ -0,0 +1,250 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["BinaryServer"]; + +const CC = Components.Constructor; + +const ServerSocket = CC( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init" +); +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const BinaryOutputStream = CC( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" +); + +/** + * A binary stream-based server. + * Listens on a socket, and whenever a new connection is made it runs + * a user-supplied handler function. + * + * Example: + * A trivial echo server (with a null daemon, so no state shared between + * connections): + * + * let echoServer = new BinaryServer(function(conn, daemon) { + * while(1) { + * let data = conn.read(1); + * conn.write(data); + * } + * }, null); + * + */ + +class BinaryServer { + /** + * The handler function should be of the form: + * async function handlerFn(conn, daemon) + * + * @async + * @callback handlerFn + * @param {Connection} conn + * @param {object} daemon + * + * The handler function runs as long as it wants - reading and writing bytes + * (via methods on conn) until it is finished with the connection. + * The handler simply returns to indicate the connection is done, or throws + * an exception to indicate that something went wrong. + * The daemon is the object which holds the server data/state, shared with + * all connection handler. The BinaryServer doesn't do anything with daemon + * other than passing it directly on to the handler function. + */ + + /** + * Construct a new BinaryServer. + * + * @param {handlerFn} handlerFn - Function to call to handle each new connection. + * @param {object} daemon - Object to pass on to the handler, to share state + * and functionality between across connections. + */ + constructor(handlerFn, daemon) { + this._port = -1; + this._handlerFn = handlerFn; + this._daemon = daemon; + this._listener = null; // Listening socket to accept new connections. + this._connections = new Set(); + } + + /** + * Starts the server running. + * + * @param {number} port - The port to run on (or -1 to pick one automatically). + */ + async start(port = -1) { + if (this._listener) { + throw Components.Exception( + "Server already started", + Cr.NS_ERROR_ALREADY_INITIALIZED + ); + } + + let socket = new ServerSocket( + port, + true, // Loopback only. + -1 // Default max pending connections. + ); + + let server = this; + + socket.asyncListen({ + async onSocketAccepted(socket, transport) { + let conn = new Connection(transport); + server._connections.add(conn); + try { + await server._handlerFn(conn, server._daemon); + // If we get here, handler completed, without error. + } catch (e) { + if (conn.isClosed()) { + // if we get here, assume the error occurred because we're + // shutting down, and ignore it. + } else { + // if we get here, something went wrong. + dump("ERROR " + e.toString()); + } + } + conn.close(); + server._connections.delete(conn); + }, + onStopListening(socket, status) { + // Server is stopping, time to close any outstanding connections. + server._connections.forEach(conn => conn.close()); + server._connections.clear(); + }, + QueryInterface: ChromeUtils.generateQI(["nsIServerSocketListener"]), + }); + // We're running! + this._listener = socket; + } + + /** + * Provides port, a read-only attribute to get which port the server + * server is listening upon. Behaviour is undefined if server is not + * running. + */ + get port() { + return this._listener.port; + } + + /** + * Stops the server, if it is running. + */ + stop() { + if (!this._listener) { + // Already stopped. + return; + } + this._listener.close(); + this._listener = null; + // We could still be accepting new connections at this point, + // so we wait until the onStopListening callback to tear down the + // connections. + } +} + +/** + * Connection wraps a nsITransport with read/write functions that are + * javascript async, to simplify writing server handers. + * Handlers should only need to use read() and write() from here, leaving + * all connection management up to the BinaryServer. + */ +class Connection { + constructor(transport) { + this._transport = transport; + this._input = transport.openInputStream(0, 0, 0); + let outStream = transport.openOutputStream(0, 0, 0); + this._output = new BinaryOutputStream(outStream); + } + + /** + * @returns true if close() has been called. + */ + isClosed() { + return this._transport === null; + } + + /** + * Closes the connection. Can be safely called multiple times. + * The BinaryServer will call this - handlers don't need to worry about + * the connection status. + */ + close() { + if (this.isClosed()) { + return; + } + this._input.close(); + this._output.close(); + this._transport.close(Cr.NS_OK); + this._input = null; + this._output = null; + this._transport = null; + } + + /** + * Read exactly nBytes from the connection. + * + * @param {number} nBytes - The number of bytes required. + * @returns {Array.} - An array containing the requested bytes. + */ + async read(nBytes) { + let conn = this; + let buf = []; + while (buf.length < nBytes) { + let want = nBytes - buf.length; + // A slightly odd-looking construct to wrap the listener-based + // asyncwait() into a javascript async function. + await new Promise((resolve, reject) => { + try { + conn._input.asyncWait( + { + onInputStreamReady(stream) { + // how many bytes are actually available? + let n; + try { + n = stream.available(); + } catch (e) { + // stream was closed. + reject(e); + } + if (n > want) { + n = want; + } + let chunk = new BinaryInputStream(stream).readByteArray(n); + Array.prototype.push.apply(buf, chunk); + resolve(); + }, + }, + 0, + want, + Services.tm.mainThread + ); + } catch (e) { + // asyncwait() failed + reject(e); + } + }); + } + return buf; + } + + /** + * Write data to the connection. + * + * @param {Array.} data - The bytes to send. + */ + async write(data) { + // TODO: need to check outputstream for writeability here??? + // Might be an issue if we start throwing bigger chunks of data about... + await this._output.writeByteArray(data); + } +} diff --git a/comm/mailnews/test/fakeserver/Imapd.jsm b/comm/mailnews/test/fakeserver/Imapd.jsm new file mode 100644 index 0000000000..6023bf7b90 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Imapd.jsm @@ -0,0 +1,2544 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +// This file implements test IMAP servers + +var EXPORTED_SYMBOLS = [ + "ImapDaemon", + "ImapMailbox", + "ImapMessage", + "IMAP_RFC3501_handler", + "configurations", + "mixinExtension", + "IMAP_GMAIL_extension", + "IMAP_MOVE_extension", + "IMAP_CUSTOM_extension", + "IMAP_RFC2197_extension", + "IMAP_RFC2342_extension", + "IMAP_RFC3348_extension", + "IMAP_RFC4315_extension", + "IMAP_RFC5258_extension", + "IMAP_RFC2195_extension", +]; + +// IMAP DAEMON ORGANIZATION +// ------------------------ +// The large numbers of RFCs all induce some implicit assumptions as to the +// organization of an IMAP server. Ideally, we'd like to be as inclusive as +// possible so that we can guarantee that it works for every type of server. +// Unfortunately, such all-accepting setups make generic algorithms hard to +// use; given their difficulty in a generic framework, it seems unlikely that +// a server would implement such characteristics. It also seems likely that +// if mailnews had a problem with the implementation, then most clients would +// see similar problems, so as to make the server widely unusable. In any +// case, if someone complains about not working on bugzilla, it can be added +// to the test suite. +// So, with that in mind, this is the basic layout of the daemon: +// DAEMON +// + Namespaces: parentless mailboxes whose names are the namespace name. The +// type of the namespace is specified by the type attribute. +// + Mailboxes: ImapMailbox objects with several properties. If a mailbox +// | | property begins with a '_', then it should not be serialized because +// | | it can be discovered from other means; in particular, a '_' does not +// | | necessarily mean that it is a private property that should not be +// | | accessed. The parent of a top-level mailbox is null, not "". +// | + I18N names: RFC 3501 specifies a modified UTF-7 form for names. +// | | However, a draft RFC makes the names UTF-8; it is expected to be +// | | completed and implemented "soon". Therefore, the correct usage is +// | | to specify the mailbox names as one normally does in JS and the +// | | protocol will take care of conversion itself. +// | + Case-sensitivity: RFC 3501 takes no position on this issue, only that +// | | a case-insensitive server must treat the base-64 parts of mailbox +// | | names as case-sensitive. The draft UTF8 RFC says nothing on this +// | | topic, but Crispin recommends using Unicode case-insensitivity. We +// | | therefore treat names in such manner (if the case-insensitive flag +// | | is set), in technical violation of RFC 3501. +// | + Flags: Flags are (as confirmed by Crispin) case-insensitive. Internal +// | flag equality, though, uses case-sensitive checks. Therefore they +// | should be normalized to a title-case form (e.g., \Noselect). +// + Synchronization: On certain synchronizing commands, the daemon will call +// | a synchronizing function to allow manipulating code the chance to +// | perform various (potentially expensive) actions. +// + Messages: A message is represented internally as an annotated URI. + +const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); +var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import( + "resource://testing-common/mailnews/Auth.jsm" +); + +class ImapDaemon { + constructor(flags, syncFunc) { + this._flags = flags; + + this.namespaces = []; + this.idResponse = "NIL"; + this.root = new ImapMailbox("", null, { type: IMAP_NAMESPACE_PERSONAL }); + this.uidvalidity = Math.round(Date.now() / 1000); + this.inbox = new ImapMailbox("INBOX", null, this.uidvalidity++); + this.root.addMailbox(this.inbox); + this.namespaces.push(this.root); + this.syncFunc = syncFunc; + // This can be used to cause the artificial failure of any given command. + this.commandToFail = ""; + // This can be used to simulate timeouts on large copies + this.copySleep = 0; + } + synchronize(mailbox, update) { + if (this.syncFunc) { + this.syncFunc.call(null, this); + } + if (update) { + for (var message of mailbox._messages) { + message.recent = false; + } + } + } + getNamespace(name) { + for (var namespace of this.namespaces) { + if ( + name.indexOf(namespace.name) == 0 && + name[namespace.name.length] == namespace.delimiter + ) { + return namespace; + } + } + return this.root; + } + createNamespace(name, type) { + var newbox = this.createMailbox(name, { type }); + this.namespaces.push(newbox); + } + getMailbox(name) { + if (name == "") { + return this.root; + } + // INBOX is case-insensitive, no matter what + if (name.toUpperCase().startsWith("INBOX")) { + name = "INBOX" + name.substr(5); + } + // We want to find a child who has the same name, but we don't quite know + // what the delimiter is. The convention is that different namespaces use a + // name starting with '#', so that's how we'll work it out. + let mailbox; + if (name.startsWith("#")) { + for (mailbox of this.root._children) { + if ( + mailbox.name.indexOf(name) == 0 && + name[mailbox.name.length] == mailbox.delimiter + ) { + break; + } + } + if (!mailbox) { + return null; + } + + // Now we continue like normal + let names = name.split(mailbox.delimiter); + names.splice(0, 1); + for (let part of names) { + mailbox = mailbox.getChild(part); + if (!mailbox || mailbox.nonExistent) { + return null; + } + } + } else { + // This is easy, just split it up using the inbox's delimiter + let names = name.split(this.inbox.delimiter); + mailbox = this.root; + + for (let part of names) { + mailbox = mailbox.getChild(part); + if (!mailbox || mailbox.nonExistent) { + return null; + } + } + } + return mailbox; + } + createMailbox(name, oldBox) { + var namespace = this.getNamespace(name); + if (namespace.name != "") { + name = name.substring(namespace.name.length + 1); + } + var prefixes = name.split(namespace.delimiter); + var subName; + if (prefixes[prefixes.length - 1] == "") { + subName = prefixes.splice(prefixes.length - 2, 2)[0]; + } else { + subName = prefixes.splice(prefixes.length - 1, 1)[0]; + } + var box = namespace; + for (var component of prefixes) { + box = box.getChild(component); + // Yes, we won't autocreate intermediary boxes + if (box == null || box.flags.includes("\\NoInferiors")) { + return false; + } + } + // If this is an ImapMailbox... + if (oldBox && oldBox._children) { + // Only delete now so we don't screw ourselves up if creation fails + this.deleteMailbox(oldBox); + oldBox._parent = box == this.root ? null : box; + let newBox = new ImapMailbox(subName, box, this.uidvalidity++); + newBox._messages = oldBox._messages; + box.addMailbox(newBox); + + // And if oldBox is an INBOX, we need to recreate that + if (oldBox.name == "INBOX") { + this.inbox = new ImapMailbox("INBOX", null, this.uidvalidity++); + this.root.addMailbox(this.inbox); + } + oldBox.name = subName; + } else if (oldBox) { + // oldBox is a regular {} object, so it contains mailbox data but is not + // a mailbox itself. Pass it into the constructor and let that deal with + // it... + let childBox = new ImapMailbox( + subName, + box == this.root ? null : box, + oldBox + ); + box.addMailbox(childBox); + // And return the new mailbox, since this is being used by people setting + // up the daemon. + return childBox; + } else { + var creatable = hasFlag(this._flags, IMAP_FLAG_NEEDS_DELIMITER) + ? name[name.length - 1] == namespace.delimiter + : true; + let childBox = new ImapMailbox(subName, box == this.root ? null : box, { + flags: creatable ? [] : ["\\NoInferiors"], + uidvalidity: this.uidvalidity++, + }); + box.addMailbox(childBox); + } + return true; + } + deleteMailbox(mailbox) { + if (mailbox._children.length == 0) { + // We don't preserve the subscribed state for deleted mailboxes + var parentBox = mailbox._parent == null ? this.root : mailbox._parent; + parentBox._children.splice(parentBox._children.indexOf(mailbox), 1); + } else { + // clear mailbox + mailbox._messages = []; + mailbox.flags.push("\\Noselect"); + } + } +} + +class ImapMailbox { + constructor(name, parent, state) { + this.name = name; + this._parent = parent; + this._children = []; + this._messages = []; + this._updates = []; + + // Shorthand for uidvalidity + if (typeof state == "number") { + this.uidvalidity = state; + state = {}; + } + + if (!state) { + state = {}; + } + + for (var prop in state) { + this[prop] = state[prop]; + } + + this.setDefault("subscribed", false); + this.setDefault("nonExistent", false); + this.setDefault("delimiter", "/"); + this.setDefault("flags", []); + this.setDefault("specialUseFlag", ""); + this.setDefault("uidnext", 1); + this.setDefault("msgflags", [ + "\\Seen", + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + ]); + this.setDefault("permflags", [ + "\\Seen", + "\\Answered", + "\\Flagged", + "\\Deleted", + "\\Draft", + "\\*", + ]); + } + setDefault(prop, def) { + this[prop] = prop in this ? this[prop] : def; + } + addMailbox(mailbox) { + this._children.push(mailbox); + } + getChild(name) { + for (var mailbox of this._children) { + if (name == mailbox.name) { + return mailbox; + } + } + return null; + } + matchKids(pattern) { + if (pattern == "") { + return this._parent ? this._parent.matchKids("") : [this]; + } + + var portions = pattern.split(this.delimiter); + var matching = [this]; + for (var folder of portions) { + if (folder.length == 0) { + continue; + } + + let generator = folder.includes("*") ? "allChildren" : "_children"; + let possible = matching.reduce(function (arr, elem) { + return arr.concat(elem[generator]); + }, []); + + if (folder == "*" || folder == "%") { + matching = possible; + continue; + } + + let parts = folder.split(/[*%]/).filter(function (str) { + return str.length > 0; + }); + matching = possible.filter(function (mailbox) { + let index = 0, + name = mailbox.fullName; + for (var part of parts) { + index = name.indexOf(part, index); + if (index == -1) { + return false; + } + } + return true; + }); + } + return matching; + } + get fullName() { + return ( + (this._parent ? this._parent.fullName + this.delimiter : "") + this.name + ); + } + get displayName() { + let manager = Cc["@mozilla.org/charset-converter-manager;1"].getService( + Ci.nsICharsetConverterManager + ); + // Escape backslash and double-quote with another backslash before encoding. + return manager.unicodeToMutf7(this.fullName.replace(/([\\"])/g, "\\$1")); + } + get allChildren() { + return this._children.reduce(function (arr, elem) { + return arr.concat(elem._allChildrenInternal); + }, []); + } + get _allChildrenInternal() { + return this._children.reduce( + function (arr, elem) { + return arr.concat(elem._allChildrenInternal); + }, + [this] + ); + } + addMessage(message) { + this._messages.push(message); + if (message.uid >= this.uidnext) { + this.uidnext = message.uid + 1; + } + if (!this._updates.includes("EXISTS")) { + this._updates.push("EXISTS"); + } + if ("__highestuid" in this && message.uid > this.__highestuid) { + this.__highestuid = message.uid; + } + } + get _highestuid() { + if ("__highestuid" in this) { + return this.__highestuid; + } + var highest = 0; + for (var message of this._messages) { + if (message.uid > highest) { + highest = message.uid; + } + } + this.__highestuid = highest; + return highest; + } + expunge() { + var response = ""; + for (var i = 0; i < this._messages.length; i++) { + if (this._messages[i].flags.includes("\\Deleted")) { + response += "* " + (i + 1) + " EXPUNGE\0"; + this._messages.splice(i--, 1); + } + } + if (response.length > 0) { + delete this.__highestuid; + } + return response; + } +} + +class ImapMessage { + constructor(URI, uid, flags) { + this._URI = URI; + this.uid = uid; + this.size = 0; + this.flags = []; + for (let flag in flags) { + this.flags.push(flag); + } + this.recent = false; + } + get channel() { + return Services.io.newChannel( + this._URI, + null, + null, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + } + setFlag(flag) { + if (!this.flags.includes(flag)) { + this.flags.push(flag); + } + } + // This allows us to simulate servers that approximate the rfc822 size. + setSize(size) { + this.size = size; + } + clearFlag(flag) { + let index = this.flags.indexOf(flag); + if (index != -1) { + this.flags.splice(index, 1); + } + } + getText(start, length) { + if (!start) { + start = 0; + } + if (!length) { + length = -1; + } + var channel = this.channel; + var istream = channel.open(); + var bstream = Cc["@mozilla.org/binaryinputstream;1"].createInstance( + Ci.nsIBinaryInputStream + ); + bstream.setInputStream(istream); + var str = bstream.readBytes(start); + if (str.length != start) { + throw new Error("Erm, we didn't just pass through 8-bit"); + } + length = length == -1 ? istream.available() : length; + if (length > istream.available()) { + length = istream.available(); + } + str = bstream.readBytes(length); + return str; + } + + get _partMap() { + if (this.__partMap) { + return this.__partMap; + } + var partMap = {}; + var emitter = { + startPart(partNum, headers) { + var imapPartNum = partNum.replace("$", ""); + // If there are multiple imap parts that this represents, we'll + // overwrite with the latest. This is what we want (most deeply nested). + partMap[imapPartNum] = [partNum, headers]; + }, + }; + MimeParser.parseSync(this.getText(), emitter, { + bodyformat: "none", + stripcontinuations: false, + }); + return (this.__partMap = partMap); + } + getPartHeaders(partNum) { + return this._partMap[partNum][1]; + } + getPartBody(partNum) { + var body = ""; + var emitter = { + deliverPartData(partNum, data) { + body += data; + }, + }; + var mimePartNum = this._partMap[partNum][0]; + MimeParser.parseSync(this.getText(), emitter, { + pruneat: mimePartNum, + bodyformat: "raw", + }); + return body; + } +} + +// IMAP FLAGS +// If you don't specify any flag, no flags are set. + +/** + * This flag represents whether or not CREATE hierarchies need a delimiter. + * + * If this flag is off, CREATE a
CREATE a/b
fails where + * CREATE a/
CREATE a/b
would succeed (assuming the delimiter is + * '/'). + */ +var IMAP_FLAG_NEEDS_DELIMITER = 2; + +function hasFlag(flags, flag) { + return (flags & flag) == flag; +} + +// IMAP Namespaces +var IMAP_NAMESPACE_PERSONAL = 0; +// var IMAP_NAMESPACE_OTHER_USERS = 1; +// var IMAP_NAMESPACE_SHARED = 2; + +// IMAP server helpers +var IMAP_STATE_NOT_AUTHED = 0; +var IMAP_STATE_AUTHED = 1; +var IMAP_STATE_SELECTED = 2; + +function parseCommand(text, partial) { + var args = []; + var current = args; + var stack = []; + if (partial) { + args = partial.args; + current = partial.current; + stack = partial.stack; + current.push(partial.text); + } + var atom = ""; + while (text.length > 0) { + let c = text[0]; + + if (c == '"') { + let index = 1; + let s = ""; + while (index < text.length && text[index] != '"') { + if (text[index] == "\\") { + index++; + if (text[index] != '"' && text[index] != "\\") { + throw new Error("Expected quoted character"); + } + } + s += text[index++]; + } + if (index == text.length) { + throw new Error("Expected DQUOTE"); + } + current.push(s); + text = text.substring(index + 1); + continue; + } else if (c == "{") { + let end = text.indexOf("}"); + if (end == -1) { + throw new Error("Expected CLOSE_BRACKET"); + } + if (end + 1 != text.length) { + throw new Error("Expected CRLF"); + } + let length = parseInt(text.substring(1, end)); + // Usable state + // eslint-disable-next-line no-throw-literal + throw { length, current, args, stack, text: "" }; + } else if (c == "(") { + stack.push(current); + current = []; + } else if (c == ")") { + if (atom.length > 0) { + current.push(atom); + atom = ""; + } + let hold = current; + current = stack.pop(); + if (current == undefined) { + throw new Error("Unexpected CLOSE_PAREN"); + } + current.push(hold); + } else if (c == " ") { + if (atom.length > 0) { + current.push(atom); + atom = ""; + } + } else if ( + text.toUpperCase().startsWith("NIL") && + (text.length == 3 || text[3] == " ") + ) { + current.push(null); + text = text.substring(4); + continue; + } else { + atom += c; + } + text = text.substring(1); + } + if (stack.length != 0) { + throw new Error("Expected CLOSE_PAREN!"); + } + if (atom.length > 0) { + args.push(atom); + } + return args; +} + +function formatArg(argument, spec) { + // Get NILs out of the way quickly + var nilAccepted = false; + if (spec.startsWith("n") && spec[1] != "u") { + spec = spec.substring(1); + nilAccepted = true; + } + if (argument == null) { + if (!nilAccepted) { + throw new Error("Unexpected NIL!"); + } + + return null; + } + + // array! + if (spec.startsWith("(")) { + // typeof array is object. Don't ask me why. + if (!Array.isArray(argument)) { + throw new Error("Expected list!"); + } + // Strip the '(' and ')'... + spec = spec.substring(1, spec.length - 1); + // ... and apply to the rest + return argument.map(function (item) { + return formatArg(item, spec); + }); + } + + // or! + var pipe = spec.indexOf("|"); + if (pipe > 0) { + var first = spec.substring(0, pipe); + try { + return formatArg(argument, first); + } catch (e) { + return formatArg(argument, spec.substring(pipe + 1)); + } + } + + // By now, we know that the input should be generated from an atom or string. + if (typeof argument != "string") { + throw new Error("Expected argument of type " + spec + "!"); + } + + if (spec == "atom") { + argument = argument.toUpperCase(); + } else if (spec == "mailbox") { + let manager = Cc["@mozilla.org/charset-converter-manager;1"].getService( + Ci.nsICharsetConverterManager + ); + argument = manager.mutf7ToUnicode(argument); + } else if (spec == "string") { + // Do nothing + } else if (spec == "flag") { + argument = argument.toLowerCase(); + if ( + !("a" <= argument[0] && argument[0] <= "z") && + !("A" <= argument[0] && argument[0] <= "Z") + ) { + argument = argument[0] + argument[1].toUpperCase() + argument.substr(2); + } else { + argument = argument[0].toUpperCase() + argument.substr(1); + } + } else if (spec == "number") { + if (argument == parseInt(argument)) { + argument = parseInt(argument); + } + } else if (spec == "date") { + if ( + !/^\d{1,2}-[A-Z][a-z]{2}-\d{4}( \d{2}(:\d{2}){2} [+-]\d{4})?$/.test( + argument + ) + ) { + throw new Error("Expected date!"); + } + argument = new Date(Date.parse(argument.replace(/-(?!\d{4}$)/g, " "))); + } else { + throw new Error("Unknown spec " + spec); + } + + return argument; +} + +// IMAP TEST SERVERS +// ----------------- +// Because of IMAP and the LEMONADE RFCs, we have a myriad of different +// server configurations that we should ideally be supporting. We handle them +// by defining a core RFC 3501 implementation and then have different server +// extensions subclass the server through functions below. However, we also +// provide standard configurations for best handling. +// Configurations: +// * Barebones RFC 3501 +// * Cyrus +// * UW IMAP +// * Courier +// * Exchange +// * Dovecot +// * Zimbra +// * GMail +// KNOWN DEVIATIONS FROM RFC 3501: +// + The autologout timer is 3 minutes, not 30 minutes. A test with a logout +// of 30 minutes would take a very long time if it failed. +// + SEARCH (except for UNDELETED) and STARTTLS are not supported, +// nor is all of FETCH. +// + Concurrent mailbox access is probably compliant with a rather liberal +// implementation of RFC 3501, although probably not what one would expect, +// and certainly not what the Dovecot IMAP server tests expect. + +/* IMAP Fakeserver operates in a different manner than the rest of fakeserver + * because of some differences in the protocol. Commands are dispatched through + * onError, which parses the message into components. Like other fakeserver + * implementations, the command property will be called, but this time with an + * argument that is an array of data items instead of a string representing the + * rest of the line. + */ +class IMAP_RFC3501_handler { + constructor(daemon) { + this.kUsername = "user"; + this.kPassword = "password"; + this.kAuthSchemes = []; // Added by RFC2195 extension. Test may modify as needed. + this.kCapabilities = [ + /* "LOGINDISABLED", "STARTTLS", */ + "CLIENTID", + ]; // Test may modify as needed. + this.kUidCommands = ["FETCH", "STORE", "SEARCH", "COPY"]; + + this._daemon = daemon; + this.closing = false; + this.dropOnStartTLS = false; + // map: property = auth scheme {String}, value = start function on this obj + this._kAuthSchemeStartFunction = {}; + + this._enabledCommands = { + // IMAP_STATE_NOT_AUTHED + 0: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "STARTTLS", + "CLIENTID", + "AUTHENTICATE", + "LOGIN", + ], + // IMAP_STATE_AUTHED + 1: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "SELECT", + "EXAMINE", + "CREATE", + "DELETE", + "RENAME", + "SUBSCRIBE", + "UNSUBSCRIBE", + "LIST", + "LSUB", + "STATUS", + "APPEND", + ], + // IMAP_STATE_SELECTED + 2: [ + "CAPABILITY", + "NOOP", + "LOGOUT", + "SELECT", + "EXAMINE", + "CREATE", + "DELETE", + "RENAME", + "SUBSCRIBE", + "UNSUBSCRIBE", + "LIST", + "LSUB", + "STATUS", + "APPEND", + "CHECK", + "CLOSE", + "EXPUNGE", + "SEARCH", + "FETCH", + "STORE", + "COPY", + "UID", + ], + }; + // Format explanation: + // atom -> UPPERCASE + // string -> don't touch! + // mailbox -> Apply ->UTF16 transformation with case-insensitivity stuff + // flag -> Titlecase (or \Titlecase, $Titlecase, etc.) + // date -> Make it a JSDate object + // number -> Make it a number, if possible + // ( ) -> list, apply flags as specified + // [ ] -> optional argument. + // x|y -> either x or y format. + // ... -> variable args, don't parse + this._argFormat = { + CAPABILITY: [], + NOOP: [], + LOGOUT: [], + STARTTLS: [], + CLIENTID: ["string", "string"], + AUTHENTICATE: ["atom", "..."], + LOGIN: ["string", "string"], + SELECT: ["mailbox"], + EXAMINE: ["mailbox"], + CREATE: ["mailbox"], + DELETE: ["mailbox"], + RENAME: ["mailbox", "mailbox"], + SUBSCRIBE: ["mailbox"], + UNSUBSCRIBE: ["mailbox"], + LIST: ["mailbox", "mailbox"], + LSUB: ["mailbox", "mailbox"], + STATUS: ["mailbox", "(atom)"], + APPEND: ["mailbox", "[(flag)]", "[date]", "string"], + CHECK: [], + CLOSE: [], + EXPUNGE: [], + SEARCH: ["atom", "..."], + FETCH: ["number", "atom|(atom|(atom))"], + STORE: ["number", "atom", "flag|(flag)"], + COPY: ["number", "mailbox"], + UID: ["atom", "..."], + }; + + this.resetTest(); + } + resetTest() { + this._state = IMAP_STATE_NOT_AUTHED; + this._multiline = false; + this._nextAuthFunction = undefined; // should be in RFC2195_ext, but too lazy + } + onStartup() { + this._state = IMAP_STATE_NOT_AUTHED; + return "* OK IMAP4rev1 Fakeserver started up"; + } + + // CENTRALIZED DISPATCH FUNCTIONS + + // IMAP sends commands in the form of "tag command args", but fakeserver + // parsing tries to call the tag, which doesn't exist. Instead, we use this + // error method to do the actual command dispatch. Mailnews uses numbers for + // tags, which won't impede on actual commands. + onError(tag, realLine) { + this._tag = tag; + var space = realLine.indexOf(" "); + var command = space == -1 ? realLine : realLine.substring(0, space); + realLine = space == -1 ? "" : realLine.substring(space + 1); + + // Now parse realLine into an array of atoms, etc. + try { + var args = parseCommand(realLine); + } catch (state) { + if (typeof state == "object") { + this._partial = state; + this._partial.command = command; + this._multiline = true; + return "+ More!"; + } + + return this._tag + " BAD " + state; + } + + // If we're here, we have a command with arguments. Dispatch! + return this._dispatchCommand(command, args); + } + onMultiline(line) { + // A multiline arising form a literal being passed + if (this._partial) { + // There are two cases to be concerned with: + // 1. The CRLF is internal or end (we want more) + // 1a. The next line is the actual command stuff! + // 2. The CRLF is in the middle (rest of the line is args) + if (this._partial.length >= line.length + 2) { + // Case 1 + this._partial.text += line + "\r\n"; + this._partial.length -= line.length + 2; + return undefined; + } else if (this._partial.length != 0) { + this._partial.text += line.substring(0, this._partial.length); + line = line.substring(this._partial.length); + } + var command = this._partial.command; + var args; + try { + args = parseCommand(line, this._partial); + } catch (state) { + if (typeof state == "object") { + // Yet another literal coming around... + this._partial = state; + this._partial.command = command; + return "+ I'll be needing more text"; + } + + this._multiline = false; + return this.tag + " BAD parse error: " + state; + } + + this._partial = undefined; + this._multiline = false; + return this._dispatchCommand(command, args); + } + + if (this._nextAuthFunction) { + var func = this._nextAuthFunction; + this._multiline = false; + this._nextAuthFunction = undefined; + if (line == "*") { + return this._tag + " BAD Okay, as you wish. Chicken"; + } + if (!func || typeof func != "function") { + return this._tag + " BAD I'm lost. Internal server error during auth"; + } + try { + return this._tag + " " + func.call(this, line); + } catch (e) { + return this._tag + " BAD " + e; + } + } + return undefined; + } + _dispatchCommand(command, args) { + this.sendingLiteral = false; + command = command.toUpperCase(); + if (command == this._daemon.commandToFail.toUpperCase()) { + return this._tag + " NO " + command + " failed"; + } + var response; + if (command in this) { + this._lastCommand = command; + // Are we allowed to execute this command? + if (!this._enabledCommands[this._state].includes(command)) { + return ( + this._tag + " BAD illegal command for current state " + this._state + ); + } + + try { + // Format the arguments nicely + args = this._treatArgs(args, command); + + // UID command by itself is not useful for PerformTest + if (command == "UID") { + this._lastCommand += " " + args[0]; + } + + // Finally, run the thing + response = this[command](args); + } catch (e) { + if (typeof e == "string") { + response = e; + } else { + throw e; + } + } + } else { + response = "BAD " + command + " not implemented"; + } + + // Add status updates + if (this._selectedMailbox) { + for (var update of this._selectedMailbox._updates) { + let line; + switch (update) { + case "EXISTS": + line = "* " + this._selectedMailbox._messages.length + " EXISTS"; + break; + } + response = line + "\0" + response; + } + } + + var lines = response.split("\0"); + response = ""; + for (let line of lines) { + if (!line.startsWith("+") && !line.startsWith("*")) { + response += this._tag + " "; + } + response += line + "\r\n"; + } + return response; + } + _treatArgs(args, command) { + var format = this._argFormat[command]; + var treatedArgs = []; + for (var i = 0; i < format.length; i++) { + var spec = format[i]; + + if (spec == "...") { + treatedArgs = treatedArgs.concat(args); + args = []; + break; + } + + if (args.length == 0) { + if (spec.startsWith("[")) { + // == optional arg + continue; + } else { + throw new Error("BAD not enough arguments"); + } + } + + if (spec.startsWith("[")) { + // We have an optional argument. See if the format matches and move on + // if it doesn't. Ideally, we'd rethink our decision if a later + // application turns out to be wrong, but that's ugly to do + // iteratively. Should any IMAP extension require it, we'll have to + // come back and change this assumption, though. + spec = spec.substr(1, spec.length - 2); + try { + var out = formatArg(args[0], spec); + } catch (e) { + continue; + } + treatedArgs.push(out); + args.shift(); + continue; + } + try { + treatedArgs.push(formatArg(args.shift(), spec)); + } catch (e) { + throw new Error("BAD " + e); + } + } + if (args.length != 0) { + throw new Error("BAD Too many arguments"); + } + return treatedArgs; + } + + // PROTOCOL COMMANDS (ordered as in spec) + + CAPABILITY(args) { + var capa = "* CAPABILITY IMAP4rev1 " + this.kCapabilities.join(" "); + if (this.kAuthSchemes.length > 0) { + capa += " AUTH=" + this.kAuthSchemes.join(" AUTH="); + } + capa += "\0OK CAPABILITY completed"; + return capa; + } + CLIENTID(args) { + return "OK Recognized a valid CLIENTID command, used for authentication methods"; + } + LOGOUT(args) { + this.closing = true; + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_NOT_AUTHED; + return "* BYE IMAP4rev1 Logging out\0OK LOGOUT completed"; + } + NOOP(args) { + return "OK NOOP completed"; + } + STARTTLS(args) { + // simulate annoying server that drops connection on STARTTLS + if (this.dropOnStartTLS) { + this.closing = true; + return ""; + } + return "BAD maild doesn't support TLS ATM"; + } + _nextAuthFunction = undefined; + AUTHENTICATE(args) { + var scheme = args[0]; // already uppercased by type "atom" + // |scheme| contained in |kAuthSchemes|? + if ( + !this.kAuthSchemes.some(function (s) { + return s == scheme; + }) + ) { + return "-ERR AUTH " + scheme + " not supported"; + } + + var func = this._kAuthSchemeStartFunction[scheme]; + if (!func || typeof func != "function") { + return ( + "BAD I just pretended to implement AUTH " + scheme + ", but I don't" + ); + } + return func.apply(this, args.slice(1)); + } + LOGIN(args) { + if ( + this.kCapabilities.some(function (c) { + return c == "LOGINDISABLED"; + }) + ) { + return "BAD old-style LOGIN is disabled, use AUTHENTICATE"; + } + if (args[0] == this.kUsername && args[1] == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK authenticated"; + } + return "BAD invalid password, I won't authenticate you"; + } + SELECT(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox"; + } + + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_SELECTED; + this._selectedMailbox = box; + this._readOnly = false; + + var response = "* FLAGS (" + box.msgflags.join(" ") + ")\0"; + response += "* " + box._messages.length + " EXISTS\0* "; + response += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + response += " RECENT\0"; + for (var i = 0; i < box._messages.length; i++) { + if (!box._messages[i].flags.includes("\\Seen")) { + response += "* OK [UNSEEN " + (i + 1) + "]\0"; + break; + } + } + response += "* OK [PERMANENTFLAGS (" + box.permflags.join(" ") + ")]\0"; + response += "* OK [UIDNEXT " + box.uidnext + "]\0"; + if ("uidvalidity" in box) { + response += "* OK [UIDVALIDITY " + box.uidvalidity + "]\0"; + } + return response + "OK [READ-WRITE] SELECT completed"; + } + EXAMINE(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox"; + } + + if (this._selectedMailbox) { + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + } + this._state = IMAP_STATE_SELECTED; + this._selectedMailbox = box; + this._readOnly = true; + + var response = "* FLAGS (" + box.msgflags.join(" ") + ")\0"; + response += "* " + box._messages.length + " EXISTS\0* "; + response += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + response += " RECENT\0"; + for (var i = 0; i < box._messages.length; i++) { + if (!box._messages[i].flags.includes("\\Seen")) { + response += "* OK [UNSEEN " + (i + 1) + "]\0"; + break; + } + } + response += "* OK [PERMANENTFLAGS (" + box.permflags.join(" ") + ")]\0"; + response += "* OK [UIDNEXT " + box.uidnext + "]\0"; + response += "* OK [UIDVALIDITY " + box.uidvalidity + "]\0"; + return response + "OK [READ-ONLY] EXAMINE completed"; + } + CREATE(args) { + if (this._daemon.getMailbox(args[0])) { + return "NO mailbox already exists"; + } + if (!this._daemon.createMailbox(args[0])) { + return "NO cannot create mailbox"; + } + return "OK CREATE completed"; + } + DELETE(args) { + var mbox = this._daemon.getMailbox(args[0]); + if (!mbox || mbox.name == "") { + return "NO no such mailbox"; + } + if (mbox._children.length > 0) { + for (let i = 0; i < mbox.flags.length; i++) { + if (mbox.flags[i] == "\\Noselect") { + return "NO cannot delete mailbox"; + } + } + } + this._daemon.deleteMailbox(mbox); + return "OK DELETE completed"; + } + RENAME(args) { + var mbox = this._daemon.getMailbox(args[0]); + if (!mbox || mbox.name == "") { + return "NO no such mailbox"; + } + if (!this._daemon.createMailbox(args[1], mbox)) { + return "NO cannot rename mailbox"; + } + return "OK RENAME completed"; + } + SUBSCRIBE(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (!mailbox) { + return "NO error in subscribing"; + } + mailbox.subscribed = true; + return "OK SUBSCRIBE completed"; + } + UNSUBSCRIBE(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (mailbox) { + mailbox.subscribed = false; + } + return "OK UNSUBSCRIBE completed"; + } + LIST(args) { + // even though this is the LIST function for RFC 3501, code for + // LIST-EXTENDED (RFC 5258) is included here to keep things simple and + // avoid duplication. We can get away with this because the _treatArgs + // function filters out invalid args for servers that don't support + // LIST-EXTENDED before they even get here. + + let listFunctionName = "_LIST"; + // check for optional list selection options argument used by LIST-EXTENDED + // and other related RFCs + if (args.length == 3 || (args.length > 3 && args[3] == "RETURN")) { + let selectionOptions = args.shift(); + selectionOptions = selectionOptions.toString().split(" "); + selectionOptions.sort(); + for (let option of selectionOptions) { + listFunctionName += "_" + option.replace(/-/g, "_"); + } + } + // check for optional list return options argument used by LIST-EXTENDED + // and other related RFCs + if ( + (args.length > 2 && args[2] == "RETURN") || + this.kCapabilities.includes("CHILDREN") + ) { + listFunctionName += "_RETURN"; + let returnOptions = args[3] ? args[3].toString().split(" ") : []; + if ( + this.kCapabilities.includes("CHILDREN") && + !returnOptions.includes("CHILDREN") + ) { + returnOptions.push("CHILDREN"); + } + returnOptions.sort(); + for (let option of returnOptions) { + listFunctionName += "_" + option.replace(/-/g, "_"); + } + } + if (!this[listFunctionName]) { + return "BAD unknown LIST request options"; + } + + let base = this._daemon.getMailbox(args[0]); + if (!base) { + return "NO no such mailbox"; + } + let requestedBoxes; + // check for multiple mailbox patterns used by LIST-EXTENDED + // and other related RFCs + if (args[1].startsWith("(")) { + requestedBoxes = parseCommand(args[1])[0]; + } else { + requestedBoxes = [args[1]]; + } + let response = ""; + for (let requestedBox of requestedBoxes) { + let people = base.matchKids(requestedBox); + for (let box of people) { + response += this[listFunctionName](box); + } + } + return response + "OK LIST completed"; + } + // _LIST is the standard LIST command response + _LIST(aBox) { + if (aBox.nonExistent) { + return ""; + } + return ( + "* LIST (" + + aBox.flags.join(" ") + + ') "' + + aBox.delimiter + + '" "' + + aBox.displayName + + '"\0' + ); + } + LSUB(args) { + var base = this._daemon.getMailbox(args[0]); + if (!base) { + return "NO no such mailbox"; + } + var people = base.matchKids(args[1]); + var response = ""; + for (var box of people) { + if (box.subscribed) { + response += + '* LSUB () "' + box.delimiter + '" "' + box.displayName + '"\0'; + } + } + return response + "OK LSUB completed"; + } + STATUS(args) { + var box = this._daemon.getMailbox(args[0]); + if (!box) { + return "NO no such mailbox exists"; + } + for (let i = 0; i < box.flags.length; i++) { + if (box.flags[i] == "\\Noselect") { + return "NO STATUS not allowed on Noselect folder"; + } + } + var parts = []; + for (var status of args[1]) { + var line = status + " "; + switch (status) { + case "MESSAGES": + line += box._messages.length; + break; + case "RECENT": + line += box._messages.reduce(function (count, message) { + return count + (message.recent ? 1 : 0); + }, 0); + break; + case "UIDNEXT": + line += box.uidnext; + break; + case "UIDVALIDITY": + line += box.uidvalidity; + break; + case "UNSEEN": + line += box._messages.reduce(function (count, message) { + return count + (message.flags.includes("\\Seen") ? 0 : 1); + }, 0); + break; + default: + return "BAD unknown status flag: " + status; + } + parts.push(line); + } + return ( + '* STATUS "' + + args[0] + + '" (' + + parts.join(" ") + + ")\0OK STATUS completed" + ); + } + APPEND(args) { + var mailbox = this._daemon.getMailbox(args[0]); + if (!mailbox) { + return "NO [TRYCREATE] no such mailbox"; + } + var flags, date, text; + if (args.length == 3) { + if (args[1] instanceof Date) { + flags = []; + date = args[1]; + } else { + flags = args[1]; + date = Date.now(); + } + text = args[2]; + } else if (args.length == 4) { + flags = args[1]; + date = args[2]; + text = args[3]; + } else { + flags = []; + date = Date.now(); + text = args[1]; + } + var msg = new ImapMessage( + "data:text/plain," + encodeURI(text), + mailbox.uidnext++, + flags + ); + msg.recent = true; + msg.date = date; + mailbox.addMessage(msg); + return "OK APPEND complete"; + } + CHECK(args) { + this._daemon.synchronize(this._selectedMailbox, false); + return "OK CHECK completed"; + } + CLOSE(args) { + this._selectedMailbox.expunge(); + this._daemon.synchronize(this._selectedMailbox, !this._readOnly); + this._selectedMailbox = null; + this._state = IMAP_STATE_AUTHED; + return "OK CLOSE completed"; + } + EXPUNGE(args) { + // Will be either empty or LF-terminated already + var response = this._selectedMailbox.expunge(); + this._daemon.synchronize(this._selectedMailbox); + return response + "OK EXPUNGE completed"; + } + SEARCH(args, uid) { + if (args[0] == "UNDELETED") { + let response = "* SEARCH"; + let messages = this._selectedMailbox._messages; + for (let i = 0; i < messages.length; i++) { + if (!messages[i].flags.includes("\\Deleted")) { + response += " " + messages[i].uid; + } + } + response += "\0"; + return response + "OK SEARCH COMPLETED\0"; + } + return "BAD not here yet"; + } + FETCH(args, uid) { + // Step 1: Get the messages to fetch + var ids = []; + var messages = this._parseSequenceSet(args[0], uid, ids); + + // Step 2: Ensure that the fetching items are in a neat format + if (typeof args[1] == "string") { + if (args[1] in this.fetchMacroExpansions) { + args[1] = this.fetchMacroExpansions[args[1]]; + } else { + args[1] = [args[1]]; + } + } + if (uid && !args[1].includes("UID")) { + args[1].push("UID"); + } + + // Step 2.1: Preprocess the item fetch stack + var items = [], + prefix = undefined; + for (let item of args[1]) { + if (item.indexOf("[") > 0 && !item.includes("]")) { + // We want to append everything into an item until we find a ']' + prefix = item + " "; + continue; + } + if (prefix !== undefined) { + if (typeof item != "string" || !item.includes("]")) { + prefix += + (typeof item == "string" ? item : "(" + item.join(" ") + ")") + " "; + continue; + } + // Replace superfluous space with a ']'. + prefix = prefix.substr(0, prefix.length - 1) + "]"; + item = prefix; + prefix = undefined; + } + item = item.toUpperCase(); + if (!items.includes(item)) { + items.push(item); + } + } + + // Step 3: Fetch time! + var response = ""; + for (var i = 0; i < messages.length; i++) { + response += "* " + ids[i] + " FETCH ("; + var parts = []; + const flagsBefore = messages[i].flags.slice(); + for (let item of items) { + // Brief explanation: an item like BODY[]<> can't be hardcoded easily, + // so we go for the initial alphanumeric substring, passing in the + // actual string as an optional second part. + var front = item.split(/[^A-Z0-9-]/, 1)[0]; + var functionName = "_FETCH_" + front.replace(/-/g, "_"); + + if (!(functionName in this)) { + return "BAD can't fetch " + front; + } + try { + parts.push(this[functionName](messages[i], item)); + } catch (ex) { + return "BAD error in fetching: " + ex; + } + } + const flagsAfter = messages[i].flags; + if ( + !items.includes("FLAGS") && + (flagsAfter.length != flagsBefore.length || + flagsAfter.some((f, i) => f != flagsBefore[i])) + ) { + // Flags changed, send them too, even though they weren't requested. + parts.push(this._FETCH_FLAGS(messages[i], "FLAGS")); + } + response += parts.join(" ") + ")\0"; + } + return response + "OK FETCH completed"; + } + STORE(args, uid) { + var ids = []; + var messages = this._parseSequenceSet(args[0], uid, ids); + + args[1] = args[1].toUpperCase(); + var silent = args[1].includes(".SILENT", 1); + if (silent) { + args[1] = args[1].substring(0, args[1].indexOf(".")); + } + + if (typeof args[2] != "object") { + args[2] = [args[2]]; + } + + var response = ""; + for (var i = 0; i < messages.length; i++) { + var message = messages[i]; + switch (args[1]) { + case "FLAGS": + message.flags = args[2]; + break; + case "+FLAGS": + for (let flag of args[2]) { + message.setFlag(flag); + } + break; + case "-FLAGS": + for (let flag of args[2]) { + var index; + if ((index = message.flags.indexOf(flag)) != -1) { + message.flags.splice(index, 1); + } + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (FLAGS ("; + response += message.flags.join(" "); + response += "))\0"; + } + if (silent) { + response = ""; + } + return response + "OK STORE completed"; + } + COPY(args, uid) { + var messages = this._parseSequenceSet(args[0], uid); + + var dest = this._daemon.getMailbox(args[1]); + if (!dest) { + return "NO [TRYCREATE] what mailbox?"; + } + + for (var message of messages) { + let newMessage = new ImapMessage( + message._URI, + dest.uidnext++, + message.flags + ); + newMessage.recent = false; + dest.addMessage(newMessage); + } + if (this._daemon.copySleep > 0) { + // spin rudely for copyTimeout milliseconds. + let now = new Date(); + let alarm; + let startingMSeconds = now.getTime(); + while (true) { + alarm = new Date(); + if (alarm.getTime() - startingMSeconds > this._daemon.copySleep) { + break; + } + } + } + return "OK COPY completed"; + } + UID(args) { + var name = args.shift(); + if (!this.kUidCommands.includes(name)) { + return "BAD illegal command " + name; + } + + args = this._treatArgs(args, name); + return this[name](args, true); + } + + postCommand(reader) { + if (this.closing) { + this.closing = false; + reader.closeSocket(); + } + if (this.sendingLiteral) { + reader.preventLFMunge(); + } + reader.setMultiline(this._multiline); + if (this._lastCommand == reader.watchWord) { + reader.stopTest(); + } + } + onServerFault(e) { + return ( + ("_tag" in this ? this._tag : "*") + " BAD Internal server error: " + e + ); + } + + // FETCH sub commands and helpers + + fetchMacroExpansions = { + ALL: ["FLAGS", "INTERNALDATE", "RFC822.SIZE" /* , "ENVELOPE" */], + FAST: ["FLAGS", "INTERNALDATE", "RFC822.SIZE"], + FULL: ["FLAGS", "INTERNALDATE", "RFC822.SIZE" /* , "ENVELOPE", "BODY" */], + }; + _parseSequenceSet(set, uid, ids /* optional */) { + if (typeof set == "number") { + if (uid) { + for (let i = 0; i < this._selectedMailbox._messages.length; i++) { + var message = this._selectedMailbox._messages[i]; + if (message.uid == set) { + if (ids) { + ids.push(i + 1); + } + return [message]; + } + } + return []; + } + if (!(set - 1 in this._selectedMailbox._messages)) { + return []; + } + if (ids) { + ids.push(set); + } + return [this._selectedMailbox._messages[set - 1]]; + } + + var daemon = this; + function part2num(part) { + if (part == "*") { + if (uid) { + return daemon._selectedMailbox._highestuid; + } + return daemon._selectedMailbox._messages.length; + } + let re = /[0-9]/g; + let num = part.match(re); + if (!num || num.length != part.length) { + throw new Error("BAD invalid UID " + part); + } + return parseInt(part); + } + + var elements = set.split(/,/); + set = []; + for (var part of elements) { + if (!part.includes(":")) { + set.push(part2num(part)); + } else { + var range = part.split(/:/); + range[0] = part2num(range[0]); + range[1] = part2num(range[1]); + if (range[0] > range[1]) { + let temp = range[1]; + range[1] = range[0]; + range[0] = temp; + } + for (let i = range[0]; i <= range[1]; i++) { + set.push(i); + } + } + } + set.sort(); + for (let i = set.length - 1; i > 0; i--) { + if (set[i] == set[i - 1]) { + set.splice(i, 0); + } + } + + if (!ids) { + ids = []; + } + var messages; + if (uid) { + messages = this._selectedMailbox._messages.filter(function (msg, i) { + if (!set.includes(msg.uid)) { + return false; + } + ids.push(i + 1); + return true; + }); + } else { + messages = []; + for (var id of set) { + if (id - 1 in this._selectedMailbox._messages) { + ids.push(id); + messages.push(this._selectedMailbox._messages[id - 1]); + } + } + } + return messages; + } + _FETCH_BODY(message, query) { + if (query == "BODY") { + return "BODYSTRUCTURE " + bodystructure(message.getText(), false); + } + // parts = [ name, section, empty, {, partial, empty } ] + var parts = query.split(/[[\]<>]/); + + if (parts[0] != "BODY.PEEK" && !this._readOnly) { + message.setFlag("\\Seen"); + } + + if (parts[3]) { + parts[3] = parts[3].split(/\./).map(function (e) { + return parseInt(e); + }); + } + + if (parts[1].length == 0) { + // Easy case: we have BODY[], just send the message... + let response = "BODY[]"; + var text; + if (parts[3]) { + response += "<" + parts[3][0] + ">"; + text = message.getText(parts[3][0], parts[3][1]); + } else { + text = message.getText(); + } + response += " {" + text.length + "}\r\n"; + response += text; + return response; + } + + // What's inside the command? + var data = /((?:\d+\.)*\d+)(?:\.([^ ]+))?/.exec(parts[1]); + var partNum; + if (data) { + partNum = data[1]; + query = data[2]; + } else { + partNum = ""; + if (parts[1].includes(" ", 1)) { + query = parts[1].substring(0, parts[1].indexOf(" ")); + } else { + query = parts[1]; + } + } + var queryArgs; + if (parts[1].includes(" ", 1)) { + queryArgs = parseCommand(parts[1].substr(parts[1].indexOf(" ")))[0]; + } else { + queryArgs = []; + } + + // Now we have three parameters representing the part number (empty for top- + // level), the subportion representing what we want to find (empty for the + // body), and an array of arguments if we have a subquery. If we made an + // error here, it will pop until it gets to FETCH, which will just pop at a + // BAD response, which is what should happen if the query is malformed. + // Now we dump it all off onto ImapMessage to mess with. + + // Start off the response + let response = "BODY[" + parts[1] + "]"; + if (parts[3]) { + response += "<" + parts[3][0] + ">"; + } + response += " "; + + data = ""; + switch (query) { + case "": + case "TEXT": + data += message.getPartBody(partNum); + break; + case "HEADER": // I believe this specifies mime for an RFC822 message only + data += message.getPartHeaders(partNum).rawHeaderText + "\r\n"; + break; + case "MIME": + data += message.getPartHeaders(partNum).rawHeaderText + "\r\n\r\n"; + break; + case "HEADER.FIELDS": { + let joinList = []; + let headers = message.getPartHeaders(partNum); + for (let header of queryArgs) { + header = header.toLowerCase(); + if (headers.has(header)) { + joinList.push( + headers + .getRawHeader(header) + .map(value => `${header}: ${value}`) + .join("\r\n") + ); + } + } + data += joinList.join("\r\n") + "\r\n"; + break; + } + case "HEADER.FIELDS.NOT": { + let joinList = []; + let headers = message.getPartHeaders(partNum); + for (let header of headers) { + if (!(header in queryArgs)) { + joinList.push( + headers + .getRawHeader(header) + .map(value => `${header}: ${value}`) + .join("\r\n") + ); + } + } + data += joinList.join("\r\n") + "\r\n"; + break; + } + default: + data += message.getPartBody(partNum); + } + + this.sendingLiteral = true; + response += "{" + data.length + "}\r\n"; + response += data; + return response; + } + _FETCH_BODYSTRUCTURE(message, query) { + return "BODYSTRUCTURE " + bodystructure(message.getText(), true); + } + // _FETCH_ENVELOPE, + _FETCH_FLAGS(message) { + var response = "FLAGS ("; + response += message.flags.join(" "); + if (message.recent) { + response += " \\Recent"; + } + response += ")"; + return response; + } + _FETCH_INTERNALDATE(message) { + let date = message.date; + // Format timestamp as: "%d-%b-%Y %H:%M:%S %z" (%b in English). + let year = date.getFullYear().toString(); + let month = date.toLocaleDateString("en-US", { month: "short" }); + let day = date.getDate().toString(); + let hours = date.getHours().toString().padStart(2, "0"); + let minutes = date.getMinutes().toString().padStart(2, "0"); + let seconds = date.getSeconds().toString().padStart(2, "0"); + let offset = date.getTimezoneOffset(); + let tzoff = + Math.floor(Math.abs(offset) / 60) * 100 + (Math.abs(offset) % 60); + let timeZone = (offset < 0 ? "+" : "-") + tzoff.toString().padStart(4, "0"); + + let response = 'INTERNALDATE "'; + response += `${day}-${month}-${year} ${hours}:${minutes}:${seconds} ${timeZone}`; + response += '"'; + return response; + } + _FETCH_RFC822(message, query) { + if (query == "RFC822") { + return this._FETCH_BODY(message, "BODY[]").replace("BODY[]", "RFC822"); + } + if (query == "RFC822.HEADER") { + return this._FETCH_BODY(message, "BODY.PEEK[HEADER]").replace( + "BODY[HEADER]", + "RFC822.HEADER" + ); + } + if (query == "RFC822.TEXT") { + return this._FETCH_BODY(message, "BODY[TEXT]").replace( + "BODY[TEXT]", + "RFC822.TEXT" + ); + } + + if (query == "RFC822.SIZE") { + var channel = message.channel; + var length = message.size ? message.size : channel.contentLength; + if (length == -1) { + var inputStream = channel.open(); + length = inputStream.available(); + inputStream.close(); + } + return "RFC822.SIZE " + length; + } + throw new Error("Unknown item " + query); + } + _FETCH_UID(message) { + return "UID " + message.uid; + } +} + +// IMAP4 RFC extensions +// -------------------- +// Since there are so many extensions to IMAP, and since these extensions are +// not strictly hierarchical (e.g., an RFC 2342-compliant server can also be +// RFC 3516-compliant, but a server might only implement one of them), they +// must be handled differently from other fakeserver implementations. +// An extension is defined as follows: it is an object (not a function and +// prototype pair!). This object is "mixed" into the handler via the helper +// function mixinExtension, which applies appropriate magic to make the +// handler compliant to the extension. Functions are added untransformed, but +// both arrays and objects are handled by appending the values onto the +// original state of the handler. Semantics apply as for the base itself. + +// Note that UIDPLUS (RFC4315) should be mixed in last (or at least after the +// MOVE extension) because it changes behavior of that extension. +var configurations = { + Cyrus: ["RFC2342", "RFC2195", "RFC5258"], + UW: ["RFC2342", "RFC2195"], + Dovecot: ["RFC2195", "RFC5258"], + Zimbra: ["RFC2197", "RFC2342", "RFC2195", "RFC5258"], + Exchange: ["RFC2342", "RFC2195"], + LEMONADE: ["RFC2342", "RFC2195"], + CUSTOM1: ["MOVE", "RFC4315", "CUSTOM"], + GMail: ["GMAIL", "RFC2197", "RFC2342", "RFC3348", "RFC4315"], +}; + +function mixinExtension(handler, extension) { + if (extension.preload) { + extension.preload(handler); + } + + for (var property in extension) { + if (property == "preload") { + continue; + } + if (typeof extension[property] == "function") { + // This is a function, so we add it to the handler + handler[property] = extension[property]; + } else if (extension[property] instanceof Array) { + // This is an array, so we append the values + if (!(property in handler)) { + handler[property] = []; + } + handler[property] = handler[property].concat(extension[property]); + } else if (property in handler) { + // This is an object, so we add in the values + // Hack to make arrays et al. work recursively + mixinExtension(handler[property], extension[property]); + } else { + handler[property] = extension[property]; + } + } +} + +// Support for Gmail extensions: XLIST and X-GM-EXT-1 +var IMAP_GMAIL_extension = { + preload(toBeThis) { + toBeThis._preGMAIL_STORE = toBeThis.STORE; + toBeThis._preGMAIL_STORE_argFormat = toBeThis._argFormat.STORE; + toBeThis._argFormat.STORE = ["number", "atom", "..."]; + toBeThis._DEFAULT_LIST = toBeThis.LIST; + }, + XLIST(args) { + // XLIST is really just SPECIAL-USE that does not conform to RFC 6154 + return this.LIST(args); + }, + LIST(args) { + // XLIST was deprecated, LIST implies SPECIAL-USE for Gmail. + args.push("RETURN"); + args.push("SPECIAL-USE"); + return this._DEFAULT_LIST(args); + }, + _LIST_RETURN_CHILDREN(aBox) { + return IMAP_RFC5258_extension._LIST_RETURN_CHILDREN(aBox); + }, + _LIST_RETURN_CHILDREN_SPECIAL_USE(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox._children.length > 0) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasChildren"; + } else if (!aBox.flags.includes("\\NoInferiors")) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasNoChildren"; + } + if (aBox.specialUseFlag && aBox.specialUseFlag.length > 0) { + result += " " + aBox.specialUseFlag; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + STORE(args, uid) { + let regex = /[+-]?FLAGS.*/; + if (regex.test(args[1])) { + // if we are storing flags, use the method that was overridden + this._argFormat = this._preGMAIL_STORE_argFormat; + args = this._treatArgs(args, "STORE"); + return this._preGMAIL_STORE(args, uid); + } + // otherwise, handle gmail specific cases + let ids = []; + let messages = this._parseSequenceSet(args[0], uid, ids); + args[2] = formatArg(args[2], "string|(string)"); + for (let i = 0; i < args[2].length; i++) { + if (args[2][i].includes(" ")) { + args[2][i] = '"' + args[2][i] + '"'; + } + } + let response = ""; + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + switch (args[1]) { + case "X-GM-LABELS": + if (message.xGmLabels) { + message.xGmLabels = args[2]; + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + case "+X-GM-LABELS": + if (message.xGmLabels) { + message.xGmLabels = message.xGmLabels.concat(args[2]); + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + case "-X-GM-LABELS": + if (message.xGmLabels) { + for (let i = 0; i < args[2].length; i++) { + let idx = message.xGmLabels.indexOf(args[2][i]); + if (idx != -1) { + message.xGmLabels.splice(idx, 1); + } + } + } else { + return "BAD can't store X-GM-LABELS"; + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (X-GM-LABELS ("; + response += message.xGmLabels.join(" "); + response += "))\0"; + } + return response + "OK STORE completed"; + }, + _FETCH_X_GM_MSGID(message) { + if (message.xGmMsgid) { + return "X-GM-MSGID " + message.xGmMsgid; + } + return "BAD can't fetch X-GM-MSGID"; + }, + _FETCH_X_GM_THRID(message) { + if (message.xGmThrid) { + return "X-GM-THRID " + message.xGmThrid; + } + return "BAD can't fetch X-GM-THRID"; + }, + _FETCH_X_GM_LABELS(message) { + if (message.xGmLabels) { + return "X-GM-LABELS " + message.xGmLabels; + } + return "BAD can't fetch X-GM-LABELS"; + }, + kCapabilities: ["XLIST", "X-GM-EXT-1"], + _argFormat: { XLIST: ["mailbox", "mailbox"] }, + // Enabled in AUTHED and SELECTED states + _enabledCommands: { 1: ["XLIST"], 2: ["XLIST"] }, +}; + +var IMAP_MOVE_extension = { + MOVE(args, uid) { + let messages = this._parseSequenceSet(args[0], uid); + + let dest = this._daemon.getMailbox(args[1]); + if (!dest) { + return "NO [TRYCREATE] what mailbox?"; + } + + for (var message of messages) { + let newMessage = new ImapMessage( + message._URI, + dest.uidnext++, + message.flags + ); + newMessage.recent = false; + dest.addMessage(newMessage); + } + let mailbox = this._selectedMailbox; + let response = ""; + for (let i = messages.length - 1; i >= 0; i--) { + let msgIndex = mailbox._messages.indexOf(messages[i]); + if (msgIndex != -1) { + response += "* " + (msgIndex + 1) + " EXPUNGE\0"; + mailbox._messages.splice(msgIndex, 1); + } + } + if (response.length > 0) { + delete mailbox.__highestuid; + } + + return response + "OK MOVE completed"; + }, + kCapabilities: ["MOVE"], + kUidCommands: ["MOVE"], + _argFormat: { MOVE: ["number", "mailbox"] }, + // Enabled in SELECTED state + _enabledCommands: { 2: ["MOVE"] }, +}; + +// Provides methods for testing fetchCustomAttribute and issueCustomCommand +var IMAP_CUSTOM_extension = { + preload(toBeThis) { + toBeThis._preCUSTOM_STORE = toBeThis.STORE; + toBeThis._preCUSTOM_STORE_argFormat = toBeThis._argFormat.STORE; + toBeThis._argFormat.STORE = ["number", "atom", "..."]; + }, + STORE(args, uid) { + let regex = /[+-]?FLAGS.*/; + if (regex.test(args[1])) { + // if we are storing flags, use the method that was overridden + this._argFormat = this._preCUSTOM_STORE_argFormat; + args = this._treatArgs(args, "STORE"); + return this._preCUSTOM_STORE(args, uid); + } + // otherwise, handle custom attribute + let ids = []; + let messages = this._parseSequenceSet(args[0], uid, ids); + args[2] = formatArg(args[2], "string|(string)"); + for (let i = 0; i < args[2].length; i++) { + if (args[2][i].includes(" ")) { + args[2][i] = '"' + args[2][i] + '"'; + } + } + let response = ""; + for (let i = 0; i < messages.length; i++) { + let message = messages[i]; + switch (args[1]) { + case "X-CUSTOM-VALUE": + if (message.xCustomValue && args[2].length == 1) { + message.xCustomValue = args[2][0]; + } else { + return "BAD can't store X-CUSTOM-VALUE"; + } + break; + case "X-CUSTOM-LIST": + if (message.xCustomList) { + message.xCustomList = args[2]; + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + case "+X-CUSTOM-LIST": + if (message.xCustomList) { + message.xCustomList = message.xCustomList.concat(args[2]); + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + case "-X-CUSTOM-LIST": + if (message.xCustomList) { + for (let i = 0; i < args[2].length; i++) { + let idx = message.xCustomList.indexOf(args[2][i]); + if (idx != -1) { + message.xCustomList.splice(idx, 1); + } + } + } else { + return "BAD can't store X-CUSTOM-LIST"; + } + break; + default: + return "BAD change what now?"; + } + response += "* " + ids[i] + " FETCH (X-CUSTOM-LIST ("; + response += message.xCustomList.join(" "); + response += "))\0"; + } + return response + "OK STORE completed"; + }, + _FETCH_X_CUSTOM_VALUE(message) { + if (message.xCustomValue) { + return "X-CUSTOM-VALUE " + message.xCustomValue; + } + return "BAD can't fetch X-CUSTOM-VALUE"; + }, + _FETCH_X_CUSTOM_LIST(message) { + if (message.xCustomList) { + return "X-CUSTOM-LIST (" + message.xCustomList.join(" ") + ")"; + } + return "BAD can't fetch X-CUSTOM-LIST"; + }, + kCapabilities: ["X-CUSTOM1"], +}; + +// RFC 2197: ID +var IMAP_RFC2197_extension = { + ID(args) { + let clientID = "("; + for (let i of args) { + clientID += '"' + i + '"'; + } + + clientID += ")"; + let clientStrings = clientID.split(","); + clientID = ""; + for (let i of clientStrings) { + clientID += '"' + i + '" '; + } + clientID = clientID.slice(1, clientID.length - 3); + clientID += ")"; + this._daemon.clientID = clientID; + return "* ID " + this._daemon.idResponse + "\0OK Success"; + }, + kCapabilities: ["ID"], + _argFormat: { ID: ["(string)"] }, + _enabledCommands: { 1: ["ID"], 2: ["ID"] }, +}; + +// RFC 2342: IMAP4 Namespace (NAMESPACE) +var IMAP_RFC2342_extension = { + NAMESPACE(args) { + var namespaces = [[], [], []]; + for (let namespace of this._daemon.namespaces) { + namespaces[namespace.type].push(namespace); + } + + var response = "* NAMESPACE"; + for (var type of namespaces) { + if (type.length == 0) { + response += " NIL"; + continue; + } + response += " ("; + for (let namespace of type) { + response += '("'; + response += namespace.displayName; + response += '" "'; + response += namespace.delimiter; + response += '")'; + } + response += ")"; + } + response += "\0OK NAMESPACE command completed"; + return response; + }, + kCapabilities: ["NAMESPACE"], + _argFormat: { NAMESPACE: [] }, + // Enabled in AUTHED and SELECTED states + _enabledCommands: { 1: ["NAMESPACE"], 2: ["NAMESPACE"] }, +}; + +// RFC 3348 Child Mailbox (CHILDREN) +var IMAP_RFC3348_extension = { + kCapabilities: ["CHILDREN"], +}; + +// RFC 4315: UIDPLUS +var IMAP_RFC4315_extension = { + preload(toBeThis) { + toBeThis._preRFC4315UID = toBeThis.UID; + toBeThis._preRFC4315APPEND = toBeThis.APPEND; + toBeThis._preRFC4315COPY = toBeThis.COPY; + toBeThis._preRFC4315MOVE = toBeThis.MOVE; + }, + UID(args) { + // XXX: UID EXPUNGE is not supported. + return this._preRFC4315UID(args); + }, + APPEND(args) { + let response = this._preRFC4315APPEND(args); + if (response.indexOf("OK") == 0) { + let mailbox = this._daemon.getMailbox(args[0]); + let uid = mailbox.uidnext - 1; + response = + "OK [APPENDUID " + + mailbox.uidvalidity + + " " + + uid + + "]" + + response.substring(2); + } + return response; + }, + COPY(args) { + let mailbox = this._daemon.getMailbox(args[0]); + if (mailbox) { + var first = mailbox.uidnext; + } + let response = this._preRFC4315COPY(args); + if (response.indexOf("OK") == 0) { + let last = mailbox.uidnext - 1; + response = + "OK [COPYUID " + + this._selectedMailbox.uidvalidity + + " " + + args[0] + + " " + + first + + ":" + + last + + "]" + + response.substring(2); + } + return response; + }, + MOVE(args) { + let mailbox = this._daemon.getMailbox(args[1]); + if (mailbox) { + var first = mailbox.uidnext; + } + let response = this._preRFC4315MOVE(args); + if (response.includes("OK MOVE")) { + let last = mailbox.uidnext - 1; + response = response.replace( + "OK MOVE", + "OK [COPYUID " + + this._selectedMailbox.uidvalidity + + " " + + args[0] + + " " + + first + + ":" + + last + + "]" + ); + } + return response; + }, + kCapabilities: ["UIDPLUS"], +}; + +// RFC 5258: LIST-EXTENDED +var IMAP_RFC5258_extension = { + preload(toBeThis) { + toBeThis._argFormat.LIST = [ + "[(atom)]", + "mailbox", + "mailbox|(mailbox)", + "[atom]", + "[(atom)]", + ]; + }, + _LIST_SUBSCRIBED(aBox) { + if (!aBox.subscribed) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\Subscribed"; + if (aBox.nonExistent) { + result += " \\NonExistent"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + _LIST_RETURN_CHILDREN(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox._children.length > 0) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasChildren"; + } else if (!aBox.flags.includes("\\NoInferiors")) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\HasNoChildren"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + _LIST_RETURN_SUBSCRIBED(aBox) { + if (aBox.nonExistent) { + return ""; + } + + let result = "* LIST (" + aBox.flags.join(" "); + if (aBox.subscribed) { + if (aBox.flags.length > 0) { + result += " "; + } + result += "\\Subscribed"; + } + result += ') "' + aBox.delimiter + '" "' + aBox.displayName + '"\0'; + return result; + }, + // TODO implement _LIST_REMOTE, _LIST_RECURSIVEMATCH, _LIST_RETURN_SUBSCRIBED + // and all valid combinations thereof. Currently, nsImapServerResponseParser + // does not support any of these responses anyway. + + kCapabilities: ["LIST-EXTENDED"], +}; + +/** + * This implements AUTH schemes. Could be moved into RFC3501 actually. + * The test can en-/disable auth schemes by modifying kAuthSchemes. + */ +var IMAP_RFC2195_extension = { + kAuthSchemes: ["CRAM-MD5", "PLAIN", "LOGIN"], + + preload(handler) { + handler._kAuthSchemeStartFunction["CRAM-MD5"] = this.authCRAMStart; + handler._kAuthSchemeStartFunction.PLAIN = this.authPLAINStart; + handler._kAuthSchemeStartFunction.LOGIN = this.authLOGINStart; + }, + + authPLAINStart(lineRest) { + this._nextAuthFunction = this.authPLAINCred; + this._multiline = true; + + return "+"; + }, + authPLAINCred(line) { + var req = AuthPLAIN.decodeLine(line); + if (req.username == this.kUsername && req.password == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend! Friends give friends good advice: Next time, use CRAM-MD5"; + } + return "BAD Wrong username or password, crook!"; + }, + + authCRAMStart(lineRest) { + this._nextAuthFunction = this.authCRAMDigest; + this._multiline = true; + + this._usedCRAMMD5Challenge = AuthCRAM.createChallenge("localhost"); + return "+ " + this._usedCRAMMD5Challenge; + }, + authCRAMDigest(line) { + var req = AuthCRAM.decodeLine(line); + var expectedDigest = AuthCRAM.encodeCRAMMD5( + this._usedCRAMMD5Challenge, + this.kPassword + ); + if (req.username == this.kUsername && req.digest == expectedDigest) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend!"; + } + return "BAD Wrong username or password, crook!"; + }, + + authLOGINStart(lineRest) { + this._nextAuthFunction = this.authLOGINUsername; + this._multiline = true; + + return "+ " + btoa("Username:"); + }, + authLOGINUsername(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kUsername) { + this._nextAuthFunction = this.authLOGINPassword; + } else { + // Don't return error yet, to not reveal valid usernames + this._nextAuthFunction = this.authLOGINBadUsername; + } + this._multiline = true; + return "+ " + btoa("Password:"); + }, + authLOGINBadUsername(line) { + return "BAD Wrong username or password, crook!"; + }, + authLOGINPassword(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kPassword) { + this._state = IMAP_STATE_AUTHED; + return "OK Hello friend! Where did you pull out this old auth scheme?"; + } + return "BAD Wrong username or password, crook!"; + }, +}; + +// FETCH BODYSTRUCTURE +function bodystructure(msg, extension) { + if (!msg || msg == "") { + return ""; + } + + // Use the mime parser emitter to generate body structure data. Most of the + // string will be built as we exit a part. Currently not working: + // 1. Some of the fields return NIL instead of trying to calculate them. + // 2. MESSAGE is missing the ENVELOPE and the lines at the end. + var bodystruct = ""; + function paramToString(params) { + let paramList = []; + for (let [param, value] of params) { + paramList.push('"' + param.toUpperCase() + '" "' + value + '"'); + } + return paramList.length == 0 ? "NIL" : "(" + paramList.join(" ") + ")"; + } + var headerStack = []; + var BodyStructureEmitter = { + startPart(partNum, headers) { + bodystruct += "("; + headerStack.push(headers); + this.numLines = 0; + this.length = 0; + }, + deliverPartData(partNum, data) { + this.length += data.length; + this.numLines += Array.from(data).filter(x => x == "\n").length; + }, + endPart(partNum) { + // Grab the headers from before + let headers = headerStack.pop(); + let contentType = headers.contentType; + if (contentType.mediatype == "multipart") { + bodystruct += ' "' + contentType.subtype.toUpperCase() + '"'; + if (extension) { + bodystruct += " " + paramToString(contentType); + // XXX: implement the rest + bodystruct += " NIL NIL NIL"; + } + } else { + bodystruct += + '"' + + contentType.mediatype.toUpperCase() + + '" "' + + contentType.subtype.toUpperCase() + + '"'; + bodystruct += " " + paramToString(contentType); + + // XXX: Content ID, Content description + bodystruct += " NIL NIL"; + + let cte = headers.has("content-transfer-encoding") + ? headers.get("content-transfer-encoding") + : "7BIT"; + bodystruct += ' "' + cte + '"'; + + bodystruct += " " + this.length; + if (contentType.mediatype == "text") { + bodystruct += " " + this.numLines; + } + + // XXX: I don't want to implement these yet + if (extension) { + bodystruct += " NIL NIL NIL NIL"; + } + } + bodystruct += ")"; + }, + }; + MimeParser.parseSync(msg, BodyStructureEmitter, {}); + return bodystruct; +} diff --git a/comm/mailnews/test/fakeserver/Ldapd.jsm b/comm/mailnews/test/fakeserver/Ldapd.jsm new file mode 100644 index 0000000000..2206ebce81 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Ldapd.jsm @@ -0,0 +1,665 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const EXPORTED_SYMBOLS = ["LDAPDaemon", "LDAPHandlerFn"]; + +var { MailStringUtils } = ChromeUtils.import( + "resource:///modules/MailStringUtils.jsm" +); + +/** + * This file provides fake LDAP server functionality, just enough to run + * our unit tests against. + * + * Currently: + * - it accepts any bind request (no authentication). + * - it supports searches, but only some types of filter. + * - it supports unbind (quit) requests. + * - all other requests are ignored. + * + * It should be extensible enough that extra features can be added as + * required. + */ + +/* + * Helpers for application-neutral BER-encoding/decoding. + * + * BER is self-describing enough to allow us to parse it without knowing + * the meaning. So we can break down a binary stream into ints, strings, + * sequences etc... and then leave it up to the separate LDAP code to + * interpret the meaning of it. + * + * Clearest BER reference I've read: + * https://docs.oracle.com/cd/E19476-01/821-0510/def-basic-encoding-rules.html + */ + +/** + * Encodes a BER length, returning an array of (wire-format) bytes. + * It's variable length encoding - smaller numbers are encoded with + * fewer bytes. + * + * @param {number} i - The length to encode. + */ +function encodeLength(i) { + if (i < 128) { + return [i]; + } + + let temp = i; + let bytes = []; + + while (temp >= 128) { + bytes.unshift(temp & 255); + temp >>= 8; + } + bytes.unshift(temp); + bytes.unshift(0x80 | bytes.length); + return bytes; +} + +/** + * Helper for encoding and decoding BER values. + * Each value is notionally a type-length-data triplet, although we just + * store type and an array for data (with the array knowing it's length). + * BERValue.data is held in raw form (wire format) for non-sequence values. + * For sequences and sets (constructed values), .data is empty, and + * instead .children is used to hold the contained BERValue objects. + */ +class BERValue { + constructor(type) { + this.type = type; + this.children = []; // only for constructed values (sequences) + this.data = []; // the raw data (empty for constructed ones) + } + + /** + * Encode the BERValue to an array of bytes, ready to be written to the wire. + * + * @returns {Array.} - The encoded bytes. + */ + encode() { + let bytes = []; + if (this.isConstructed()) { + for (let c of this.children) { + bytes = bytes.concat(c.encode()); + } + } else { + bytes = this.data; + } + return [this.type].concat(encodeLength(bytes.length), bytes); + } + + // Functions to check class (upper two bits of type). + isUniversal() { + return (this.type & 0xc0) == 0x00; + } + isApplication() { + return (this.type & 0xc0) == 0x40; + } + isContextSpecific() { + return (this.type & 0xc0) == 0x80; + } + isPrivate() { + return (this.type & 0xc0) == 0xc0; + } + + /* + * @return {boolean} - Is this value a constructed type a sequence or set? + * (As encoded in bit 5 of the type) + */ + isConstructed() { + return !!(this.type & 0x20); + } + + /** + * @returns {number} - The tag number of the type (the lower 5 bits). + */ + tag() { + return this.type & 0x1f; + } + + // Functions to check for some of the core universal types. + isNull() { + return this.type == 0x05; + } + isBoolean() { + return this.type == 0x01; + } + isInteger() { + return this.type == 0x02; + } + isOctetString() { + return this.type == 0x04; + } + isEnumerated() { + return this.type == 0x0a; + } + + // Functions to interpret the value in particular ways. + // No type checking is performed, as application/context-specific + // types can also use these. + + asBoolean() { + return this.data[0] != 0; + } + + asInteger() { + let i = 0; + // TODO: handle negative numbers! + for (let b of this.data) { + i = (i << 8) | b; + } + return i; + } + + asEnumerated() { + return this.asInteger(); + } + + // Helper to interpret an octet string as an ASCII string. + asString() { + // TODO: pass in expected encoding? + if (this.data.length > 0) { + return MailStringUtils.uint8ArrayToByteString(new Uint8Array(this.data)); + } + return ""; + } + + // Static helpers to construct specific types of BERValue. + static newNull() { + let ber = new BERValue(0x05); + ber.data = []; + return ber; + } + + static newBoolean(b) { + let ber = new BERValue(0x01); + ber.data = [b ? 0xff : 0x00]; + return ber; + } + + static newInteger(i) { + let ber = new BERValue(0x02); + // TODO: does this handle negative correctly? + while (i >= 128) { + ber.data.unshift(i & 255); + i >>= 8; + } + ber.data.unshift(i); + return ber; + } + + static newEnumerated(i) { + let ber = BERValue.newInteger(i); + ber.type = 0x0a; // sneaky but valid. + return ber; + } + + static newOctetString(bytes) { + let ber = new BERValue(0x04); + ber.data = bytes; + return ber; + } + + /** + * Create an octet string from an ASCII string. + */ + static newString(str) { + let ber = new BERValue(0x04); + if (str.length > 0) { + ber.data = Array.from(str, c => c.charCodeAt(0)); + } + return ber; + } + + /** + * Create a new sequence + * + * @param {number} type - BER type byte + * @param {Array.} children - The contents of the sequence. + */ + static newSequence(type, children) { + let ber = new BERValue(type); + ber.children = children; + return ber; + } + + /* + * A helper to dump out the value (and it's children) in a human-readable + * way. + */ + dbug(prefix = "") { + let desc = ""; + switch (this.type) { + case 0x01: + desc += `BOOLEAN (${this.asBoolean()})`; + break; + case 0x02: + desc += `INTEGER (${this.asInteger()})`; + break; + case 0x04: + desc += `OCTETSTRING ("${this.asString()}")`; + break; + case 0x05: + desc += `NULL`; + break; + case 0x0a: + desc += `ENUMERATED (${this.asEnumerated()})`; + break; + case 0x30: + desc += `SEQUENCE`; + break; + case 0x31: + desc += `SET`; + break; + default: + desc = `0x${this.type.toString(16)}`; + if (this.isConstructed()) { + desc += " SEQUENCE"; + } + break; + } + + switch (this.type & 0xc0) { + case 0x00: + break; // universal + case 0x40: + desc += " APPLICATION"; + break; + case 0x80: + desc += " CONTEXT-SPECIFIC"; + break; + case 0xc0: + desc += " PRIVATE"; + break; + } + + if (this.isConstructed()) { + desc += ` ${this.children.length} children`; + } else { + desc += ` ${this.data.length} bytes`; + } + + // Dump out the beginning of the payload as raw bytes. + let rawdump = this.data.slice(0, 8).join(" "); + if (this.data.length > 8) { + rawdump += "..."; + } + + dump(`${prefix}${desc} ${rawdump}\n`); + + for (let c of this.children) { + c.dbug(prefix + " "); + } + } +} + +/** + * Parser to decode BER elements from a Connection. + */ +class BERParser { + constructor(conn) { + this._conn = conn; + } + + /** + * Helper to fetch the next byte in the stream. + * + * @returns {number} - The byte. + */ + async _nextByte() { + let buf = await this._conn.read(1); + return buf[0]; + } + + /** + * Helper to read a BER length field from the connection. + * + * @returns {Array.} - 2 elements: [length, bytesconsumed]. + */ + async _readLength() { + let n = await this._nextByte(); + if ((n & 0x80) == 0) { + return [n, 1]; // msb clear => single-byte encoding + } + // lower 7 bits are number of bytes encoding length (big-endian order). + n = n & 0x7f; + let len = 0; + for (let i = 0; i < n; ++i) { + len = (len << 8) + (await this._nextByte()); + } + return [len, 1 + n]; + } + + /** + * Reads a single BERValue from the connection (including any children). + * + * @returns {Array.} - 2 elements: [value, bytesconsumed]. + */ + async decodeBERValue() { + // BER values always encoded as TLV (type, length, value) triples, + // where type is a single byte, length can be a variable number of bytes + // and value is a byte string, of size length. + let type = await this._nextByte(); + let [length, lensize] = await this._readLength(); + + let ber = new BERValue(type); + if (type & 0x20) { + // it's a sequence + let cnt = 0; + while (cnt < length) { + let [child, consumed] = await this.decodeBERValue(); + cnt += consumed; + ber.children.push(child); + } + if (cnt != length) { + // All the bytes in the sequence must be accounted for. + // TODO: should define a specific BER error type so handler can + // detect and respond to BER decoding issues? + throw new Error("Mismatched length in sequence"); + } + } else { + ber.data = await this._conn.read(length); + } + return [ber, 1 + lensize + length]; + } +} + +/* + * LDAP-specific code from here on. + */ + +/* + * LDAPDaemon holds our LDAP database and has methods for + * searching and manipulating the data. + * So tests can set up test data here, shared by any number of LDAPHandlerFn + * connections. + */ +class LDAPDaemon { + constructor() { + // An entry is an object of the form: + // {dn:"....", attributes: {attr1: [val1], attr2:[val2,val3], ...}} + // Note that the attribute values are arrays (attributes can have multiple + // values in LDAP). + this.entries = {}; // We map dn to entry, to ensure dn is unique. + this.debug = false; + } + + /** + * If set, will dump out assorted debugging info. + */ + setDebug(yesno) { + this.debug = yesno; + } + + /** + * Add entries to the LDAP database. + * Overwrites previous entries with same dn. + * since attributes can have multiple values, they should be arrays. + * For example: + * {dn: "...", {cn: ["Bob Smith"], ...}} + * But because that can be a pain, non-arrays values will be promoted. + * So we'll also accept: + * {dn: "...", {cn: "Bob Smith", ...}} + */ + add(...entries) { + // Clone the data before munging it. + let entriesCopy = JSON.parse(JSON.stringify(entries)); + for (let e of entriesCopy) { + if (e.dn === undefined || e.attributes === undefined) { + throw new Error("bad entry"); + } + + // Convert attr values to arrays, if required. + for (let [attr, val] of Object.entries(e.attributes)) { + if (!Array.isArray(val)) { + e.attributes[attr] = [val]; + } + } + this.entries[e.dn] = e; + } + } + + /** + * Find entries in our LDAP db. + * + * @param {BERValue} berFilter - BERValue containing the filter to apply. + * @returns {Array} - The matching entries. + */ + search(berFilter) { + let f = this.buildFilter(berFilter); + return Object.values(this.entries).filter(f); + } + + /** + * Recursively build a filter function from a BER-encoded filter. + * The resulting function accepts a single entry as parameter, and + * returns a bool to say if it passes the filter or not. + * + * @param {BERValue} ber - The filter. + * @returns {Function} - A function to test an entry against the filter. + */ + buildFilter(ber) { + if (!ber.isContextSpecific()) { + throw new Error("Bad filter"); + } + + switch (ber.tag()) { + case 0: { + // and + if (ber.children.length < 1) { + throw new Error("Bad 'and' filter"); + } + let subFilters = ber.children.map(this.buildFilter); + return function (e) { + return subFilters.every(filt => filt(e)); + }; + } + case 1: { + // or + if (ber.children.length < 1) { + throw new Error("Bad 'or' filter"); + } + let subFilters = ber.children.map(this.buildFilter); + return function (e) { + return subFilters.some(filt => filt(e)); + }; + } + case 2: { + // not + if (ber.children.length != 1) { + throw new Error("Bad 'not' filter"); + } + let subFilter = this.buildFilter(ber.children[0]); // one child + return function (e) { + return !subFilter(e); + }; + } + case 3: { + // equalityMatch + if (ber.children.length != 2) { + throw new Error("Bad 'equality' filter"); + } + let attrName = ber.children[0].asString().toLowerCase(); + let attrVal = ber.children[1].asString().toLowerCase(); + return function (e) { + let attrs = Object.keys(e.attributes).reduce(function (c, key) { + c[key.toLowerCase()] = e.attributes[key]; + return c; + }, {}); + return ( + attrs[attrName] !== undefined && + attrs[attrName].map(val => val.toLowerCase()).includes(attrVal) + ); + }; + } + case 7: { + // present + let attrName = ber.asString().toLowerCase(); + return function (e) { + let attrs = Object.keys(e.attributes).reduce(function (c, key) { + c[key.toLowerCase()] = e.attributes[key]; + return c; + }, {}); + return attrs[attrName] !== undefined; + }; + } + case 4: // substring (Probably need to implement this!) + case 5: // greaterOrEqual + case 6: // lessOrEqual + case 8: // approxMatch + case 9: // extensibleMatch + // UNSUPPORTED! just match everything. + dump("WARNING: unsupported filter\n"); + return e => true; + default: + throw new Error("unknown filter"); + } + } +} + +/** + * Helper class to help break down LDAP handler into multiple functions. + * Used by LDAPHandlerFn, below. + * Handler state for a single connection (as opposed to any state common + * across all connections, which is handled by LDAPDaemon). + */ +class LDAPHandler { + constructor(conn, daemon) { + this._conn = conn; + this._daemon = daemon; + } + + // handler run() should exit when done, or throw exception to crash out. + async run() { + let parser = new BERParser(this._conn); + + while (1) { + let [msg] = await parser.decodeBERValue(); + if (this._daemon.debug) { + dump("=== received ===\n"); + msg.dbug("C: "); + } + + if ( + msg.type != 0x30 || + msg.children.length < 2 || + !msg.children[0].isInteger() + ) { + // badly formed message - TODO: bail out gracefully... + throw new Error("Bad message.."); + } + + let msgID = msg.children[0].asInteger(); + let req = msg.children[1]; + + // Handle a teeny tiny subset of requests. + switch (req.type) { + case 0x60: + this.handleBindRequest(msgID, req); + break; + case 0x63: + this.handleSearchRequest(msgID, req); + break; + case 0x42: // unbindRequest (essentially a "quit"). + return; + } + } + } + + /** + * Send out an LDAP message. + * + * @param {number} msgID - The ID of the message we're responding to. + * @param {BERValue} payload - The message content. + */ + async sendLDAPMessage(msgID, payload) { + let msg = BERValue.newSequence(0x30, [BERValue.newInteger(msgID), payload]); + if (this._daemon.debug) { + msg.dbug("S: "); + } + await this._conn.write(msg.encode()); + } + + async handleBindRequest(msgID, req) { + // Ignore the details, just say "OK!" + // TODO: Add some auth support here, would be handy for testing. + let bindResponse = new BERValue(0x61); + bindResponse.children = [ + BERValue.newEnumerated(0), // resultCode 0=success + BERValue.newString(""), // matchedDN + BERValue.newString(""), // diagnosticMessage + ]; + + if (this._daemon.debug) { + dump("=== send bindResponse ===\n"); + } + await this.sendLDAPMessage(msgID, bindResponse); + } + + async handleSearchRequest(msgID, req) { + // Make sure all the parts we expect are present and of correct type. + if ( + req.children.length < 8 || + !req.children[0].isOctetString() || + !req.children[1].isEnumerated() || + !req.children[2].isEnumerated() || + !req.children[3].isInteger() || + !req.children[4].isInteger() || + !req.children[5].isBoolean() + ) { + throw new Error("Bad search request!"); + } + + // Perform search + let filt = req.children[6]; + let matches = this._daemon.search(filt); + + // Send a searchResultEntry for each match + for (let match of matches) { + let dn = BERValue.newString(match.dn); + let attrList = new BERValue(0x30); + for (let [key, values] of Object.entries(match.attributes)) { + let valueSet = new BERValue(0x31); + for (let v of values) { + valueSet.children.push(BERValue.newString(v)); + } + + attrList.children.push( + BERValue.newSequence(0x30, [BERValue.newString(key), valueSet]) + ); + } + + // 0x64 = searchResultEntry + let searchResultEntry = BERValue.newSequence(0x64, [dn, attrList]); + + if (this._daemon.debug) { + dump(`=== send searchResultEntry ===\n`); + } + this.sendLDAPMessage(msgID, searchResultEntry); + } + + //SearchResultDone ::= [APPLICATION 5] LDAPResult + let searchResultDone = new BERValue(0x65); + searchResultDone.children = [ + BERValue.newEnumerated(0), // resultCode 0=success + BERValue.newString(""), // matchedDN + BERValue.newString(""), // diagnosticMessage + ]; + + if (this._daemon.debug) { + dump(`=== send searchResultDone ===\n`); + } + this.sendLDAPMessage(msgID, searchResultDone); + } +} + +/** + * Handler function to deal with a connection to our LDAP server. + */ +async function LDAPHandlerFn(conn, daemon) { + let handler = new LDAPHandler(conn, daemon); + await handler.run(); +} diff --git a/comm/mailnews/test/fakeserver/Maild.jsm b/comm/mailnews/test/fakeserver/Maild.jsm new file mode 100644 index 0000000000..30647e1d56 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Maild.jsm @@ -0,0 +1,566 @@ +/* vim:set ts=2 sw=2 sts=2 et: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Much of the original code is taken from netwerk's httpserver implementation + +var EXPORTED_SYMBOLS = [ + "nsMailServer", + "gThreadManager", // TODO: kill this export + "fsDebugNone", + "fsDebugAll", + "fsDebugRecv", + "fsDebugRecvSend", +]; + +var CC = Components.Constructor; + +/** + * The XPCOM thread manager. This declaration is obsolete and exists only + * because deleting it breaks several dozen tests at the moment. + */ +var gThreadManager = Services.tm; + +var fsDebugNone = 0; +var fsDebugRecv = 1; +var fsDebugRecvSend = 2; +var fsDebugAll = 3; + +/** + * JavaScript constructors for commonly-used classes; precreating these is a + * speedup over doing the same from base principles. See the docs at + * http://developer.mozilla.org/en/Components.Constructor for details. + */ +var ServerSocket = CC( + "@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init" +); +var BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +// Time out after 3 minutes +var TIMEOUT = 3 * 60 * 1000; + +/** + * The main server handling class. A fake server consists of three parts, this + * server implementation (which handles the network communication), the handler + * (which handles the state for a connection), and the daemon (which handles + * the state for the logical server). To make a new server, one needs to pass + * in a function to create handlers--not the handlers themselves--and the + * backend daemon. Since each handler presumably needs access to the logical + * server daemon, that is passed into the handler creation function. A new + * handler will be constructed for every connection made. + * + * As the core code is inherently single-threaded, it is guaranteed that all of + * the calls to the daemon will be made on the same thread, so you do not have + * to worry about reentrancy in daemon calls. + * + * Typical usage: + * + * function createHandler(daemon) { + * return new handler(daemon); + * } + * do_test_pending(); + * var server = new nsMailServer(createHandler, serverDaemon); + * // Port to use. I tend to like using 1024 + default port number myself. + * server.start(port); + * + * // Set up a connection the server... + * server.performTest(); + * transaction = server.playTransaction(); + * // Verify that the transaction is correct... + * + * server.resetTest(); + * // Set up second test... + * server.performTest(); + * transaction = server.playTransaction(); + * + * // Finished with tests + * server.stop(); + * + * var thread = Services.tm.currentThread; + * while (thread.hasPendingEvents()) + * thread.processNextEvent(true); + * + * do_test_finished(); + */ +class nsMailServer { + constructor(handlerCreator, daemon) { + this._debug = fsDebugNone; + + /** The port on which this server listens. */ + this._port = -1; + + /** The socket associated with this. */ + this._socket = null; + + /** + * True if the socket in this is closed (and closure notifications have been + * sent and processed if the socket was ever opened), false otherwise. + */ + this._socketClosed = true; + + /** + * Should we log transactions? This only matters if you want to inspect the + * protocol traffic. Defaults to true because this was written for protocol + * testing. + */ + this._logTransactions = true; + + this._handlerCreator = handlerCreator; + this._daemon = daemon; + this._readers = []; + this._test = false; + this._watchWord = undefined; + + /** + * An array to hold refs to all the input streams below, so that they don't + * get GCed + */ + this._inputStreams = []; + } + + onSocketAccepted(socket, trans) { + if (this._debug != fsDebugNone) { + dump("Received Connection from " + trans.host + ":" + trans.port + "\n"); + } + + const SEGMENT_SIZE = 1024; + const SEGMENT_COUNT = 1024; + var input = trans + .openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT) + .QueryInterface(Ci.nsIAsyncInputStream); + this._inputStreams.push(input); + + var handler = this._handlerCreator(this._daemon); + var reader = new nsMailReader( + this, + handler, + trans, + this._debug, + this._logTransactions + ); + this._readers.push(reader); + + // Note: must use main thread here, or we might get a GC that will cause + // threadsafety assertions. We really need to fix XPConnect so that + // you can actually do things in multi-threaded JS. :-( + input.asyncWait(reader, 0, 0, Services.tm.mainThread); + this._test = true; + } + + onStopListening(socket, status) { + if (this._debug != fsDebugNone) { + dump("Connection Lost " + status + "\n"); + } + + this._socketClosed = true; + // We've been killed or we've stopped, reset the handler to the original + // state (e.g. to require authentication again). + for (var i = 0; i < this._readers.length; i++) { + this._readers[i]._handler.resetTest(); + this._readers[i]._realCloseSocket(); + } + } + + setDebugLevel(debug) { + this._debug = debug; + for (var i = 0; i < this._readers.length; i++) { + this._readers[i].setDebugLevel(debug); + } + } + + start(port = -1) { + if (this._socket) { + throw Components.Exception("", Cr.NS_ERROR_ALREADY_INITIALIZED); + } + + if (port > 0) { + this._port = port; + } + this._socketClosed = false; + + var socket = new ServerSocket( + this._port, + true, // loopback only + -1 + ); // default number of pending connections + + socket.asyncListen(this); + this._socket = socket; + } + + stop() { + if (!this._socket) { + return; + } + + this._socket.close(); + this._socket = null; + + for (let reader of this._readers) { + reader._realCloseSocket(); + } + + if (this._readers.some(e => e.observer.forced)) { + return; + } + + // spin an event loop and wait for the socket-close notification + let thr = Services.tm.currentThread; + while (!this._socketClosed) { + // Don't wait for the next event, just in case there isn't one. + thr.processNextEvent(false); + } + } + stopTest() { + this._test = false; + } + + get port() { + if (this._port == -1) { + this._port = this._socket.port; + } + return this._port; + } + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface = ChromeUtils.generateQI(["nsIServerSocketListener"]); + + // NON-XPCOM PUBLIC API + + /** + * Returns true if this server is not running (and is not in the process of + * serving any requests still to be processed when the server was last + * stopped after being run). + */ + isStopped() { + return this._socketClosed; + } + + /** + * Runs the test. It will not exit until the test has finished. + */ + performTest(watchWord) { + this._watchWord = watchWord; + + let thread = Services.tm.currentThread; + while (!this.isTestFinished()) { + thread.processNextEvent(false); + } + } + + /** + * Returns true if the current processing test has finished. + */ + isTestFinished() { + return this._readers.length > 0 && !this._test; + } + + /** + * Returns the commands run between the server and client. + * The return is an object with two variables (us and them), both of which + * are arrays returning the commands given by each server. + */ + playTransaction() { + if (this._readers.some(e => e.observer.forced)) { + throw new Error("Server timed out!"); + } + if (this._readers.length == 1) { + return this._readers[0].transaction; + } + return this._readers.map(e => e.transaction); + } + + /** + * Prepares for the next test. + */ + resetTest() { + this._readers = this._readers.filter(function (reader) { + return reader._isRunning; + }); + this._test = true; + for (var i = 0; i < this._readers.length; i++) { + this._readers[i]._handler.resetTest(); + } + } +} + +function readTo(input, count, arr) { + var old = new BinaryInputStream(input).readByteArray(count); + Array.prototype.push.apply(arr, old); +} + +/** + * The nsMailReader service, which reads and handles the lines. + * All specific handling is passed off to the handler, which is responsible + * for maintaining its own state. The following commands are required for the + * handler object: + * onError Called when handler[command] does not exist with both the + * command and rest-of-line as arguments + * onStartup Called on initialization with no arguments + * onMultiline Called when in multiline with the entire line as an argument + * postCommand Called after every command with this reader as the argument + * [command] An untranslated command with the rest of the line as the + * argument. Defined as everything to the first space + * + * All functions, except onMultiline and postCommand, treat the + * returned value as the text to be sent to the client; a newline at the end + * may be added if it does not exist, and all lone newlines are converted to + * CRLF sequences. + * + * The return of postCommand is ignored. The return of onMultiline is a bit + * complicated: it may or may not return a response string (returning one is + * necessary to trigger the postCommand handler). + * + * This object has the following supplemental functions for use by handlers: + * closeSocket Performs a server-side socket closing + * setMultiline Sets the multiline mode based on the argument + */ +class nsMailReader { + constructor(server, handler, transport, debug, logTransaction) { + this._debug = debug; + this._server = server; + this._buffer = []; + this._lines = []; + this._handler = handler; + this._transport = transport; + // We don't seem to properly handle large streams when the buffer gets + // exhausted, which causes issues trying to test large messages. So just + // allow a really big buffer. + var output = transport.openOutputStream( + Ci.nsITransport.OPEN_BLOCKING, + 1024, + 4096 + ); + this._output = output; + if (logTransaction) { + this.transaction = { us: [], them: [] }; + } else { + this.transaction = null; + } + + // Send response line + var response = this._handler.onStartup(); + response = response.replace(/([^\r])\n/g, "$1\r\n"); + if (!response.endsWith("\n")) { + response = response + "\r\n"; + } + if (this.transaction) { + this.transaction.us.push(response); + } + this._output.write(response, response.length); + this._output.flush(); + + this._multiline = false; + + this._isRunning = true; + + this.observer = { + server, + forced: false, + notify(timer) { + this.forced = true; + this.server.stopTest(); + this.server.stop(); + }, + QueryInterface: ChromeUtils.generateQI(["nsITimerCallback"]), + }; + this.timer = Cc["@mozilla.org/timer;1"] + .createInstance() + .QueryInterface(Ci.nsITimer); + this.timer.initWithCallback( + this.observer, + TIMEOUT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + + _findLines() { + var buf = this._buffer; + for ( + var crlfLoc = buf.indexOf(13); + crlfLoc >= 0; + crlfLoc = buf.indexOf(13, crlfLoc + 1) + ) { + if (buf[crlfLoc + 1] == 10) { + break; + } + } + if (crlfLoc == -1) { + // We failed to find a newline + return; + } + + var line = String.fromCharCode.apply(null, buf.slice(0, crlfLoc)); + this._buffer = buf.slice(crlfLoc + 2); + this._lines.push(line); + this._findLines(); + } + + onInputStreamReady(stream) { + if (this.observer.forced) { + return; + } + + this.timer.cancel(); + try { + var bytes = stream.available(); + } catch (e) { + // Someone, not us, has closed the stream. This means we can't get any + // more data from the stream, so we'll just go and close our socket. + this._realCloseSocket(); + return; + } + readTo(stream, bytes, this._buffer); + this._findLines(); + + while (this._lines.length > 0) { + var line = this._lines.shift(); + + if (this._debug != fsDebugNone) { + dump("RECV: " + line + "\n"); + } + + var response; + try { + let command; + if (this._multiline) { + response = this._handler.onMultiline(line); + + if (response === undefined) { + continue; + } + } else { + // Record the transaction + if (this.transaction) { + this.transaction.them.push(line); + } + + // Find the command and splice it out... + var splitter = line.indexOf(" "); + command = splitter == -1 ? line : line.substring(0, splitter); + let args = splitter == -1 ? "" : line.substring(splitter + 1); + + // By convention, commands are uppercase + command = command.toUpperCase(); + + if (this._debug == fsDebugAll) { + dump("Received command " + command + "\n"); + } + + if (command in this._handler) { + response = this._handler[command](args); + } else { + response = this._handler.onError(command, args); + } + } + + this._preventLFMunge = false; + this._handler.postCommand(this); + + if (this.watchWord && command == this.watchWord) { + this.stopTest(); + } + } catch (e) { + response = this._handler.onServerFault(e); + if (e instanceof Error) { + dump(e.name + ": " + e.message + "\n"); + dump("File: " + e.fileName + " Line: " + e.lineNumber + "\n"); + dump("Stack trace:\n" + e.stack); + } else { + dump("Exception caught: " + e + "\n"); + } + } + + if (!this._preventLFMunge) { + response = response.replaceAll("\r\n", "\n").replaceAll("\n", "\r\n"); + } + + if (!response.endsWith("\n")) { + response = response + "\r\n"; + } + + if (this._debug == fsDebugRecvSend) { + dump("SEND: " + response.split(" ", 1)[0] + "\n"); + } else if (this._debug == fsDebugAll) { + var responses = response.split("\n"); + responses.forEach(function (line) { + dump("SEND: " + line + "\n"); + }); + } + + if (this.transaction) { + this.transaction.us.push(response); + } + + try { + this._output.write(response, response.length); + this._output.flush(); + } catch (ex) { + if (ex.result == Cr.NS_BASE_STREAM_CLOSED) { + dump("Stream closed whilst sending, this may be expected\n"); + this._realCloseSocket(); + } else { + // Some other issue, let the test see it. + throw ex; + } + } + + if (this._signalStop) { + this._realCloseSocket(); + this._signalStop = false; + } + } + + if (this._isRunning) { + stream.asyncWait(this, 0, 0, Services.tm.currentThread); + this.timer.initWithCallback( + this.observer, + TIMEOUT, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + } + + closeSocket() { + this._signalStop = true; + } + _realCloseSocket() { + this._isRunning = false; + this._output.close(); + this._transport.close(Cr.NS_OK); + this._server.stopTest(); + } + + setMultiline(multi) { + this._multiline = multi; + } + + setDebugLevel(debug) { + this._debug = debug; + } + + preventLFMunge() { + this._preventLFMunge = true; + } + + get watchWord() { + return this._server._watchWord; + } + + stopTest() { + this._server.stopTest(); + } + + QueryInterface = ChromeUtils.generateQI(["nsIInputStreamCallback"]); +} diff --git a/comm/mailnews/test/fakeserver/Nntpd.jsm b/comm/mailnews/test/fakeserver/Nntpd.jsm new file mode 100644 index 0000000000..f6d1be0a48 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Nntpd.jsm @@ -0,0 +1,631 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// This file implements test NNTP servers + +const { MimeParser } = ChromeUtils.import("resource:///modules/mimeParser.jsm"); + +var EXPORTED_SYMBOLS = [ + "NntpDaemon", + "NewsArticle", + "NNTP_POSTABLE", + "NNTP_REAL_LENGTH", + "NNTP_RFC977_handler", + "NNTP_RFC2980_handler", + "NNTP_RFC3977_handler", + "NNTP_Giganews_handler", + "NNTP_RFC4643_extension", +]; + +class NntpDaemon { + constructor(flags) { + this._groups = {}; + this._messages = {}; + this._flags = flags; + } + addGroup(group, postable) { + var flags = 0; + if (postable) { + flags |= NNTP_POSTABLE; + } + this._groups[group] = { keys: [], flags, nextKey: 1 }; + } + addArticle(article) { + this._messages[article.messageID] = article; + for (let group of article.groups) { + if (group in this._groups) { + var key = this._groups[group].nextKey++; + this._groups[group][key] = article; + this._groups[group].keys.push(key); + } + } + } + addArticleToGroup(article, group, key) { + this._groups[group][key] = article; + this._messages[article.messageID] = article; + this._groups[group].keys.push(key); + if (this._groups[group].nextKey <= key) { + this._groups[group].nextKey = key + 1; + } + } + removeArticleFromGroup(groupName, key) { + let group = this._groups[groupName]; + delete group[key]; + group.keys = group.keys.filter(x => x != key); + } + getGroup(group) { + if (this._groups.hasOwnProperty(group)) { + return this._groups[group]; + } + return null; + } + getGroupStats(group) { + if (group.keys.length == 0) { + return [0, 0, 0]; + } + var min = 1 << 30; + var max = 0; + group.keys.forEach(function (key) { + if (key < min) { + min = key; + } + if (key > max) { + max = key; + } + }); + + var length; + if (hasFlag(this._flags, NNTP_REAL_LENGTH)) { + length = group.keys.length; + } else { + length = max - min + 1; + } + + return [length, min, max]; + } + getArticle(msgid) { + if (msgid in this._messages) { + return this._messages[msgid]; + } + return null; + } +} + +function NewsArticle(text) { + this.headers = new Map(); + this.body = ""; + this.messageID = ""; + this.fullText = text; + + var headerMap; + [headerMap, this.body] = MimeParser.extractHeadersAndBody(text); + for (var [header, values] of headerMap._rawHeaders) { + var value = values[0]; + this.headers.set(header, value); + if (header == "message-id") { + var start = value.indexOf("<"); + var end = value.indexOf(">", start); + this.messageID = value.substring(start, end + 1); + } else if (header == "newsgroups") { + this.groups = value.split(/[ \t]*,[ \t]*/); + } + } + + // Add in non-existent fields + if (!this.headers.has("lines")) { + let lines = this.body.split("\n").length; + this.headers.set("lines", lines); + } +} + +/** + * This function converts an NNTP wildmat into a regular expression. + * + * I don't know how accurate it is wrt i18n characters, but its primary usage + * right now is just XPAT, where i18n effects are utterly unspecified, so I am + * not too concerned. + * + * This also neglects cases where special characters are in [] blocks. + */ +function wildmat2regex(wildmat) { + // Special characters in regex that aren't special in wildmat + wildmat = wildmat.replace(/[$+.()|{}^]/, function (str) { + return "\\" + str; + }); + wildmat = wildmat.replace(/(\\*)([*?])/, function (str, p1, p2) { + // TODO: This function appears to be wrong on closer inspection. + if (p1.length % 2 == 0) { + return p2 == "*" ? ".*" : "."; + } + return str; + }); + return new RegExp(wildmat); +} + +// NNTP FLAGS +var NNTP_POSTABLE = 0x0001; + +var NNTP_REAL_LENGTH = 0x0100; + +function hasFlag(flags, flag) { + return (flags & flag) == flag; +} + +// NNTP TEST SERVERS +// ----------------- +// To be comprehensive about testing and fallback, we define these varying +// levels of RFC-compliance: +// * RFC 977 solely (there's not a lot there!) +// * RFC 977 + 2980 (note that there are varying levels of this impl) +// * RFC 3977 bare bones +// * RFC 3977 full +// * RFC 3977 + post-3977 extensions +// * Giganews (Common newsserver for ISP stuff; highest importance) +// * INN 2.4 (Gold standard common implementation; second highest importance) +// Note too that we want various levels of brokenness: +// * Perm errors that require login +// * "I can't handle that" (e.g., news.mozilla.org only supports XOVER for +// searching with XHDR) +// * Naive group counts, missing articles +// * Limitations on what can be posted + +// This handler implements the bare minimum required by RFC 977. Actually, not +// even that much: IHAVE and SLAVE are not implemented, as those two are +// explicitly server implementations. +class NNTP_RFC977_handler { + constructor(daemon) { + this._daemon = daemon; + this.closing = false; + this.resetTest(); + } + resetTest() { + this.extraCommands = ""; + this.articleKey = null; + this.group = null; + } + ARTICLE(args) { + var info = this._selectArticle(args, 220); + if (info[0] == null) { + return info[1]; + } + + var response = info[1] + "\n"; + response += info[0].fullText.replace(/^\./gm, ".."); + response += "."; + return response; + } + BODY(args) { + var info = this._selectArticle(args, 222); + if (info[0] == null) { + return info[1]; + } + + var response = info[1] + "\n"; + response += info[0].body.replace(/^\./gm, ".."); + response += "."; + return response; + } + GROUP(args) { + var group = this._daemon.getGroup(args); + if (group == null) { + return "411 no such news group"; + } + + this.group = group; + this.articleKey = 0 in this.group.keys ? this.group.keys[0] : null; + + var stats = this._daemon.getGroupStats(group); + return ( + "211 " + + stats[0] + + " " + + stats[1] + + " " + + stats[2] + + " " + + args + + " group selected" + ); + } + HEAD(args) { + var info = this._selectArticle(args, 221); + if (info[0] == null) { + return info[1]; + } + + var response = info[1] + "\n"; + for (let [header, value] of info[0].headers) { + response += header + ": " + value + "\n"; + } + response += "."; + return response; + } + HELP(args) { + var response = "100 Why certainly, here is my help:\n"; + response += "Mozilla fake NNTP RFC 977 testing server"; + response += "Commands supported:\n"; + response += "\tARTICLE | [nnn]\n"; + response += "\tBODY\n"; + response += "\tGROUP group\n"; + response += "\tHEAD\n"; + response += "\tHELP\n"; + response += "\tLAST\n"; + response += "\tLIST\n"; + response += "\tNEWGROUPS\n"; + response += "\tNEWNEWS\n"; + response += "\tNEXT\n"; + response += "\tPOST\n"; + response += "\tQUIT\n"; + response += "\tSTAT\n"; + response += this.extraCommands; + response += "."; + return response; + } + LAST(args) { + if (this.group == null) { + return "412 no newsgroup selected"; + } + if (this.articleKey == null) { + return "420 no current article has been selected"; + } + return "502 Command not implemented"; + } + LIST(args) { + var response = "215 list of newsgroup follows\n"; + for (let groupname in this._daemon._groups) { + let group = this._daemon._groups[groupname]; + let stats = this._daemon.getGroupStats(group); + response += + groupname + + " " + + stats[1] + + " " + + stats[0] + + " " + + (hasFlag(group.flags, NNTP_POSTABLE) ? "y" : "n") + + "\n"; + } + response += "."; + return response; + } + NEWGROUPS(args) { + return "502 Command not implemented"; + } + NEWNEWS(args) { + return "502 Command not implemented"; + } + NEXT(args) { + if (this.group == null) { + return "412 no newsgroup selected"; + } + if (this.articleKey == null) { + return "420 no current article has been selected"; + } + return "502 Command not implemented"; + } + POST(args) { + this.posting = true; + this.post = ""; + return "340 Please continue"; + } + QUIT(args) { + this.closing = true; + return "205 closing connection - goodbye!"; + } + STAT(args) { + var info = this._selectArticle(args, 223); + return info[1]; + } + LISTGROUP(args) { + // Yes, I know this isn't RFC 977, but I doubt that mailnews will ever drop + // its requirement for this, so I'll stuff it in here anyways... + var group = args == "" ? this.group : this._daemon.getGroup(args); + if (group == null) { + return "411 This newsgroup does not exist"; + } + + var response = "211 Articles follow:\n"; + for (let key of group.keys) { + response += key + "\n"; + } + response += ".\n"; + return response; + } + + onError(command, args) { + return "500 command not recognized"; + } + onServerFault(e) { + return "500 internal server error: " + e; + } + onStartup() { + this.closing = false; + this.group = null; + this.article = null; + this.posting = false; + return "200 posting allowed"; + } + onMultiline(line) { + if (line == ".") { + if (this.posting) { + var article = new NewsArticle(this.post); + this._daemon.addArticle(article); + this.posting = false; + return "240 Wonderful article, your style is gorgeous!"; + } + } + + if (this.posting) { + if (line.startsWith(".")) { + line = line.substring(1); + } + + this.post += line + "\n"; + } + + return undefined; + } + postCommand(reader) { + if (this.closing) { + reader.closeSocket(); + } + reader.setMultiline(this.posting); + } + + /** + * Selects an article based on args. + * + * Returns an array of objects consisting of: + * # The selected article (or null if non was selected + * # The first line response + */ + _selectArticle(args, responseCode) { + var art, key; + if (args == "") { + if (this.group == null) { + return [null, "412 no newsgroup has been selected"]; + } + if (this.articleKey == null) { + return [null, "420 no current article has been selected"]; + } + + art = this.group[this.articleKey]; + key = this.articleKey; + } else if (args.startsWith("<")) { + art = this._daemon.getArticle(args); + key = 0; + + if (art == null) { + return [null, "430 no such article found"]; + } + } else { + if (this.group == null) { + return [null, "412 no newsgroup has been selected"]; + } + + key = parseInt(args); + if (key in this.group) { + this.articleKey = key; + art = this.group[key]; + } else { + return [null, "423 no such article number in this group"]; + } + } + + var respCode = + responseCode + " " + key + " " + art.messageID + " article selected"; + return [art, respCode]; + } +} + +class NNTP_RFC2980_handler extends NNTP_RFC977_handler { + DATE(args) { + return "502 Command not implemented"; + } + LIST(args) { + var index = args.indexOf(" "); + var command = index == -1 ? args : args.substring(0, index); + args = index == -1 ? "" : args.substring(index + 1); + command = command.toUpperCase(); + if ("LIST_" + command in this) { + return this["LIST_" + command](args); + } + return super.LIST(command + " " + args); + } + LIST_ACTIVE(args) { + return super.LIST(args); + } + MODE(args) { + if (args == "READER") { + return this.onStartup(); + } + return "500 What do you think you're trying to pull here?"; + } + XHDR(args) { + if (!this.group) { + return "412 No group selected"; + } + + args = args.split(" "); + var header = args[0].toLowerCase(); + var found = false; + var response = "221 Headers abound\n"; + for (let key of this._filterRange(args[1], this.group.keys)) { + if (!this.group[key].headers.has(header)) { + continue; + } + found = true; + response += key + " " + this.group[key].headers.get(header) + "\n"; + } + if (!found) { + return "420 No such article"; + } + response += "."; + return response; + } + XOVER(args) { + if (!this.group) { + return "412 No group selected"; + } + + args = args.split(/ +/, 3); + var response = "224 List of articles\n"; + for (let key of this._filterRange(args[0], this.group.keys)) { + response += key + "\t"; + var article = this.group[key]; + response += + article.headers.get("subject") + + "\t" + + article.headers.get("from") + + "\t" + + article.headers.get("date") + + "\t" + + article.headers.get("message-id") + + "\t" + + (article.headers.get("references") || "") + + "\t" + + article.fullText.replace(/\r?\n/, "\r\n").length + + "\t" + + article.body.split(/\r?\n/).length + + "\t" + + (article.headers.get("xref") || "") + + "\n"; + } + response += ".\n"; + return response; + } + XPAT(args) { + if (!this.group) { + return "412 No group selected"; + } + + /* XPAT header range ... */ + args = args.split(/ +/, 3); + let header = args[0].toLowerCase(); + let regex = wildmat2regex(args[2]); + + let response = "221 Results follow\n"; + for (let key of this._filterRange(args[1], this.group.keys)) { + let article = this.group[key]; + if ( + article.headers.has(header) && + regex.test(article.headers.get(header)) + ) { + response += key + " " + article.headers.get(header) + "\n"; + } + } + return response + "."; + } + + _filterRange(range, keys) { + let dash = range.indexOf("-"); + let low, high; + if (dash < 0) { + low = high = parseInt(range); + } else { + low = parseInt(range.substring(0, dash)); + if (dash < range.length - 1) { + high = range.substring(dash + 1); + } else { + // Everything is less than this. + high = 1.0 / 0.0; + } + } + return keys.filter(function (e) { + return low <= e && e <= high; + }); + } +} + +class NNTP_Giganews_handler extends NNTP_RFC2980_handler { + XHDR(args) { + var header = args.split(" ")[0].toLowerCase(); + if ( + header in ["subject", "from", "xref", "date", "message-id", "references"] + ) { + return super.XHDR(args); + } + return "503 unsupported header field"; + } +} + +class NNTP_RFC4643_extension extends NNTP_RFC2980_handler { + constructor(daemon) { + super(daemon); + + this.extraCommands += "\tAUTHINFO USER\n"; + this.extraCommands += "\tAUTHINFO PASS\n"; + this.expectedUsername = "testnews"; + this.expectedPassword = "newstest"; + this.requireBoth = true; + this.authenticated = false; + this.usernameReceived = false; + } + + AUTHINFO(args) { + if (this.authenticated) { + return "502 Command unavailable"; + } + + var argSplit = args.split(" "); + var action = argSplit[0]; + var param = argSplit[1]; + + if (action == "user") { + if (this.usernameReceived) { + return "502 Command unavailable"; + } + + var expectUsername = this.lastGroupTried + ? this._daemon.groupCredentials[this.lastGroupTried][0] + : this.expectedUsername; + if (param != expectUsername) { + return "481 Authentication failed"; + } + + this.usernameReceived = true; + if (this.requireBoth) { + return "381 Password required"; + } + + this.authenticated = this.lastGroupTried ? this.lastGroupTried : true; + return "281 Authentication Accepted"; + } else if (action == "pass") { + if (!this.requireBoth || !this.usernameReceived) { + return "482 Authentication commands issued out of sequence"; + } + + this.usernameReceived = false; + + var expectPassword = this.lastGroupTried + ? this._daemon.groupCredentials[this.lastGroupTried][1] + : this.expectedPassword; + if (param != expectPassword) { + return "481 Authentication failed"; + } + + this.authenticated = this.lastGroupTried ? this.lastGroupTried : true; + return "281 Authentication Accepted"; + } + return "502 Invalid Command"; + } + LIST(args) { + if (this.authenticated) { + return args ? super.LIST(args) : "502 Invalid command: LIST"; + } + return "480 Authentication required"; + } + GROUP(args) { + if ( + (this._daemon.groupCredentials != null && this.authenticated == args) || + (this._daemon.groupCredentials == null && this.authenticated) + ) { + return super.GROUP(args); + } + if (this._daemon.groupCredentials != null) { + this.lastGroupTried = args; + } + return "480 Authentication required"; + } +} diff --git a/comm/mailnews/test/fakeserver/Pop3d.jsm b/comm/mailnews/test/fakeserver/Pop3d.jsm new file mode 100644 index 0000000000..33a2b06a90 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Pop3d.jsm @@ -0,0 +1,454 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/** + * Contributors: + * Ben Bucksch (RFC 5034 Authentication) + */ +/* This file implements test POP3 servers + */ + +var EXPORTED_SYMBOLS = [ + "Pop3Daemon", + "POP3_RFC1939_handler", + "POP3_RFC2449_handler", + "POP3_RFC5034_handler", +]; + +var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import( + "resource://testing-common/mailnews/Auth.jsm" +); +var { mailTestUtils } = ChromeUtils.import( + "resource://testing-common/mailnews/MailTestUtils.jsm" +); + +// Since we don't really need to worry about peristence, we can just +// use a UIDL counter. +var gUIDLCount = 1; + +/** + * Read the contents of a file to the string. + * + * @param fileName A path relative to the current working directory, or + * a filename underneath the "data" directory relative to + * the cwd. + */ +function readFile(fileName) { + let cwd = Services.dirsvc.get("CurWorkD", Ci.nsIFile); + + // Try to find the file relative to either the data directory or to the + // current working directory. + let file = cwd.clone(); + if (fileName.includes("/")) { + let parts = fileName.split("/"); + for (let part of parts) { + if (part == "..") { + file = file.parent; + } else { + file.append(part); + } + } + } else { + file.append("data"); + file.append(fileName); + } + + if (!file.exists()) { + throw new Error("Cannot find file named " + fileName); + } + + return mailTestUtils.loadFileToString(file); +} + +class Pop3Daemon { + messages = []; + _messages = []; + _totalMessageSize = 0; + + /** + * Set the messages that the POP3 daemon will provide to its clients. + * + * @param messages An array of either 1) strings that are filenames whose + * contents will be loaded from the files or 2) objects with a "fileData" + * attribute whose value is the content of the file. + */ + setMessages(messages) { + this._messages = []; + this._totalMessageSize = 0; + + function addMessage(element) { + // if it's a string, then it's a file-name. + if (typeof element == "string") { + this._messages.push({ fileData: readFile(element), size: -1 }); + } else { + // Otherwise it's an object as dictionary already. + this._messages.push(element); + } + } + messages.forEach(addMessage, this); + + for (var i = 0; i < this._messages.length; ++i) { + this._messages[i].size = this._messages[i].fileData.length; + this._messages[i].uidl = "UIDL" + gUIDLCount++; + this._totalMessageSize += this._messages[i].size; + } + } + getTotalMessages() { + return this._messages.length; + } + getTotalMessageSize() { + return this._totalMessageSize; + } +} + +// POP3 TEST SERVERS +// ----------------- + +var kStateAuthNeeded = 1; // Not authenticated yet, need username and password +var kStateAuthPASS = 2; // got command USER, expecting command PASS +var kStateTransaction = 3; // Authenticated, can fetch and delete mail + +/** + * This handler implements the bare minimum required by RFC 1939. + * If dropOnAuthFailure is set, the server will drop the connection + * on authentication errors, to simulate servers that do the same. + */ +class POP3_RFC1939_handler { + kUsername = "fred"; + kPassword = "wilma"; + + constructor(daemon) { + this._daemon = daemon; + this.closing = false; + this.dropOnAuthFailure = false; + this._multiline = false; + this.resetTest(); + } + + resetTest() { + this._state = kStateAuthNeeded; + } + + USER(args) { + if (this._state != kStateAuthNeeded) { + return "-ERR invalid state"; + } + + if (args == this.kUsername) { + this._state = kStateAuthPASS; + return "+OK user recognized"; + } + + return "-ERR sorry, no such mailbox"; + } + PASS(args) { + if (this._state != kStateAuthPASS) { + return "-ERR invalid state"; + } + + if (args == this.kPassword) { + this._state = kStateTransaction; + return "+OK maildrop locked and ready"; + } + + this._state = kStateAuthNeeded; + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "-ERR invalid password"; + } + STAT(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + + return ( + "+OK " + + this._daemon.getTotalMessages() + + " " + + this._daemon.getTotalMessageSize() + ); + } + LIST(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + + var result = "+OK " + this._daemon._messages.length + " messages\r\n"; + for (var i = 0; i < this._daemon._messages.length; ++i) { + result += i + 1 + " " + this._daemon._messages[i].size + "\r\n"; + } + + result += "."; + return result; + } + UIDL(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + let result = "+OK\r\n"; + for (let i = 0; i < this._daemon._messages.length; ++i) { + result += i + 1 + " " + this._daemon._messages[i].uidl + "\r\n"; + } + + result += "."; + return result; + } + TOP(args) { + let [messageNumber, numberOfBodyLines] = args.split(" "); + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + let result = "+OK\r\n"; + let msg = this._daemon._messages[messageNumber - 1].fileData; + let index = msg.indexOf("\r\n\r\n"); + result += msg.slice(0, index); + if (numberOfBodyLines) { + result += "\r\n\r\n"; + let bodyLines = msg.slice(index + 4).split("\r\n"); + result += bodyLines.slice(0, numberOfBodyLines).join("\r\n"); + } + result += "\r\n."; + return result; + } + RETR(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + + var result = "+OK " + this._daemon._messages[args - 1].size + "\r\n"; + result += this._daemon._messages[args - 1].fileData; + result += "."; + return result; + } + DELE(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + return "+OK"; + } + NOOP(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + return "+OK"; + } + RSET(args) { + if (this._state != kStateTransaction) { + return "-ERR invalid state"; + } + this._state = kStateAuthNeeded; + return "+OK"; + } + QUIT(args) { + // Let the client close the socket + // this.closing = true; + return "+OK fakeserver signing off"; + } + onStartup() { + this.closing = false; + this._state = kStateAuthNeeded; + return "+OK Fake POP3 server ready"; + } + onError(command, args) { + return "-ERR command " + command + " not implemented"; + } + onServerFault(e) { + return "-ERR internal server error: " + e; + } + postCommand(reader) { + reader.setMultiline(this._multiline); + if (this.closing) { + reader.closeSocket(); + } + } +} + +/** + * This implements CAPA + * + * @see RFC 2449 + */ +class POP3_RFC2449_handler extends POP3_RFC1939_handler { + kCapabilities = ["UIDL", "TOP"]; // the test may adapt this as necessary + + CAPA(args) { + var capa = "+OK List of our wanna-be capabilities follows:\r\n"; + for (var i = 0; i < this.kCapabilities.length; i++) { + capa += this.kCapabilities[i] + "\r\n"; + } + if (this.capaAdditions) { + capa += this.capaAdditions(); + } + capa += "IMPLEMENTATION fakeserver\r\n."; + return capa; + } +} + +/** + * This implements the AUTH command, i.e. authentication using CRAM-MD5 etc. + * + * @see RFC 5034 + * @author Ben Bucksch + */ +class POP3_RFC5034_handler extends POP3_RFC2449_handler { + kAuthSchemes = ["CRAM-MD5", "PLAIN", "LOGIN"]; // the test may adapt this as necessary + _usedCRAMMD5Challenge = null; // not base64-encoded + + constructor(daemon) { + super(daemon); + + this._kAuthSchemeStartFunction = { + "CRAM-MD5": this.authCRAMStart, + PLAIN: this.authPLAINStart, + LOGIN: this.authLOGINStart, + }; + } + + // called by this.CAPA() + capaAdditions() { + var capa = ""; + if (this.kAuthSchemes.length > 0) { + capa += "SASL"; + for (var i = 0; i < this.kAuthSchemes.length; i++) { + capa += " " + this.kAuthSchemes[i]; + } + capa += "\r\n"; + } + return capa; + } + AUTH(lineRest) { + // |lineRest| is a string containing the rest of line after "AUTH " + if (this._state != kStateAuthNeeded) { + return "-ERR invalid state"; + } + + // AUTH without arguments returns a list of supported schemes + if (!lineRest) { + var capa = "+OK I like:\r\n"; + for (var i = 0; i < this.kAuthSchemes.length; i++) { + capa += this.kAuthSchemes[i] + "\r\n"; + } + capa += ".\r\n"; + return capa; + } + + var args = lineRest.split(" "); + var scheme = args[0].toUpperCase(); + // |scheme| contained in |kAuthSchemes|? + if ( + !this.kAuthSchemes.some(function (s) { + return s == scheme; + }) + ) { + return "-ERR AUTH " + scheme + " not supported"; + } + + var func = this._kAuthSchemeStartFunction[scheme]; + if (!func || typeof func != "function") { + return ( + "-ERR I just pretended to implement AUTH " + scheme + ", but I don't" + ); + } + return func.call(this, "1" in args ? args[1] : undefined); + } + + onMultiline(line) { + if (this._nextAuthFunction) { + var func = this._nextAuthFunction; + this._multiline = false; + this._nextAuthFunction = undefined; + if (line == "*") { + return "-ERR Okay, as you wish. Chicken"; + } + if (!func || typeof func != "function") { + return "-ERR I'm lost. Internal server error during auth"; + } + try { + return func.call(this, line); + } catch (e) { + return "-ERR " + e; + } + } + + if (super.onMultiline) { + // Call parent. + return super.onMultiline.call(this, line); + } + return undefined; + } + + authPLAINStart(lineRest) { + this._nextAuthFunction = this.authPLAINCred; + this._multiline = true; + + return "+"; + } + authPLAINCred(line) { + var req = AuthPLAIN.decodeLine(line); + if (req.username == this.kUsername && req.password == this.kPassword) { + this._state = kStateTransaction; + return "+OK Hello friend! Friends give friends good advice: Next time, use CRAM-MD5"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "-ERR Wrong username or password, crook!"; + } + + authCRAMStart(lineRest) { + this._nextAuthFunction = this.authCRAMDigest; + this._multiline = true; + + this._usedCRAMMD5Challenge = AuthCRAM.createChallenge("localhost"); + return "+ " + this._usedCRAMMD5Challenge; + } + authCRAMDigest(line) { + var req = AuthCRAM.decodeLine(line); + var expectedDigest = AuthCRAM.encodeCRAMMD5( + this._usedCRAMMD5Challenge, + this.kPassword + ); + if (req.username == this.kUsername && req.digest == expectedDigest) { + this._state = kStateTransaction; + return "+OK Hello friend!"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "-ERR Wrong username or password, crook!"; + } + + authLOGINStart(lineRest) { + this._nextAuthFunction = this.authLOGINUsername; + this._multiline = true; + + return "+ " + btoa("Username:"); + } + authLOGINUsername(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kUsername) { + this._nextAuthFunction = this.authLOGINPassword; + } else { + // Don't return error yet, to not reveal valid usernames. + this._nextAuthFunction = this.authLOGINBadUsername; + } + this._multiline = true; + return "+ " + btoa("Password:"); + } + authLOGINBadUsername(line) { + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "-ERR Wrong username or password, crook!"; + } + authLOGINPassword(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kPassword) { + this._state = kStateTransaction; + return "+OK Hello friend! Where did you pull out this old auth scheme?"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "-ERR Wrong username or password, crook!"; + } +} diff --git a/comm/mailnews/test/fakeserver/Smtpd.jsm b/comm/mailnews/test/fakeserver/Smtpd.jsm new file mode 100644 index 0000000000..646b626b86 --- /dev/null +++ b/comm/mailnews/test/fakeserver/Smtpd.jsm @@ -0,0 +1,274 @@ +/* -*- Mode: JavaScript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +// This file implements test SMTP servers + +var EXPORTED_SYMBOLS = ["SmtpDaemon", "SMTP_RFC2821_handler"]; + +var { AuthPLAIN, AuthLOGIN, AuthCRAM } = ChromeUtils.import( + "resource://testing-common/mailnews/Auth.jsm" +); + +class SmtpDaemon { + _messages = {}; +} + +// SMTP TEST SERVERS +// ----------------- + +var kStateAuthNeeded = 0; +var kStateAuthOptional = 2; +var kStateAuthenticated = 3; + +/** + * This handler implements the bare minimum required by RFC 2821. + * + * @see RFC 2821 + * If dropOnAuthFailure is set, the server will drop the connection + * on authentication errors, to simulate servers that do the same. + */ +class SMTP_RFC2821_handler { + kAuthRequired = false; + kUsername = "testsmtp"; + kPassword = "smtptest"; + kAuthSchemes = ["CRAM-MD5", "PLAIN", "LOGIN"]; + kCapabilities = ["8BITMIME", "SIZE", "CLIENTID"]; + _nextAuthFunction = undefined; + + constructor(daemon) { + this._daemon = daemon; + this.closing = false; + this.dropOnAuthFailure = false; + + this._kAuthSchemeStartFunction = { + "CRAM-MD5": this.authCRAMStart, + PLAIN: this.authPLAINStart, + LOGIN: this.authLOGINStart, + }; + + this.resetTest(); + } + + resetTest() { + this._state = this.kAuthRequired ? kStateAuthNeeded : kStateAuthOptional; + this._nextAuthFunction = undefined; + this._multiline = false; + this.expectingData = false; + this._daemon.post = ""; + } + EHLO(args) { + var capa = "250-fakeserver greets you"; + if (this.kCapabilities.length > 0) { + capa += "\n250-" + this.kCapabilities.join("\n250-"); + } + if (this.kAuthSchemes.length > 0) { + capa += "\n250-AUTH " + this.kAuthSchemes.join(" "); + } + capa += "\n250 HELP"; // the odd one: no "-", per RFC 2821 + return capa; + } + CLIENTID(args) { + return "250 ok"; + } + AUTH(lineRest) { + if (this._state == kStateAuthenticated) { + return "503 You're already authenticated"; + } + var args = lineRest.split(" "); + var scheme = args[0].toUpperCase(); + // |scheme| contained in |kAuthSchemes|? + if ( + !this.kAuthSchemes.some(function (s) { + return s == scheme; + }) + ) { + return "504 AUTH " + scheme + " not supported"; + } + var func = this._kAuthSchemeStartFunction[scheme]; + if (!func || typeof func != "function") { + return ( + "504 I just pretended to implement AUTH " + scheme + ", but I don't" + ); + } + dump("Starting AUTH " + scheme + "\n"); + return func.call(this, args.length > 1 ? args[1] : undefined); + } + MAIL(args) { + if (this._state == kStateAuthNeeded) { + return "530 5.7.0 Authentication required"; + } + return "250 ok"; + } + RCPT(args) { + if (this._state == kStateAuthNeeded) { + return "530 5.7.0 Authentication required"; + } + return "250 ok"; + } + DATA(args) { + if (this._state == kStateAuthNeeded) { + return "530 5.7.0 Authentication required"; + } + this.expectingData = true; + this._daemon.post = ""; + return "354 ok\n"; + } + RSET(args) { + return "250 ok\n"; + } + VRFY(args) { + if (this._state == kStateAuthNeeded) { + return "530 5.7.0 Authentication required"; + } + return "250 ok\n"; + } + EXPN(args) { + return "250 ok\n"; + } + HELP(args) { + return "211 ok\n"; + } + NOOP(args) { + return "250 ok\n"; + } + QUIT(args) { + this.closing = true; + return "221 done"; + } + onStartup() { + this.closing = false; + return "220 ok"; + } + + /** + * AUTH implementations + * + * @see RFC 4954 + */ + authPLAINStart(lineRest) { + if (lineRest) { + // all in one command, called initial client response, see RFC 4954 + return this.authPLAINCred(lineRest); + } + + this._nextAuthFunction = this.authPLAINCred; + this._multiline = true; + + return "334 "; + } + authPLAINCred(line) { + var req = AuthPLAIN.decodeLine(line); + if (req.username == this.kUsername && req.password == this.kPassword) { + this._state = kStateAuthenticated; + return "235 2.7.0 Hello friend! Friends give friends good advice: Next time, use CRAM-MD5"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "535 5.7.8 Wrong username or password, crook!"; + } + + authCRAMStart(lineRest) { + this._nextAuthFunction = this.authCRAMDigest; + this._multiline = true; + + this._usedCRAMMD5Challenge = AuthCRAM.createChallenge("localhost"); + return "334 " + this._usedCRAMMD5Challenge; + } + authCRAMDigest(line) { + var req = AuthCRAM.decodeLine(line); + var expectedDigest = AuthCRAM.encodeCRAMMD5( + this._usedCRAMMD5Challenge, + this.kPassword + ); + if (req.username == this.kUsername && req.digest == expectedDigest) { + this._state = kStateAuthenticated; + return "235 2.7.0 Hello friend!"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "535 5.7.8 Wrong username or password, crook!"; + } + + authLOGINStart(lineRest) { + this._nextAuthFunction = this.authLOGINUsername; + this._multiline = true; + + return "334 " + btoa("Username:"); + } + authLOGINUsername(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kUsername) { + this._nextAuthFunction = this.authLOGINPassword; + } else { + // Don't return error yet, to not reveal valid usernames. + this._nextAuthFunction = this.authLOGINBadUsername; + } + this._multiline = true; + return "334 " + btoa("Password:"); + } + authLOGINBadUsername(line) { + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "535 5.7.8 Wrong username or password, crook!"; + } + authLOGINPassword(line) { + var req = AuthLOGIN.decodeLine(line); + if (req == this.kPassword) { + this._state = kStateAuthenticated; + return "235 2.7.0 Hello friend! Where did you pull out this old auth scheme?"; + } + if (this.dropOnAuthFailure) { + this.closing = true; + } + return "535 5.7.8 Wrong username or password, crook!"; + } + + onError(command, args) { + return "500 Command " + command + " not recognized\n"; + } + onServerFault(e) { + return "451 Internal server error: " + e; + } + onMultiline(line) { + if (this._nextAuthFunction) { + var func = this._nextAuthFunction; + this._multiline = false; + this._nextAuthFunction = undefined; + if (line == "*") { + // abort, per RFC 4954 and others + return "501 Okay, as you wish. Chicken"; + } + if (!func || typeof func != "function") { + return "451 I'm lost. Internal server error during auth"; + } + try { + return func.call(this, line); + } catch (e) { + return "451 " + e; + } + } + if (line == ".") { + if (this.expectingData) { + this.expectingData = false; + return "250 Wonderful article, your style is gorgeous!"; + } + return "503 Huch? How did you get here?"; + } + + if (this.expectingData) { + if (line.startsWith(".")) { + line = line.substring(1); + } + // This uses CR LF to match with the specification + this._daemon.post += line + "\r\n"; + } + return undefined; + } + postCommand(reader) { + if (this.closing) { + reader.closeSocket(); + } + reader.setMultiline(this._multiline || this.expectingData); + } +} -- cgit v1.2.3