Bug 1213990 Clear storage when webextension is uninstalled r?kmag draft
authorAndrew Swan <aswan@mozilla.com>
Mon, 01 Aug 2016 16:30:18 -0700
changeset 397661 1130e891fdf0acac5791f313ad66ea989e6dab60
parent 397660 ae206a96200cc93250e9876dcab0a45a7d8340e6
child 527498 41c2056611024f88c5a1ee35edc522e4672ebcb5
push id25345
push useraswan@mozilla.com
push dateSun, 07 Aug 2016 17:04:11 +0000
reviewerskmag
bugs1213990
milestone51.0a1
Bug 1213990 Clear storage when webextension is uninstalled r?kmag MozReview-Commit-ID: BeMOxOCSeru
modules/libpref/init/all.js
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_shutdown_cleanup.html
toolkit/components/extensions/test/mochitest/test_chrome_ext_storage_cleanup.html
--- a/modules/libpref/init/all.js
+++ b/modules/libpref/init/all.js
@@ -4634,16 +4634,20 @@ pref("browser.meta_refresh_when_inactive
 // XPInstall prefs
 pref("xpinstall.whitelist.required", true);
 // Only Firefox requires add-on signatures
 pref("xpinstall.signatures.required", false);
 pref("extensions.alwaysUnpack", false);
 pref("extensions.minCompatiblePlatformVersion", "2.0");
 pref("extensions.webExtensionsMinPlatformVersion", "42.0a1");
 
+// Other webextensions prefs
+pref("extensions.webextensions.keepStorageOnUninstall", false);
+pref("extensions.webextensions.keepUuidOnUninstall", false);
+
 pref("network.buffer.cache.count", 24);
 pref("network.buffer.cache.size",  32768);
 
 // Desktop Notification
 pref("notification.feature.enabled", false);
 
 // Web Notification
 pref("dom.webnotifications.enabled", true);
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -27,16 +27,18 @@ Cu.import("resource://gre/modules/XPCOMU
 Cu.import("resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "ExtensionAPIs",
                                   "resource://gre/modules/ExtensionAPI.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorage",
+                                  "resource://gre/modules/ExtensionStorage.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "FileUtils",
                                   "resource://gre/modules/FileUtils.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
                                   "resource://gre/modules/MatchPattern.jsm");
@@ -84,16 +86,18 @@ var {
   Messenger,
   injectAPI,
   instanceOf,
   flushJarCache,
 } = ExtensionUtils;
 
 const LOGGER_ID_BASE = "addons.webextension.";
 const UUID_MAP_PREF = "extensions.webextensions.uuids";
+const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall";
+const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall";
 
 const COMMENT_REGEXP = new RegExp(String.raw`
     ^
     (
       (?:
         [^"] |
         " (?:[^"\\] | \\.)* "
       )*?
@@ -530,34 +534,59 @@ function getExtensionUUID(id) {
   return UUIDMap.get(id, true);
 }
 
 // For extensions that have called setUninstallURL(), send an event
 // so the browser can display the URL.
 var UninstallObserver = {
   initialized: false,
 
-  init: function() {
+  init() {
     if (!this.initialized) {
       AddonManager.addAddonListener(this);
+      XPCOMUtils.defineLazyPreferenceGetter(this, "leaveStorage", LEAVE_STORAGE_PREF, false);
+      XPCOMUtils.defineLazyPreferenceGetter(this, "leaveUuid", LEAVE_UUID_PREF, false);
       this.initialized = true;
     }
   },
 
-  uninit: function() {
-    if (this.initialized) {
-      AddonManager.removeAddonListener(this);
-      this.initialized = false;
+  onUninstalling(addon) {
+    let extension = GlobalManager.extensionMap.get(addon.id);
+    if (extension) {
+      // Let any other interested listeners respond
+      // (e.g., display the uninstall URL)
+      Management.emit("uninstall", extension);
     }
   },
 
-  onUninstalling: function(addon) {
-    let extension = GlobalManager.extensionMap.get(addon.id);
-    if (extension) {
-      Management.emit("uninstall", extension);
+  onUninstalled(addon) {
+    let uuid = UUIDMap.get(addon.id, false);
+    if (!uuid) {
+      return;
+    }
+
+    if (!this.leaveStorage) {
+      // Clear browser.local.storage
+      ExtensionStorage.clear(addon.id);
+
+      // Clear any IndexedDB storage created by the extension
+      let baseURI = NetUtil.newURI(`moz-extension://${uuid}/`);
+      let principal = Services.scriptSecurityManager.createCodebasePrincipal(
+        baseURI, {addonId: addon.id}
+      );
+      Services.qms.clearStoragesForPrincipal(principal);
+
+      // Clear localStorage created by the extension
+      let attrs = JSON.stringify({addonId: addon.id});
+      Services.obs.notifyObservers(null, "clear-origin-data", attrs);
+    }
+
+    if (!this.leaveUuid) {
+      // Clear the entry in the UUID map
+      UUIDMap.remove(addon.id);
     }
   },
 };
 
 // Responsible for loading extension APIs into the right globals.
 GlobalManager = {
   // Map[extension ID -> Extension]. Determines which extension is
   // responsible for content under a particular extension ID.
@@ -574,17 +603,16 @@ GlobalManager = {
     this.extensionMap.set(extension.id, extension);
   },
 
   uninit(extension) {
     this.extensionMap.delete(extension.id);
 
     if (this.extensionMap.size == 0 && this.initialized) {
       Services.obs.removeObserver(this, "content-document-global-created");
-      UninstallObserver.uninit();
       this.initialized = false;
     }
   },
 
   getExtension(extensionId) {
     return this.extensionMap.get(extensionId);
   },
 
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -19,8 +19,9 @@ skip-if = (os == 'android') # browser.ta
 skip-if = os != "mac" && os != "linux"
 [test_ext_cookies_expiry.html]
 skip-if = buildapp == 'b2g'
 [test_ext_cookies_permissions.html]
 skip-if = buildapp == 'b2g'
 [test_ext_jsversion.html]
 skip-if = buildapp == 'b2g'
 [test_ext_schema.html]
+[test_chrome_ext_storage_cleanup.html]
--- a/toolkit/components/extensions/test/mochitest/test_chrome_ext_shutdown_cleanup.html
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_shutdown_cleanup.html
@@ -13,47 +13,38 @@
 <script type="text/javascript">
 "use strict";
 
 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://testing-common/TestUtils.jsm");
 
-const {
- GlobalManager,
- UninstallObserver,
-} = Cu.import("resource://gre/modules/Extension.jsm");
+const {GlobalManager} = Cu.import("resource://gre/modules/Extension.jsm");
 
 /* eslint-disable mozilla/balanced-listeners */
 
 add_task(function* testShutdownCleanup() {
   is(GlobalManager.initialized, false,
      "GlobalManager start as not initialized");
-  is(UninstallObserver.initialized, false,
-     "UninstallObserver start as not initialized");
 
   let extension = ExtensionTestUtils.loadExtension({
     background: "new " + function() {
       browser.test.notifyPass("background page loaded");
     },
   });
 
   yield extension.startup();
 
   yield extension.awaitFinish("background page loaded");
 
   is(GlobalManager.initialized, true,
      "GlobalManager has been initialized once an extension is started");
-  is(UninstallObserver.initialized, true,
-     "UninstallObserver has been initialized once an extension is started");
 
   yield extension.unload();
 
   is(GlobalManager.initialized, false,
      "GlobalManager has been uninitialized once all the webextensions have been stopped");
-  is(UninstallObserver.initialized, false,
-     "UninstallObserver has been uninitialized once all the webextensions have been stopped");
 });
 </script>
 
 </body>
 </html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_storage_cleanup.html
@@ -0,0 +1,161 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>WebExtension test</title>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script type="text/javascript" src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+// Test that storage used by a webextension (through localStorage,
+// indexedDB, and browser.storage.local) gets cleaned up when the
+// extension is uninstalled.
+add_task(function* test_uninstall() {
+  function writeData() {
+    localStorage.setItem("hello", "world");
+
+    let idbPromise = new Promise((resolve, reject) => {
+      let req = indexedDB.open("test");
+      req.onerror = e => {
+        reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+      };
+
+      req.onupgradeneeded = e => {
+        let db = e.target.result;
+        db.createObjectStore("store", {keyPath: "name"});
+      };
+
+      req.onsuccess = e => {
+        let db = e.target.result;
+        let transaction = db.transaction("store", "readwrite");
+        let addreq = transaction.objectStore("store")
+                                .add({name: "hello", value: "world"});
+        addreq.onerror = e => {
+          reject(new Error(`add to indexedDB failed with ${e.errorCode}`));
+        };
+        addreq.onsuccess = e => {
+          resolve();
+        };
+      };
+    });
+
+    let browserStoragePromise = browser.storage.local.set({hello: "world"});
+
+    Promise.all([idbPromise, browserStoragePromise]).then(() => {
+      browser.test.sendMessage("finished");
+    });
+  }
+
+  function readData() {
+    let matchLocalStorage = (localStorage.getItem("hello") == "world");
+
+    let idbPromise = new Promise((resolve, reject) => {
+      let req = indexedDB.open("test");
+      req.onerror = e => {
+        reject(new Error(`indexedDB open failed with ${e.errorCode}`));
+      };
+
+      req.onupgradeneeded = e => {
+        // no database, data is not present
+        resolve(false);
+      };
+
+      req.onsuccess = e => {
+        let db = e.target.result;
+        let transaction = db.transaction("store", "readwrite");
+        let addreq = transaction.objectStore("store").get("hello");
+        addreq.onerror = e => {
+          reject(new Error(`read from indexedDB failed with ${e.errorCode}`));
+        };
+        addreq.onsuccess = e => {
+          let match = (addreq.result.value == "world");
+          resolve(match);
+        };
+      };
+    });
+
+    let browserStoragePromise = browser.storage.local.get("hello").then(result => {
+      return (Object.keys(result).length == 1 && result.hello == "world");
+    });
+
+    Promise.all([idbPromise, browserStoragePromise])
+           .then(([matchIDB, matchBrowserStorage]) => {
+             let result = {matchLocalStorage, matchIDB, matchBrowserStorage};
+             browser.test.sendMessage("results", result);
+           });
+  }
+
+  const ID = "storage.cleanup@tests.mozilla.org";
+
+  // Use a test-only pref to leave the addonid->uuid mapping around after
+  // uninstall so that we can re-attach to the same storage.  Also set
+  // the pref to prevent cleaning up storage on uninstall so we can test
+  // that the "keep uuid" logic works correctly.  Do the storage flag in
+  // a separate prefEnv so we can pop it below, leaving the uuid flag set.
+  yield SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.keepUuidOnUninstall", true]],
+  });
+  yield SpecialPowers.pushPrefEnv({
+    set: [["extensions.webextensions.keepStorageOnUninstall", true]],
+  });
+
+  let extension = ExtensionTestUtils.loadExtension({
+    background: `(${writeData})()`,
+    manifest: {
+      permissions: ["storage"],
+    },
+    useAddonManager: "temporary",
+  }, ID);
+
+  yield extension.startup();
+  yield extension.awaitMessage("finished");
+  yield extension.unload();
+
+  // Check that we can still see data we wrote to storage but clear the
+  // "leave storage" flag so our storaged gets cleared on uninstall.
+  // This effectively tests the keepUuidOnUninstall logic, which ensures
+  // that when we read storage again and check that it is cleared, that
+  // it is actually a meaningful test!
+  yield SpecialPowers.popPrefEnv();
+  extension = ExtensionTestUtils.loadExtension({
+    background: `(${readData})()`,
+    manifest: {
+      permissions: ["storage"],
+    },
+    useAddonManager: "temporary",
+  }, ID);
+
+  yield extension.startup();
+  let results = yield extension.awaitMessage("results");
+  is(results.matchLocalStorage, true, "localStorage data is still present");
+  is(results.matchIDB, true, "indexedDB data is still present");
+  is(results.matchBrowserStorage, true, "browser.storage.local data is still present");
+
+  yield extension.unload();
+
+  // Read again.  This time, our data should be gone.
+  extension = ExtensionTestUtils.loadExtension({
+    background: `(${readData})()`,
+    manifest: {
+      permissions: ["storage"],
+    },
+    useAddonManager: "temporary",
+  }, ID);
+
+  yield extension.startup();
+  results = yield extension.awaitMessage("results");
+  is(results.matchLocalStorage, false, "localStorage data was cleared");
+  is(results.matchIDB, false, "indexedDB data was cleared");
+  is(results.matchBrowserStorage, false, "browser.storage.local data was cleared");
+  yield extension.unload();
+});
+</script>
+
+</body>
+</html>