Bug 1134846 - Add a module to support per-site password manager recipes. r=dolske draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Fri, 27 Feb 2015 17:43:00 -0800
changeset 249002 03bb184d12e28cf194817c3789cbae4b20e9a8f3
parent 249001 4692b08e462e0f5b4dfae3d6e58dda0f0d1dfede
child 505586 deda442cd306213cf809d0ff3d77a40e0cd7190a
push id965
push usermozilla@noorenberghe.ca
push dateTue, 10 Mar 2015 01:14:45 +0000
reviewersdolske
bugs1134846
milestone39.0a1
Bug 1134846 - Add a module to support per-site password manager recipes. r=dolske
toolkit/components/passwordmgr/LoginManagerParent.jsm
toolkit/components/passwordmgr/LoginRecipes.jsm
toolkit/components/passwordmgr/moz.build
toolkit/components/passwordmgr/test/unit/head.js
toolkit/components/passwordmgr/test/unit/test_recipes.js
toolkit/components/passwordmgr/test/unit/xpcshell.ini
--- a/toolkit/components/passwordmgr/LoginManagerParent.jsm
+++ b/toolkit/components/passwordmgr/LoginManagerParent.jsm
@@ -1,21 +1,21 @@
 /* vim: set ts=2 sts=2 sw=2 et tw=80: */
 /* 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/. */
 
 "use strict";
 
-const Cu = Components.utils;
-const Ci = Components.interfaces;
-const Cc = Components.classes;
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
 
+Cu.importGlobalProperties(["URL"]);
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/Task.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "UserAutoCompleteResult",
                                   "resource://gre/modules/LoginManagerContent.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AutoCompleteE10S",
                                   "resource://gre/modules/AutoCompleteE10S.jsm");
 
 this.EXPORTED_SYMBOLS = [ "LoginManagerParent" ];
 
@@ -60,28 +60,34 @@ prefChanged();
 
 var LoginManagerParent = {
   init: function() {
     let mm = Cc["@mozilla.org/globalmessagemanager;1"]
                .getService(Ci.nsIMessageListenerManager);
     mm.addMessageListener("RemoteLogins:findLogins", this);
     mm.addMessageListener("RemoteLogins:onFormSubmit", this);
     mm.addMessageListener("RemoteLogins:autoCompleteLogins", this);
+
+    XPCOMUtils.defineLazyGetter(this, "recipeParentPromise", () => {
+      const { LoginRecipesParent } = Cu.import("resource://gre/modules/LoginRecipes.jsm", {});
+      let parent = new LoginRecipesParent();
+      return parent.init();
+    });
   },
 
   receiveMessage: function (msg) {
     let data = msg.data;
     switch (msg.name) {
       case "RemoteLogins:findLogins": {
         // TODO Verify msg.target's principals against the formOrigin?
-        this.findLogins(data.options.showMasterPassword,
-                        data.formOrigin,
-                        data.actionOrigin,
-                        data.requestId,
-                        msg.target.messageManager);
+        this.sendLoginDataToChild(data.options.showMasterPassword,
+                                  data.formOrigin,
+                                  data.actionOrigin,
+                                  data.requestId,
+                                  msg.target.messageManager);
         break;
       }
 
       case "RemoteLogins:onFormSubmit": {
         // TODO Verify msg.target's principals against the formOrigin?
         this.onFormSubmit(data.hostname,
                           data.formSubmitURL,
                           data.usernameField,
@@ -94,29 +100,45 @@ var LoginManagerParent = {
 
       case "RemoteLogins:autoCompleteLogins": {
         this.doAutocompleteSearch(data, msg.target);
         break;
       }
     }
   },
 
-  findLogins: function(showMasterPassword, formOrigin, actionOrigin,
-                       requestId, target) {
+  /**
+   * Send relevant data (e.g. logins and recipes) to the child process (LoginManagerContent).
+   */
+  sendLoginDataToChild: Task.async(function*(showMasterPassword, formOrigin, actionOrigin,
+                                             requestId, target) {
+    let recipes = [];
+    if (formOrigin) {
+      let formHost = (new URL(formOrigin)).host;
+      let recipeManager = yield this.recipeParentPromise;
+      recipes = [...recipeManager.getRecipesForHost(formHost)];
+    }
+
     if (!showMasterPassword && !Services.logins.isLoggedIn) {
-      target.sendAsyncMessage("RemoteLogins:loginsFound",
-                              { requestId: requestId, logins: [] });
+      target.sendAsyncMessage("RemoteLogins:loginsFound", {
+        requestId: requestId,
+        logins: [],
+        recipes,
+      });
       return;
     }
 
     let allLoginsCount = Services.logins.countLogins(formOrigin, "", null);
     // If there are no logins for this site, bail out now.
     if (!allLoginsCount) {
-      target.sendAsyncMessage("RemoteLogins:loginsFound",
-                              { requestId: requestId, logins: [] });
+      target.sendAsyncMessage("RemoteLogins:loginsFound", {
+        requestId: requestId,
+        logins: [],
+        recipes,
+      });
       return;
     }
 
     // If we're currently displaying a master password prompt, defer
     // processing this form until the user handles the prompt.
     if (Services.logins.uiBusy) {
       log("deferring onFormPassword for", formOrigin);
       let self = this;
@@ -125,23 +147,26 @@ var LoginManagerParent = {
                                                Ci.nsISupportsWeakReference]),
 
         observe: function (subject, topic, data) {
           log("Got deferred onFormPassword notification:", topic);
           // Only run observer once.
           Services.obs.removeObserver(this, "passwordmgr-crypto-login");
           Services.obs.removeObserver(this, "passwordmgr-crypto-loginCanceled");
           if (topic == "passwordmgr-crypto-loginCanceled") {
-            target.sendAsyncMessage("RemoteLogins:loginsFound",
-                                    { requestId: requestId, logins: [] });
+            target.sendAsyncMessage("RemoteLogins:loginsFound", {
+              requestId: requestId,
+              logins: [],
+              recipes,
+            });
             return;
           }
 
-          self.findLogins(showMasterPassword, formOrigin, actionOrigin,
-                          requestId, target);
+          self.sendLoginDataToChild(showMasterPassword, formOrigin, actionOrigin,
+                                    requestId, target);
         },
       };
 
       // Possible leak: it's possible that neither of these notifications
       // will fire, and if that happens, we'll leak the observer (and
       // never return). We should guarantee that at least one of these
       // will fire.
       // See bug XXX.
@@ -152,28 +177,29 @@ var LoginManagerParent = {
 
     var logins = Services.logins.findLogins({}, formOrigin, actionOrigin, null);
     // Convert the array of nsILoginInfo to vanilla JS objects since nsILoginInfo
     // doesn't support structured cloning.
     var jsLogins = JSON.parse(JSON.stringify(logins));
     target.sendAsyncMessage("RemoteLogins:loginsFound", {
       requestId: requestId,
       logins: jsLogins,
+      recipes,
     });
 
     const PWMGR_FORM_ACTION_EFFECT =  Services.telemetry.getHistogramById("PWMGR_FORM_ACTION_EFFECT");
     if (logins.length == 0) {
       PWMGR_FORM_ACTION_EFFECT.add(2);
     } else if (logins.length == allLoginsCount) {
       PWMGR_FORM_ACTION_EFFECT.add(0);
     } else {
       // logins.length < allLoginsCount
       PWMGR_FORM_ACTION_EFFECT.add(1);
     }
-  },
+  }),
 
   doAutocompleteSearch: function({ formOrigin, actionOrigin,
                                    searchString, previousResult,
                                    rect, requestId, remote }, target) {
     // Note: previousResult is a regular object, not an
     // nsIAutoCompleteResult.
     var result;
 
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/LoginRecipes.jsm
@@ -0,0 +1,131 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["LoginRecipesContent", "LoginRecipesParent"];
+
+const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
+const REQUIRED_KEYS = ["hosts"];
+const OPTIONAL_KEYS = ["description", "pathRegex"];
+const SUPPORTED_KEYS = REQUIRED_KEYS.concat(OPTIONAL_KEYS);
+
+Cu.importGlobalProperties(["URL"]);
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper",
+                                  "resource://gre/modules/LoginHelper.jsm");
+
+XPCOMUtils.defineLazyGetter(this, "log", () => LoginHelper.getLogger("LoginRecipes"));
+
+function LoginRecipesParent() {
+  if (Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT) {
+    throw new Error("LoginRecipesParent should only be used on the main process");
+  }
+
+  this._recipesByHost = new Map();
+}
+
+LoginRecipesParent.prototype = {
+
+  _initializationPromise: null,
+
+  /**
+   * @type {Map} Map of hosts (including non-default port numbers) to Sets of recipes.
+   *             e.g. "example.com:8080" => Set({...})
+   */
+  _recipesByHost: null,
+
+  /**
+   * @return {Promise} resolved with the module when the module is ready.
+   */
+  init() {
+    if (this._initializationPromise) {
+      return this._initializationPromise;
+    }
+
+    return this._initializationPromise = Promise.resolve(this);
+  },
+
+  /**
+   * @param {Object} aRecipes - an object containing recipes to load for use
+   * @return {Promise} resolving when the recipes are loaded
+   */
+  load(aRecipes) {
+    for (let rawRecipe of aRecipes.siteRecipes) {
+      try {
+        let recipe = {
+          description: rawRecipe.description,
+          hosts: rawRecipe.hosts,
+        };
+        recipe.pathRegex = rawRecipe.pathRegex ? new RegExp(rawRecipe.pathRegex) : undefined;
+        this.add(recipe);
+      } catch (ex) {
+        log.error("Error loading recipe", rawRecipe, ex);
+      }
+    }
+
+    return Promise.resolve();
+  },
+
+  /**
+   * Validate the recipe is sane and then add it to the set of recipes.
+   *
+   * @param {Object} recipe
+   */
+  add(recipe) {
+    log.debug("Adding recipe:", recipe);
+    let recipeKeys = Object.keys(recipe);
+    let unknownKeys = recipeKeys.filter(key => SUPPORTED_KEYS.indexOf(key) == -1);
+    if (unknownKeys.length > 0) {
+      throw new Error("The following recipe keys aren't supported: " + unknownKeys.join(", "));
+    }
+
+    let missingRequiredKeys = REQUIRED_KEYS.filter(key => recipeKeys.indexOf(key) == -1);
+    if (missingRequiredKeys.length > 0) {
+      throw new Error("The following required recipe keys are missing: " + missingRequiredKeys.join(", "));
+    }
+
+    if (!Array.isArray(recipe.hosts)) {
+      throw new Error("'hosts' must be a array");
+    }
+
+    if (!recipe.hosts.length) {
+      throw new Error("'hosts' must be a non-empty array");
+    }
+
+    if (recipe.pathRegex && recipe.pathRegex.constructor.name != "RegExp") {
+      throw new Error("'pathRegex' must be a regular expression");
+    }
+
+    if (recipe.description && typeof(recipe.description) != "string") {
+      throw new Error("'description' must be a string");
+    }
+
+    // Add the recipe to the map for each host
+    for (let host of recipe.hosts) {
+      if (!this._recipesByHost.has(host)) {
+        this._recipesByHost.set(host, new Set());
+      }
+      this._recipesByHost.get(host).add(recipe);
+    }
+  },
+
+  /**
+   * Currently only exact host matches are returned but this will eventually handle parent domains.
+   *
+   * @param {String} aHost (e.g. example.com:8080 [non-default port] or sub.example.com)
+   * @return {Set} of recipes that apply to the host ordered by host priority
+   */
+  getRecipesForHost(aHost) {
+    let hostRecipes = this._recipesByHost.get(aHost);
+    if (!hostRecipes) {
+      return new Set();
+    }
+
+    return hostRecipes;
+  },
+};
--- a/toolkit/components/passwordmgr/moz.build
+++ b/toolkit/components/passwordmgr/moz.build
@@ -31,16 +31,17 @@ EXTRA_PP_COMPONENTS += [
     'passwordmgr.manifest',
 ]
 
 EXTRA_JS_MODULES += [
     'InsecurePasswordUtils.jsm',
     'LoginHelper.jsm',
     'LoginManagerContent.jsm',
     'LoginManagerParent.jsm',
+    'LoginRecipes.jsm',
 ]
 
 if CONFIG['OS_TARGET'] == 'Android':
     EXTRA_COMPONENTS += [
         'storage-mozStorage.js',
     ]
 else:
     EXTRA_COMPONENTS += [
--- a/toolkit/components/passwordmgr/test/unit/head.js
+++ b/toolkit/components/passwordmgr/test/unit/head.js
@@ -11,16 +11,17 @@
 
 ////////////////////////////////////////////////////////////////////////////////
 //// Globals
 
 const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/LoginRecipes.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "DownloadPaths",
                                   "resource://gre/modules/DownloadPaths.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "OS",
                                   "resource://gre/modules/osfile.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Promise",
@@ -193,16 +194,22 @@ const LoginTest = {
    * since January 1, 1970, 00:00:00 UTC, falls within 30 seconds of now.
    */
   assertTimeIsAboutNow: function (aTimeMs)
   {
     do_check_true(Math.abs(aTimeMs - Date.now()) < 30000);
   }
 };
 
+const RecipeHelpers = {
+  initNewParent() {
+    return (new LoginRecipesParent()).init();
+  },
+};
+
 ////////////////////////////////////////////////////////////////////////////////
 //// Predefined test data
 
 /**
  * This object contains functions that return new instances of nsILoginInfo for
  * every call.  The returned instances can be compared using their "equals" or
  * "matches" methods, or modified for the needs of the specific test being run.
  *
new file mode 100644
--- /dev/null
+++ b/toolkit/components/passwordmgr/test/unit/test_recipes.js
@@ -0,0 +1,143 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests adding and retrieving LoginRecipes in the parent process.
+ */
+
+"use strict";
+
+Cu.import("resource://gre/modules/Task.jsm");
+
+add_task(function* test_init() {
+  let parent = new LoginRecipesParent();
+  let initPromise1 = parent.init();
+  let initPromise2 = parent.init();
+  Assert.strictEqual(initPromise1, initPromise2, "Check that the same promise is returned");
+
+  let recipesParent = yield initPromise1;
+  Assert.ok(recipesParent instanceof LoginRecipesParent, "Check init return value");
+  Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+});
+
+add_task(function* test_get_missing_host() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  let exampleRecipes = recipesParent.getRecipesForHost("example.invalid");
+  Assert.strictEqual(exampleRecipes.size, 0, "Check recipe count for example.invalid");
+
+});
+
+add_task(function* test_add_get_simple_host() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.strictEqual(recipesParent._recipesByHost.size, 0, "Initially 0 recipes");
+  recipesParent.add({
+    hosts: ["example.com"],
+  });
+  Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+                     "Check number of hosts after the addition");
+
+  let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+  Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+  let recipe = [...exampleRecipes][0];
+  Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+  Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+  Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+});
+
+add_task(function* test_add_get_non_standard_port_host() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  recipesParent.add({
+    hosts: ["example.com:8080"],
+  });
+  Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+                     "Check number of hosts after the addition");
+
+  let exampleRecipes = recipesParent.getRecipesForHost("example.com:8080");
+  Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com:8080");
+  let recipe = [...exampleRecipes][0];
+  Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+  Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+  Assert.strictEqual(recipe.hosts[0], "example.com:8080", "Check the one host");
+});
+
+add_task(function* test_add_multiple_hosts() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  recipesParent.add({
+    hosts: ["example.com", "foo.invalid"],
+  });
+  Assert.strictEqual(recipesParent._recipesByHost.size, 2,
+                     "Check number of hosts after the addition");
+
+  let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+  Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+  let recipe = [...exampleRecipes][0];
+  Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+  Assert.strictEqual(recipe.hosts.length, 2, "Check that two hosts are present");
+  Assert.strictEqual(recipe.hosts[0], "example.com", "Check the first host");
+  Assert.strictEqual(recipe.hosts[1], "foo.invalid", "Check the second host");
+
+  let fooRecipes = recipesParent.getRecipesForHost("foo.invalid");
+  Assert.strictEqual(fooRecipes.size, 1, "Check recipe count for foo.invalid");
+  let fooRecipe = [...fooRecipes][0];
+  Assert.strictEqual(fooRecipe, recipe, "Check that the recipe is shared");
+  Assert.strictEqual(typeof(fooRecipe), "object", "Check recipe type");
+  Assert.strictEqual(fooRecipe.hosts.length, 2, "Check that two hosts are present");
+  Assert.strictEqual(fooRecipe.hosts[0], "example.com", "Check the first host");
+  Assert.strictEqual(fooRecipe.hosts[1], "foo.invalid", "Check the second host");
+});
+
+add_task(function* test_add_pathRegex() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  recipesParent.add({
+    hosts: ["example.com"],
+    pathRegex: /^\/mypath\//,
+  });
+  Assert.strictEqual(recipesParent._recipesByHost.size, 1,
+                     "Check number of hosts after the addition");
+
+  let exampleRecipes = recipesParent.getRecipesForHost("example.com");
+  Assert.strictEqual(exampleRecipes.size, 1, "Check recipe count for example.com");
+  let recipe = [...exampleRecipes][0];
+  Assert.strictEqual(typeof(recipe), "object", "Check recipe type");
+  Assert.strictEqual(recipe.hosts.length, 1, "Check that one host is present");
+  Assert.strictEqual(recipe.hosts[0], "example.com", "Check the one host");
+  Assert.strictEqual(recipe.pathRegex.toString(), "/^\\/mypath\\//", "Check the pathRegex");
+});
+
+/* Begin checking errors with add */
+
+add_task(function* test_add_missing_prop() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.throws(() => recipesParent.add({}), /required/, "Some properties are required");
+});
+
+add_task(function* test_add_unknown_prop() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.throws(() => recipesParent.add({
+    unknownProp: true,
+  }), /supported/, "Unknown properties should cause an error to help with typos");
+});
+
+add_task(function* test_add_invalid_hosts() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.throws(() => recipesParent.add({
+    hosts: 404,
+  }), /array/, "hosts should be an array");
+});
+
+add_task(function* test_add_empty_host_array() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.throws(() => recipesParent.add({
+    hosts: [],
+  }), /array/, "hosts should be a non-empty array");
+});
+
+add_task(function* test_add_pathRegex_non_regexp() {
+  let recipesParent = yield RecipeHelpers.initNewParent();
+  Assert.throws(() => recipesParent.add({
+    hosts: ["example.com"],
+    pathRegex: "foo",
+  }), /regular expression/, "pathRegex should be a RegExp");
+});
+
+/* End checking errors with add */
--- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini
+++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini
@@ -18,10 +18,11 @@ skip-if = os != "android"
 [test_disabled_hosts.js]
 [test_legacy_empty_formSubmitURL.js]
 [test_legacy_validation.js]
 [test_logins_change.js]
 [test_logins_decrypt_failure.js]
 [test_logins_metainfo.js]
 [test_logins_search.js]
 [test_notifications.js]
+[test_recipes.js]
 [test_storage.js]
 [test_telemetry.js]