/* ***** BEGIN LICENSE BLOCK *****
 * Version: MPL 1.1/GPL 2.0/LGPL 2.1
 *
 * The contents of this file are subject to the Mozilla Public License Version
 * 1.1 (the "License"); you may not use this file except in compliance with
 * the License. You may obtain a copy of the License at
 * http://www.mozilla.org/MPL/
 *
 * Software distributed under the License is distributed on an "AS IS" basis,
 * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License
 * for the specific language governing rights and limitations under the
 * License.
 *
 * The Original Code is mozilla.org code.
 *
 * The Initial Developer of the Original Code is Mozilla Corporation.
 * Portions created by the Initial Developer are Copyright (C) 2007
 * the Initial Developer. All Rights Reserved.
 *
 * Contributor(s):
 *  Justin Dolske <dolske@mozilla.com> (original author)
 *
 * Alternatively, the contents of this file may be used under the terms of
 * either the GNU General Public License Version 2 or later (the "GPL"), or
 * the GNU Lesser General Public License Version 2.1 or later (the "LGPL"),
 * in which case the provisions of the GPL or the LGPL are applicable instead
 * of those above. If you wish to allow use of your version of this file only
 * under the terms of either the GPL or the LGPL, and not to allow others to
 * use your version of this file under the terms of the MPL, indicate your
 * decision by deleting the provisions above and replace them with the notice
 * and other provisions required by the GPL or the LGPL. If you do not delete
 * the provisions above, a recipient may use your version of this file under
 * the terms of any one of the MPL, the GPL or the LGPL.
 *
 * ***** END LICENSE BLOCK ***** */


const Cc = Components.classes;
const Ci = Components.interfaces;

Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");

function LoginManagerStorage_legacy() { };

LoginManagerStorage_legacy.prototype = {

    classDescription  : "LoginManagerStorage_legacy",
    contractID : "@mozilla.org/login-manager/storage/legacy;1",
    classID : Components.ID("{e09e4ca6-276b-4bb4-8b71-0635a3a2a007}"),
    QueryInterface : XPCOMUtils.generateQI([Ci.nsILoginManagerStorage,
                                    Ci.nsILoginManagerIEMigrationHelper]),

    __logService : null, // Console logging service, used for debugging.
    get _logService() {
        if (!this.__logService)
            this.__logService = Cc["@mozilla.org/consoleservice;1"].
                                getService(Ci.nsIConsoleService);
        return this.__logService;
    },

    __ioService: null, // IO service for string -> nsIURI conversion
    get _ioService() {
        if (!this.__ioService)
            this.__ioService = Cc["@mozilla.org/network/io-service;1"].
                               getService(Ci.nsIIOService);
        return this.__ioService;
    },

    __decoderRing : null,  // nsSecretDecoderRing service
    get _decoderRing() {
        if (!this.__decoderRing)
            this.__decoderRing = Cc["@mozilla.org/security/sdr;1"].
                                 getService(Ci.nsISecretDecoderRing);
        return this.__decoderRing;
    },

    __utfConverter : null, // UCS2 <--> UTF8 string conversion
    get _utfConverter() {
        if (!this.__utfConverter) {
            this.__utfConverter = Cc["@mozilla.org/intl/scriptableunicodeconverter"].
                                  createInstance(Ci.nsIScriptableUnicodeConverter);
            this.__utfConverter.charset = "UTF-8";
        }
        return this.__utfConverter;
    },

    _utfConverterReset : function() {
        this.__utfConverter = null;
    },

    __profileDir: null,  // nsIFile for the user's profile dir
    get _profileDir() {
        if (!this.__profileDir) {
            var dirService = Cc["@mozilla.org/file/directory_service;1"].
                             getService(Ci.nsIProperties);
            this.__profileDir = dirService.get("ProfD", Ci.nsIFile);
        }
        return this.__profileDir;
    },

    __nsLoginInfo: null,  // Constructor for nsILoginInfo implementation
    get _nsLoginInfo() {
        if (!this.__nsLoginInfo)
            this.__nsLoginInfo = new Components.Constructor(
                "@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo);
        return this.__nsLoginInfo;
    },

    _prefBranch : null,  // Preferences service

    _signonsFile : null,  // nsIFile for "signons3.txt" (or whatever pref is)
    _debug       : false, // mirrors signon.debug

    /*
     * A list of prefs that have been used to specify the filename for storing
     * logins. (We've used a number over time due to compatibility issues.)
     * This list is also used by _removeOldSignonsFile() to clean up old files.
     */
    _filenamePrefs : ["SignonFileName3", "SignonFileName2", "SignonFileName"],

    /*
     * Core datastructures
     *
     * EG: _logins["http://site.com"][0].password
     * EG: _disabledHosts["never.site.com"]
     */
    _logins        : null, 
    _disabledHosts : null,


    /*
     * log
     *
     * Internal function for logging debug messages to the Error Console.
     */
    log : function (message) {
        if (!this._debug)
            return;
        dump("PwMgr Storage: " + message + "\n");
        this._logService.logStringMessage("PwMgr Storage: " + message);
    },




    /* ==================== Public Methods ==================== */




    initWithFile : function(aInputFile, aOutputFile) {
        this._signonsFile = aInputFile;

        this.init();

        if (aOutputFile) {
            this._signonsFile = aOutputFile;
            this._writeFile();
        }
    },

    /*
     * init
     *
     * Initialize this storage component and load stored passwords from disk.
     */
    init : function () {
        this._logins  = {};
        this._disabledHosts = {};

        // Connect to the correct preferences branch.
        this._prefBranch = Cc["@mozilla.org/preferences-service;1"].
                           getService(Ci.nsIPrefService);
        this._prefBranch = this._prefBranch.getBranch("signon.");
        this._prefBranch.QueryInterface(Ci.nsIPrefBranch2);

        this._debug = this._prefBranch.getBoolPref("debug");

        // Check to see if the internal PKCS#11 token has been initialized.
        // If not, set a blank password.
        var tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].
                      getService(Ci.nsIPK11TokenDB);

        var token = tokenDB.getInternalKeyToken();
        if (token.needsUserInit) {
            this.log("Initializing key3.db with default blank password.");
            token.initPassword("");
        }

        var importFile = null;
        // If initWithFile is calling us, _signonsFile is already set.
        if (!this._signonsFile)
            [this._signonsFile, importFile] = this._getSignonsFile();

        // If we have an import file, do a switcharoo before reading it.
        if (importFile) {
            this.log("Importing " + importFile.path);

            var tmp = this._signonsFile;
            this._signonsFile = importFile;
        }

        // Read in the stored login data.
        this._readFile();

        // If we were importing, write back to the normal file.
        if (importFile) {
            this._signonsFile = tmp;
            this._writeFile();
        }
    },


    /*
     * addLogin
     *
     */
    addLogin : function (login) {
        // Throws if there are bogus values.
        this._checkLoginValues(login);

        // Clone the input. This ensures changes made by the caller to the
        // login (after calling addLogin) do no change the login we have.
        // Also, we rely on using login.wrappedJSObject, but can't rely on the
        // thing provided by the caller to support that.
        var clone = new this._nsLoginInfo();
        clone.init(login.hostname, login.formSubmitURL, login.httpRealm,
                   login.username,      login.password,
                   login.usernameField, login.passwordField);
        login = clone;

        var key = login.hostname;

        // If first entry for key, create an Array to hold its logins.
        var rollback;
        if (!this._logins[key]) {
            this._logins[key] = [];
            rollback = null;
        } else {
            rollback = this._logins[key].concat(); // clone array
        }

        this._logins[key].push(login);

        var ok = this._writeFile();

        // If we failed, don't keep the added login in memory.
        if (!ok) {
            if (rollback)
                this._logins[key] = rollback;
            else
                delete this._logins[key];

            throw "Couldn't write to file, login not added.";
        }
    },


    /*
     * removeLogin
     *
     */
    removeLogin : function (login) {
        var key = login.hostname;
        var logins = this._logins[key];

        if (!logins)
            throw "No logins found for hostname (" + key + ")";

        var rollback = this._logins[key].concat(); // clone array

        // The specified login isn't encrypted, so we need to ensure
        // the logins we're comparing with are decrypted. We decrypt one entry
        // at a time, lest _decryptLogins return fewer entries and screw up
        // indices between the two.
        for (var i = 0; i < logins.length; i++) {

            var [[decryptedLogin], userCanceled] =
                        this._decryptLogins([logins[i]]);

            if (userCanceled)
                throw "User canceled master password entry, login not removed.";

            if (!decryptedLogin)
                continue;

            if (decryptedLogin.equals(login)) {
                logins.splice(i, 1); // delete that login from array.
                break;
                // Note that if there are duplicate entries, they'll
                // have to be deleted one-by-one.
            }
        }

        // Did we delete the last login for this host?
        if (logins.length == 0)
            delete this._logins[key];

        var ok = this._writeFile();

        // If we failed, don't actually remove the login.
        if (!ok) {
            this._logins[key] = rollback;
            throw "Couldn't write to file, login not removed.";
        }
    },


    /*
     * modifyLogin
     *
     */
    modifyLogin : function (oldLogin, newLogin) {
        if (newLogin instanceof Ci.nsIPropertyBag)
            throw "legacy modifyLogin with propertybag not implemented.";
        newLogin.QueryInterface(Ci.nsILoginInfo);
        // Throws if there are bogus values.
        this._checkLoginValues(newLogin);

        this.removeLogin(oldLogin);
        this.addLogin(newLogin);
    },


    /*
     * getAllLogins
     *
     * Returns an array of nsAccountInfo.
     */
    getAllLogins : function (count) {
        var result = [], userCanceled;

        // Each entry is an array -- append the array entries to |result|.
        for each (var hostLogins in this._logins) {
            result = result.concat(hostLogins);
        }

        // decrypt entries for caller.
        [result, userCanceled] = this._decryptLogins(result);

        if (userCanceled)
            throw "User canceled Master Password entry";

        count.value = result.length; // needed for XPCOM
        return result;
    },


    /*
     * getAllEncryptedLogins
     *
     * Returns an array of nsAccountInfo, each in the encrypted state.
     */
    getAllEncryptedLogins : function (count) {
        var result = [];

        // Each entry is an array -- append the array entries to |result|.
        for each (var hostLogins in this._logins) {
            // Return copies to the caller. Prevents callers from modifying
            // our internal storage
            for each (var login in hostLogins) {
                var clone = new this._nsLoginInfo();
                clone.init(login.hostname, login.formSubmitURL, login.httpRealm,
                           login.wrappedJSObject.encryptedUsername,
                           login.wrappedJSObject.encryptedPassword,
                           login.usernameField, login.passwordField);
                result.push(clone);
            }
        }

        count.value = result.length; // needed for XPCOM
        return result;
    },


    /*
     * searchLogins
     *
     * Not implemented. This interface was added to perform arbitrary searches.
     * Since the legacy storage module is no longer used, there is no need to
     * implement it here.
     */
    searchLogins : function (count, matchData) {
        throw Components.results.NS_ERROR_NOT_IMPLEMENTED;
    },


    /*
     * removeAllLogins
     *
     * Removes all logins from storage.
     */
    removeAllLogins : function () {
        // Delete any old, unused files.
        this._removeOldSignonsFiles();

        // Disabled hosts kept, as one presumably doesn't want to erase those.
        this._logins = {};
        this._writeFile();
    },


    /*
     * getAllDisabledHosts
     *
     */
    getAllDisabledHosts : function (count) {
        var result = [];

        for (var hostname in this._disabledHosts) {
            result.push(hostname);
        }

        count.value = result.length; // needed for XPCOM
        return result;
    },


    /*
     * getLoginSavingEnabled
     *
     */
    getLoginSavingEnabled : function (hostname) {
        return !this._disabledHosts[hostname];
    },


    /*
     * setLoginSavingEnabled
     *
     */
    setLoginSavingEnabled : function (hostname, enabled) {
        // File format prohibits certain values. Also, nulls
        // won't round-trip with getAllDisabledHosts().
        if (hostname == "." ||
            hostname.indexOf("\r") != -1 ||
            hostname.indexOf("\n") != -1 ||
            hostname.indexOf("\0") != -1)
            throw "Invalid hostname";

        if (enabled)
            delete this._disabledHosts[hostname];
        else
            this._disabledHosts[hostname] = true;

        this._writeFile();
    },


    /*
     * findLogins
     *
     */
    findLogins : function (count, hostname, formSubmitURL, httpRealm) {
        var userCanceled;

        var logins = this._searchLogins(hostname, formSubmitURL, httpRealm);

        // Decrypt entries found for the caller.
        [logins, userCanceled] = this._decryptLogins(logins);

        // We want to throw in this case, so that the Login Manager
        // knows to stop processing forms on the page so the user isn't
        // prompted multiple times.
        if (userCanceled)
            throw "User canceled Master Password entry";

        count.value = logins.length; // needed for XPCOM
        return logins;
    },

    
    /*
     * countLogins
     *
     */
    countLogins : function (aHostname, aFormSubmitURL, aHttpRealm) {
        var logins;

        // Normal case: return direct results for the specified host.
        if (aHostname) {
            logins = this._searchLogins(aHostname, aFormSubmitURL, aHttpRealm);
            return logins.length
        } 

        // For consistency with how aFormSubmitURL and aHttpRealm work
        if (aHostname == null)
            return 0;

        // aHostname == "", so loop through each known host to match with each.
        var count = 0;
        for (var hostname in this._logins) {
            logins = this._searchLogins(hostname, aFormSubmitURL, aHttpRealm);
            count += logins.length;
        }

        return count;
    },




    /* ==================== Internal Methods ==================== */




    /*
     * _searchLogins
     *
     */
    _searchLogins : function (hostname, formSubmitURL, httpRealm) {
        var hostLogins = this._logins[hostname];
        if (hostLogins == null)
            return [];

        var result = [], userCanceled;

        for each (var login in hostLogins) {

            // If search arg is null, skip login unless it doesn't specify a
            // httpRealm (ie, it's also null). If the search arg is an empty
            // string, always match.
            if (httpRealm == null) {
                if (login.httpRealm != null)
                    continue;
            } else if (httpRealm != "") {
                // Make sure the realms match. If search arg is null,
                // only match if login doesn't specify a realm (is null)
                if (httpRealm != login.httpRealm)
                    continue;
            }

            // If search arg is null, skip login unless it doesn't specify a
            // action URL (ie, it's also null). If the search arg is an empty
            // string, always match.
            if (formSubmitURL == null) {
                if (login.formSubmitURL != null)
                    continue;
            } else if (formSubmitURL != "") {
                // If the stored login is blank (not null), that means the
                // login was stored before we started keeping the action
                // URL, so always match. Unless the search g
                if (login.formSubmitURL != "" &&
                    formSubmitURL != login.formSubmitURL)
                    continue;
            }

            result.push(login);
        }

        return result;
    },


    /*
     * _checkLoginValues
     *
     * Due to the way the signons2.txt file is formatted, we need to make
     * sure certain field values or characters do not cause the file to
     * be parse incorrectly. Reject logins that we can't store correctly.
     */
    _checkLoginValues : function (aLogin) {
        function badCharacterPresent(l, c) {
            return ((l.formSubmitURL && l.formSubmitURL.indexOf(c) != -1) ||
                    (l.httpRealm     && l.httpRealm.indexOf(c)     != -1) ||
                                        l.hostname.indexOf(c)      != -1  ||
                                        l.usernameField.indexOf(c) != -1  ||
                                        l.passwordField.indexOf(c) != -1);
        }

        // Nulls are invalid, as they don't round-trip well.
        // Mostly not a formatting problem, although ".\0" can be quirky.
        if (badCharacterPresent(aLogin, "\0"))
            throw "login values can't contain nulls";

        // In theory these nulls should just be rolled up into the encrypted
        // values, but nsISecretDecoderRing doesn't use nsStrings, so the
        // nulls cause truncation. Check for them here just to avoid
        // unexpected round-trip surprises.
        if (aLogin.username.indexOf("\0") != -1 ||
            aLogin.password.indexOf("\0") != -1)
            throw "login values can't contain nulls";

        // Newlines are invalid for any field stored as plaintext.
        if (badCharacterPresent(aLogin, "\r") ||
            badCharacterPresent(aLogin, "\n"))
            throw "login values can't contain newlines";

        // A line with just a "." can have special meaning.
        if (aLogin.usernameField == "." ||
            aLogin.formSubmitURL == ".")
            throw "login values can't be periods";

        // A hostname with "\ \(" won't roundtrip.
        // eg host="foo (", realm="bar" --> "foo ( (bar)"
        // vs host="foo", realm=" (bar" --> "foo ( (bar)"
        if (aLogin.hostname.indexOf(" (") != -1)
            throw "bad parens in hostname";
    },


    /*
     * _getSignonsFile
     *
     * Determines what file to use based on prefs. Returns it as a
     * nsILocalFile, along with a file to import from first (if needed)
     *
     */
    _getSignonsFile : function() {
        var destFile = null, importFile = null;

        // We've used a number of prefs over time due to compatibility issues.
        // Use the filename specified in the newest pref, but import from
        // older files if needed.
        for (var i = 0; i < this._filenamePrefs.length; i++) {
            var prefname = this._filenamePrefs[i];
            var filename = this._prefBranch.getCharPref(prefname);
            var file = this._profileDir.clone();
            file.append(filename);

            this.log("Checking file " + filename + " (" + prefname + ")");

            // First loop through, save the preferred filename.
            if (!destFile)
                destFile = file;
            else
                importFile = file;

            if (file.exists())
                return [destFile, importFile];
        }

        // If we can't find any existing file, use the preferred file.
        return [destFile, null];
    },


    /*
     * _removeOldSignonsFiles
     *
     * Deletes any storage files that we're not using any more.
     */
    _removeOldSignonsFiles : function() {
        // We've used a number of prefs over time due to compatibility issues.
        // Skip the first entry (the newest) and delete the others.
        for (var i = 1; i < this._filenamePrefs.length; i++) {
            var prefname = this._filenamePrefs[i];
            var filename = this._prefBranch.getCharPref(prefname);
            var file = this._profileDir.clone();
            file.append(filename);

            if (file.exists()) {
                this.log("Deleting old " + filename + " (" + prefname + ")");
                try {
                    file.remove(false);
                } catch (e) {
                    this.log("NOTICE: Couldn't delete " + filename + ": " + e);
                }
            }
        }
    },


    /*
     * _upgrade_entry_to_2E
     *
     * Updates the format of an entry from 2D to 2E. Returns an array of
     * logins (1 or 2), as sometimes updating an entry requires creating an
     * extra login.
     */
    _upgrade_entry_to_2E : function (aLogin) {
        var upgradedLogins = [aLogin];

        /*
         * For logins stored from HTTP channels
         *    - scheme needs to be derived and prepended
         *    - blank or missing realm becomes same as hostname.
         *
         *  "site.com:80"  --> "http://site.com"
         *  "site.com:443" --> "https://site.com"
         *  "site.com:123" --> Who knows! (So add both)
         *
         * Note: For HTTP logins, the hostname never contained a username
         *       or password. EG "user@site.com:80" shouldn't ever happen.
         *
         * Note: Proxy logins are also stored in this format.
         */
        if (aLogin.hostname.indexOf("://") == -1) {
            var oldHost = aLogin.hostname;

            // Check for a trailing port number, EG "site.com:80". If there's
            // no port, it wasn't saved by the browser and is probably some
            // arbitrary string picked by an extension.
            if (!/:\d+$/.test(aLogin.hostname)) {
                this.log("2E upgrade: no port, skipping " + aLogin.hostname);
                return upgradedLogins;
            }

            // Parse out "host:port".
            try {
                // Small hack: Need a scheme for nsIURI, so just prepend http.
                // We'll check for a port == -1 in case nsIURI ever starts
                // noticing that "http://foo:80" is using the default port.
                var uri = this._ioService.newURI("http://" + aLogin.hostname,
                                                 null, null);
                var host = uri.host;
                var port = uri.port;
            } catch (e) {
                this.log("2E upgrade: Can't parse hostname " + aLogin.hostname);
                return upgradedLogins;
            }

            if (port == 80 || port == -1)
                aLogin.hostname = "http://" + host;
            else if (port == 443)
                aLogin.hostname = "https://" + host;
            else {
                // Not a standard port! Could be either http or https!
                // (Or maybe it's a proxy login!) To try and avoid
                // breaking logins, we'll add *both* http and https
                // versions.
                this.log("2E upgrade: Cloning login for " + aLogin.hostname);

                aLogin.hostname = "http://" + host + ":" + port;

                var extraLogin = new this._nsLoginInfo();
                extraLogin.init("https://" + host + ":" + port,
                                null, aLogin.httpRealm,
                                aLogin.username, aLogin.password, "", "");
                // We don't have decrypted values, unless we're importing from IE,
                // so clone the encrypted bits into the new entry.
                extraLogin.wrappedJSObject.encryptedPassword = 
                    aLogin.wrappedJSObject.encryptedPassword;
                extraLogin.wrappedJSObject.encryptedUsername = 
                    aLogin.wrappedJSObject.encryptedUsername;

                if (extraLogin.httpRealm == "")
                    extraLogin.httpRealm = extraLogin.hostname;
                
                upgradedLogins.push(extraLogin);
            }

            // If the server didn't send a realm (or it was blank), we
            // previously didn't store anything.
            if (aLogin.httpRealm == "")
                aLogin.httpRealm = aLogin.hostname;

            this.log("2E upgrade: " + oldHost + " ---> " + aLogin.hostname);

            return upgradedLogins;
        }


        /*
         * For form logins and non-HTTP channel logins (both were stored in
         * the same format):
         *
         * Standardize URLs (.hostname and .actionURL)
         *    - remove default port numbers, if specified
         *      "http://site.com:80"  --> "http://site.com"
         *    - remove usernames from URL (may move into aLogin.username)
         *      "ftp://user@site.com" --> "ftp://site.com"
         *
         * Note: Passwords in the URL ("foo://user:pass@site.com") were not
         *       stored in FF2, so no need to try to move the value into
         *       aLogin.password.
         */

        // closures in cleanupURL
        var ioService = this._ioService;
        var log = this.log;

        function cleanupURL(aURL, allowJS) {
            var newURL, username = null, pathname = "";

            try {
                var uri = ioService.newURI(aURL, null, null);
                var scheme = uri.scheme;

                if (allowJS && scheme == "javascript")
                    return ["javascript:", null, ""];

                newURL = scheme + "://" + uri.host;

                // If the URL explicitly specified a port, only include it when
                // it's not the default. (We never want "http://foo.com:80")
                port = uri.port;
                if (port != -1) {
                    var handler = ioService.getProtocolHandler(scheme);
                    if (port != handler.defaultPort)
                        newURL += ":" + port;
                }

                // Could be a channel login with a username. 
                if (scheme != "http" && scheme != "https" && uri.username)
                    username = uri.username;

                if (uri.path != "/")
                    pathname = uri.path;

            } catch (e) {
                log("Can't cleanup URL: " + aURL + " e: " + e);
                newURL = aURL;
            }

            if (newURL != aURL)
                log("2E upgrade: " + aURL + " ---> " + newURL);

            return [newURL, username, pathname];
        }

        const isMailNews = /^(ldaps?|smtp|imap|news|mailbox):\/\//;

        // Old mailnews logins were protocol logins with a username/password
        // field name set.
        var isFormLogin = (aLogin.formSubmitURL ||
                           aLogin.usernameField ||
                           aLogin.passwordField) &&
                          !isMailNews.test(aLogin.hostname);

        var [hostname, username, pathname] = cleanupURL(aLogin.hostname);
        aLogin.hostname = hostname;

        // If a non-HTTP URL contained a username, it wasn't stored in the
        // encrypted username field (which contains an encrypted empty value)
        // (Don't do this if it's a form login, though.)
        if (username && !isFormLogin) {
            if (isMailNews.test(aLogin.hostname))
                try {
                    username = decodeURIComponent(username);
                }
                catch (ex) {
                    // It has been seen that some usernames cannot be decoded
                    // on upgrade, so if hit the case, log it and re-throw so
                    // that we can handle it in the caller.
                    this.log("Error decoding \"" + username + "\": " + ex);
                    throw(ex);
                }

            var [encUsername, userCanceled] = this._encrypt(username);
            if (!userCanceled)
                aLogin.wrappedJSObject.encryptedUsername = encUsername;
        }


        if (aLogin.formSubmitURL) {
            [hostname, username, pathname] = cleanupURL(aLogin.formSubmitURL,
                                                        true);
            aLogin.formSubmitURL = hostname;
            // username, if any, ignored.
        }


        /*
         * For logins stored from non-HTTP channels
         *    - Set httpRealm so they don't look like form logins
         *     "ftp://site.com" --> "ftp://site.com (ftp://site.com)"
         *
         * Tricky: Form logins and non-HTTP channel logins are stored in the
         * same format, and we don't want to add a realm to a form login.
         * Form logins have field names, so only update the realm if there are
         * no field names set. [Any login with a http[s]:// hostname is always
         * a form login, so explicitly ignore those just to be safe.]
         */
        const isHTTP = /^https?:\/\//;
        const isLDAP = /^ldaps?:\/\//;
        const isNews = /^news?:\/\//;
        if (!isHTTP.test(aLogin.hostname) && !isFormLogin) {
            // LDAP and News logins need to keep the path.
            if (isLDAP.test(aLogin.hostname) ||
                isNews.test(aLogin.hostname))
                aLogin.httpRealm = aLogin.hostname + pathname;
            else
                aLogin.httpRealm = aLogin.hostname;

            aLogin.formSubmitURL = null;

            // Null out the form items because mailnews will no longer treat
            // or expect these as form logins
            if (isMailNews.test(aLogin.hostname)) {
                aLogin.usernameField = "";
                aLogin.passwordField = "";
            }

            this.log("2E upgrade: set empty realm to " + aLogin.httpRealm);
        }

        return upgradedLogins;
    },


    /*
     * _readFile
     *
     */
    _readFile : function () {
        var formatVersion;

        this.log("Reading passwords from " + this._signonsFile.path);

        // If it doesn't exist, just bail out.
        if (!this._signonsFile.exists()) {
            this.log("No existing signons file found.");
            return;
        }

        var inputStream = Cc["@mozilla.org/network/file-input-stream;1"].
                          createInstance(Ci.nsIFileInputStream);
        // init the stream as RD_ONLY, -1 == default permissions.
        inputStream.init(this._signonsFile, 0x01, -1, null);
        var lineStream = inputStream.QueryInterface(Ci.nsILineInputStream);
        var line = { value: "" };

        const STATE = { HEADER : 0, REJECT : 1, REALM : 2,
                        USERFIELD : 3, USERVALUE : 4,
                        PASSFIELD : 5, PASSVALUE : 6, ACTIONURL : 7,
                        FILLER : 8 };
        var parseState = STATE.HEADER;

        var processEntry = false;
        var discardEntry = false;

        do {
            var hasMore = lineStream.readLine(line);
            try {
              line.value = this._utfConverter.ConvertToUnicode(line.value);
            } catch (e) {
              this.log("Bad UTF8 conversion: " + line.value);
              this._utfConverterReset();
            }

            switch (parseState) {
                // Check file header
                case STATE.HEADER:
                    if (line.value == "#2c") {
                        formatVersion = 0x2c;
                    } else if (line.value == "#2d") {
                        formatVersion = 0x2d;
                    } else if (line.value == "#2e") {
                        formatVersion = 0x2e;
                    } else {
                        this.log("invalid file header (" + line.value + ")");
                        throw "invalid file header in signons file";
                        // We could disable later writing to file, so we
                        // don't clobber whatever it is. ...however, that
                        // would mean corrupt files are not self-healing.
                        return;
                    }
                    parseState++;
                    break;

                // Line is a hostname for which passwords should never be saved.
                case STATE.REJECT:
                    if (line.value == ".") {
                        parseState++;
                        break;
                    }

                    this._disabledHosts[line.value] = true;

                    break;

                // Line is a hostname, saved login(s) will follow
                case STATE.REALM:
                    var hostrealm = line.value;

                    // Format is "http://site.com", with "(some realm)"
                    // appended if it's a HTTP-Auth login.
                    const realmFormat = /^(.+?)( \(.*\))?$/;
                    var matches = realmFormat.exec(hostrealm);
                    var hostname, httpRealm;
                    if (matches && matches.length == 3) {
                        hostname  = matches[1];
                        httpRealm = matches[2] ?
                                        matches[2].slice(2, -1) : null;
                    } else {
                        if (hostrealm != "") {
                            // Uhoh. This shouldn't happen, but try to deal.
                            this.log("Error parsing host/realm: " + hostrealm);
                        }
                        hostname = hostrealm;
                        httpRealm = null;
                    }

                    parseState++;
                    break;

                // Line is the HTML 'name' attribute for the username field
                // (or "." to indicate end of hostrealm)
                case STATE.USERFIELD:
                    if (line.value == ".") {
                        discardEntry = false;
                        parseState = STATE.REALM;
                        break;
                    }

                    // If we're discarding the entry, keep looping in this
                    // state until we hit the "." marking the end of the entry.
                    if (discardEntry)
                        break;

                    var entry = new this._nsLoginInfo();
                    entry.hostname  = hostname;
                    entry.httpRealm = httpRealm;

                    entry.usernameField = line.value;
                    parseState++;
                    break;

                // Line is a username
                case STATE.USERVALUE:
                    entry.wrappedJSObject.encryptedUsername = line.value;
                    parseState++;
                    break;

                // Line is the HTML 'name' attribute for the password field,
                // with a leading '*' character
                case STATE.PASSFIELD:
                    if (line.value.charAt(0) != '*') {
                        discardEntry = true;
                        entry = null;
                        parseState = STATE.USERFIELD;
                        break;
                    }
                    entry.passwordField = line.value.substr(1);
                    parseState++;
                    break;

                // Line is a password
                case STATE.PASSVALUE:
                    entry.wrappedJSObject.encryptedPassword = line.value;

                    // Version 2C doesn't have an ACTIONURL  line, so
                    // process entry now.
                    if (formatVersion < 0x2d)
                        processEntry = true;

                    parseState++;
                    break;

                // Line is the action URL
                case STATE.ACTIONURL:
                    var formSubmitURL = line.value;
                    if (!formSubmitURL && entry.httpRealm != null)
                        entry.formSubmitURL = null;
                    else
                        entry.formSubmitURL = formSubmitURL;

                    // Version 2D doesn't have a FILLER line, so
                    // process entry now.
                    if (formatVersion < 0x2e)
                        processEntry = true;

                    parseState++;
                    break;

                // Line is unused filler for future use
                case STATE.FILLER:
                    // Save the line's value (so we can dump it back out when
                    // we save the file next time) for forwards compatability.
                    entry.wrappedJSObject.filler = line.value;
                    processEntry = true;

                    parseState++;
                    break;
            }

            // If we've read all the lines for the current entry,
            // process it and reset the parse state for the next entry.
            if (processEntry) {
                if (formatVersion < 0x2d) {
                    // A blank, non-null value is handled as a wildcard.
                    if (entry.httpRealm != null)
                        entry.formSubmitURL = null;
                    else
                        entry.formSubmitURL = "";
                }

                // Upgrading an entry to 2E can sometimes result in the need
                // to create an extra login.
                var entries = [entry];
                if (formatVersion < 0x2e) {
                    try {
                      entries = this._upgrade_entry_to_2E(entry);
                    }
                    catch (ex) {
                      // For some reason we couldn't decode this entry,
                      // therefore, drop it and carry on so that we can
                      // hopefully translate the other entries.
                      entries = [];
                    }
                }

                for each (var e in entries) {
                    if (!this._logins[e.hostname])
                        this._logins[e.hostname] = [];
                    this._logins[e.hostname].push(e);
                }

                entry = null;
                processEntry = false;
                parseState = STATE.USERFIELD;
            }
        } while (hasMore);

        lineStream.close();

        return;
    },


    /*
     * _writeFile
     *
     * Returns true if the operation was successfully completed, or false
     * if there was an error (probably the user refusing to enter a
     * master password if prompted).
     */
    _writeFile : function () {
        var converter = this._utfConverter;
        function writeLine(data) {
            data = converter.ConvertFromUnicode(data);
            data += converter.Finish();
            data += "\r\n";
            outputStream.write(data, data.length);
        }

        this.log("Writing passwords to " + this._signonsFile.path);

        var safeStream = Cc["@mozilla.org/network/safe-file-output-stream;1"].
                         createInstance(Ci.nsIFileOutputStream);
        // WR_ONLY|CREAT|TRUNC
        safeStream.init(this._signonsFile, 0x02 | 0x08 | 0x20, 0600, null);

        var outputStream = Cc["@mozilla.org/network/buffered-output-stream;1"].
                           createInstance(Ci.nsIBufferedOutputStream);
        outputStream.init(safeStream, 8192);
        outputStream.QueryInterface(Ci.nsISafeOutputStream); // for .finish()


        // write file version header
        writeLine("#2e");

        // write disabled logins list
        for (var hostname in this._disabledHosts) {
            writeLine(hostname);
        }

        // write end-of-reject-list marker
        writeLine(".");

        for (var hostname in this._logins) {
            function sortByRealm(a,b) {
                a = a.httpRealm;
                b = b.httpRealm;

                if (!a && !b)
                    return  0;

                if (!a || a < b)
                    return -1;

                if (!b || b > a)
                    return  1;

                return 0; // a==b, neither is null
            }

            // Sort logins by httpRealm. This allows us to group multiple
            // logins for the same realm together.
            this._logins[hostname].sort(sortByRealm);


            // write each login known for the host
            var lastRealm = null;
            var firstEntry = true;
            var userCanceled = false;
            for each (var login in this._logins[hostname]) {

                // If this login is for a new realm, start a new entry.
                if (login.httpRealm != lastRealm || firstEntry) {
                    // end previous entry, if needed.
                    if (!firstEntry)
                        writeLine(".");

                    var hostrealm = login.hostname;
                    if (login.httpRealm)
                        hostrealm += " (" + login.httpRealm + ")";

                    writeLine(hostrealm);
                }

                firstEntry = false;

                // Get the encrypted value of the username. Newly added
                // logins will need the plaintext value encrypted.
                var encUsername = login.wrappedJSObject.encryptedUsername;
                if (!encUsername) {
                    [encUsername, userCanceled] = this._encrypt(login.username);
                    login.wrappedJSObject.encryptedUsername = encUsername;
                }

                if (userCanceled)
                    break;

                // Get the encrypted value of the password. Newly added
                // logins will need the plaintext value encrypted.
                var encPassword = login.wrappedJSObject.encryptedPassword;
                if (!encPassword) {
                    [encPassword, userCanceled] = this._encrypt(login.password);
                    login.wrappedJSObject.encryptedPassword = encPassword;
                }

                if (userCanceled)
                    break;


                writeLine((login.usernameField ?  login.usernameField : ""));
                writeLine(encUsername);
                writeLine("*" +
                    (login.passwordField ?  login.passwordField : ""));
                writeLine(encPassword);
                writeLine((login.formSubmitURL ? login.formSubmitURL : ""));
                if (login.wrappedJSObject.filler)
                    writeLine(login.wrappedJSObject.filler);
                else
                    writeLine("---");

                lastRealm = login.httpRealm;
            }

            if (userCanceled) {
                this.log("User canceled Master Password, aborting write.");
                // .close will cause an abort w/o modifying original file
                outputStream.close();
                return false;
            }

            // write end-of-host marker
            writeLine(".");
        }

        // [if there were no hosts, no end-of-host marker (".") needed]

        outputStream.finish();
        return true;
    },


    /*
     * _decryptLogins
     *
     * Decrypts username and password fields in the provided array of
     * logins. This is deferred from the _readFile() code, so that
     * the user is not prompted for a master password (if set) until
     * the entries are actually used.
     *
     * The entries specified by the array will be decrypted, if possible.
     * An array of successfully decrypted logins will be returned. The return
     * value should be given to external callers (since still-encrypted
     * entries are useless), whereas internal callers generally don't want
     * to lose unencrypted entries (eg, because the user clicked Cancel
     * instead of entering their master password)
     */
    _decryptLogins : function (logins) {
        var result = [], userCanceled = false;

        for each (var login in logins) {
            var decryptedUsername, decryptedPassword;

            [decryptedUsername, userCanceled] =
                this._decrypt(login.wrappedJSObject.encryptedUsername);

            if (userCanceled)
                break;

            [decryptedPassword, userCanceled] =
                this._decrypt(login.wrappedJSObject.encryptedPassword);

            // Probably can't hit this case, but for completeness...
            if (userCanceled)
                break;

            // If decryption failed (corrupt entry?) skip it. Note that we
            // allow password-only logins, so decryptedUsername can be "".
            if (decryptedUsername == null || !decryptedPassword)
                continue;

            // Return copies to the caller. Prevents callers from modifying
            // our internal stoage, and helps avoid keeping decrypted data in
            // memory (although this is fuzzy, because of GC issues).
            var clone = new this._nsLoginInfo();
            clone.init(login.hostname, login.formSubmitURL, login.httpRealm,
                       decryptedUsername, decryptedPassword,
                       login.usernameField, login.passwordField);

            // Old mime64-obscured entries should be opportunistically
            // reencrypted in the new format.
            var recrypted;
            if (login.wrappedJSObject.encryptedUsername &&
                login.wrappedJSObject.encryptedUsername.charAt(0) == '~') {
                  [recrypted, userCanceled] = this._encrypt(decryptedUsername);

                  if (userCanceled)
                    break;

                  login.wrappedJSObject.encryptedUsername = recrypted;
            }

            if (login.wrappedJSObject.encryptedPassword &&
                login.wrappedJSObject.encryptedPassword.charAt(0) == '~') {
                  [recrypted, userCanceled] = this._encrypt(decryptedPassword);

                  if (userCanceled)
                    break;

                  login.wrappedJSObject.encryptedPassword = recrypted;
            }

            result.push(clone);
        }

        return [result, userCanceled];
    },


    /*
     * _encrypt
     *
     * Encrypts the specified string, using the SecretDecoderRing.
     *
     * Returns [cipherText, userCanceled] where:
     *  cipherText   -- the encrypted string, or null if it failed.
     *  userCanceled -- if the encryption failed, this is true if the
     *                  user selected Cancel when prompted to enter their
     *                  Master Password. The caller should bail out, and not
     *                  not request that more things be encrypted (which 
     *                  results in prompting the user for a Master Password
     *                  over and over.)
     */
    _encrypt : function (plainText) {
        var cipherText = null, userCanceled = false;

        try {
            var plainOctet = this._utfConverter.ConvertFromUnicode(plainText);
            plainOctet += this._utfConverter.Finish();
            cipherText = this._decoderRing.encryptString(plainOctet);
        } catch (e) {
            this.log("Failed to encrypt string. (" + e.name + ")");
            // If the user clicks Cancel, we get NS_ERROR_FAILURE.
            // (unlike decrypting, which gets NS_ERROR_NOT_AVAILABLE).
            if (e.result == Components.results.NS_ERROR_FAILURE)
                userCanceled = true;
        }

        return [cipherText, userCanceled];
    },


    /*
     * _decrypt
     *
     * Decrypts the specified string, using the SecretDecoderRing.
     *
     * Returns [plainText, userCanceled] where:
     *  plainText    -- the decrypted string, or null if it failed.
     *  userCanceled -- if the decryption failed, this is true if the
     *                  user selected Cancel when prompted to enter their
     *                  Master Password. The caller should bail out, and not
     *                  not request that more things be decrypted (which 
     *                  results in prompting the user for a Master Password
     *                  over and over.)
     */
    _decrypt : function (cipherText) {
        var plainText = null, userCanceled = false;

        try {
            var plainOctet;
            if (cipherText.charAt(0) == '~') {
                // The older file format obscured entries by
                // base64-encoding them. These entries are signaled by a
                // leading '~' character. 
                plainOctet = atob(cipherText.substring(1));
            } else {
                plainOctet = this._decoderRing.decryptString(cipherText);
            }
            plainText = this._utfConverter.ConvertToUnicode(plainOctet);
        } catch (e) {
            this.log("Failed to decrypt string: " + cipherText +
                " (" + e.name + ")");

            // In the unlikely event the converter threw, reset it.
            this._utfConverterReset();

            // If the user clicks Cancel, we get NS_ERROR_NOT_AVAILABLE.
            // If the cipherText is bad / wrong key, we get NS_ERROR_FAILURE
            // Wrong passwords are handled by the decoderRing reprompting;
            // we get no notification.
            if (e.result == Components.results.NS_ERROR_NOT_AVAILABLE)
                userCanceled = true;
        }

        return [plainText, userCanceled];
    },




    /* ================== nsILoginManagerIEMigratorHelper ================== */




    _migrationLoginManager : null,

    /*
     * migrateAndAddLogin
     *
     * Given a login with IE6-formatted fields, migrates it to the new format
     * and adds it to the login manager.
     *
     * Experimentally derived format of IE6 logins, see:
     *     https://bugzilla.mozilla.org/attachment.cgi?id=319346
     *
     * HTTP AUTH:
     * - hostname is always "example.com:123"
     *   * "example.com", "http://example.com", "http://example.com:80" all
     *     end up as just "example.com:80"
     *   * Entering "example.com:80" in the URL bar isn't recognized as a
     *     valid URL by IE6.
     *   * "https://example.com" is saved as "example.com:443"
     *   * "https://example.com:666" is saved as "example.com:666". Thus, for
     *     non-standard ports we don't know the right scheme, so create both.
     *
     * - an empty or missing "realm" in the WWW-Authenticate reply is stored
     *   as just an empty string by IE6.
     *
     * - IE6 will store logins where one or both (!) of the username/password
     *   is left blank. We don't support logins without a password, so these
     *   logins won't be added [addLogin() will throw].
     *
     * - IE6 won't recognize a URL with and embedded username/password (eg
     *   http://user@example.com, http://user:pass@example.com), so these
     *   shouldn't be encountered.
     *
     * - Our migration code doesn't extract non-HTTP logins (eg, FTP). So
     *   they shouldn't be encountered here. (Verified by saving FTP logins
     *   in IE and then importing in Firefox.)
     *
     *
     * FORM LOGINS:
     * - hostname is "http://site.com" or "https://site.com".
     *   * scheme always included
     *   * default port not included
     * - port numbers, even for non-standard posts, are never present!
     *   unfortunately, this means logins will only work on the default
     *   port, because we don't know what the original was (or even that
     *   it wasn't originally stored for the original port).
     * - Logins are stored without a field name by IE, but we look one up
     *   in the migrator for the username. The password field name will
     *   always be empty-string.
     */
    migrateAndAddLogin : function (aLogin) {
        // Initialize outself on the first call
        if (!this._migrationLoginManager) {
            // Connect to the correct preferences branch.
            this._prefBranch = Cc["@mozilla.org/preferences-service;1"].
                               getService(Ci.nsIPrefService);
            this._prefBranch = this._prefBranch.getBranch("signon.");
            this._prefBranch.QueryInterface(Ci.nsIPrefBranch2);

            this._debug = this._prefBranch.getBoolPref("debug");

            this._migrationLoginManager = Cc["@mozilla.org/login-manager;1"].
                                          getService(Ci.nsILoginManager);
        }

        this.log("Migrating login for " + aLogin.hostname);

        // The IE login is in the same format as the old password
        // manager entries, so just reuse that code.
        var logins = this._upgrade_entry_to_2E(aLogin);

        // Add logins via the login manager (and not this.addLogin),
        // lest an alternative storage module be in use.
        for each (var login in logins)
            this._migrationLoginManager.addLogin(login);
    }
}; // end of nsLoginManagerStorage_legacy implementation

var component = [LoginManagerStorage_legacy];
function NSGetModule(compMgr, fileSpec) {
    return XPCOMUtils.generateModule(component);
}
