summaryrefslogtreecommitdiffstats
path: root/toolkit/components/contextualidentity
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/contextualidentity')
-rw-r--r--toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs655
-rw-r--r--toolkit/components/contextualidentity/moz.build14
-rw-r--r--toolkit/components/contextualidentity/tests/unit/test_basic.html71
-rw-r--r--toolkit/components/contextualidentity/tests/unit/test_basic.js149
-rw-r--r--toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js169
-rw-r--r--toolkit/components/contextualidentity/tests/unit/test_migratedFile.js121
-rw-r--r--toolkit/components/contextualidentity/tests/unit/xpcshell.ini9
7 files changed, 1188 insertions, 0 deletions
diff --git a/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
new file mode 100644
index 0000000000..a0c0357db3
--- /dev/null
+++ b/toolkit/components/contextualidentity/ContextualIdentityService.sys.mjs
@@ -0,0 +1,655 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+// The maximum valid numeric value for the userContextId.
+const MAX_USER_CONTEXT_ID = -1 >>> 0;
+const LAST_CONTAINERS_JSON_VERSION = 4;
+const SAVE_DELAY_MS = 1500;
+const CONTEXTUAL_IDENTITY_ENABLED_PREF = "privacy.userContext.enabled";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyGetter(lazy, "gBrowserBundle", function () {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gTextDecoder", function () {
+ return new TextDecoder();
+});
+
+XPCOMUtils.defineLazyGetter(lazy, "gTextEncoder", function () {
+ return new TextEncoder();
+});
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs",
+ DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs",
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+function _TabRemovalObserver(resolver, remoteTabIds) {
+ this._resolver = resolver;
+ this._remoteTabIds = remoteTabIds;
+ Services.obs.addObserver(this, "ipc:browser-destroyed");
+}
+
+_TabRemovalObserver.prototype = {
+ _resolver: null,
+ _remoteTabIds: null,
+
+ QueryInterface: ChromeUtils.generateQI(["nsIObserver"]),
+
+ observe(subject, topic, data) {
+ let remoteTab = subject.QueryInterface(Ci.nsIRemoteTab);
+ if (this._remoteTabIds.has(remoteTab.tabId)) {
+ this._remoteTabIds.delete(remoteTab.tabId);
+ if (this._remoteTabIds.size == 0) {
+ Services.obs.removeObserver(this, "ipc:browser-destroyed");
+ this._resolver();
+ }
+ }
+ },
+};
+
+function _ContextualIdentityService(path) {
+ this.init(path);
+}
+
+_ContextualIdentityService.prototype = {
+ LAST_CONTAINERS_JSON_VERSION,
+
+ _userIdentities: [
+ {
+ icon: "fingerprint",
+ color: "blue",
+ l10nID: "userContextPersonal.label",
+ accessKey: "userContextPersonal.accesskey",
+ },
+ {
+ icon: "briefcase",
+ color: "orange",
+ l10nID: "userContextWork.label",
+ accessKey: "userContextWork.accesskey",
+ },
+ {
+ icon: "dollar",
+ color: "green",
+ l10nID: "userContextBanking.label",
+ accessKey: "userContextBanking.accesskey",
+ },
+ {
+ icon: "cart",
+ color: "pink",
+ l10nID: "userContextShopping.label",
+ accessKey: "userContextShopping.accesskey",
+ },
+ ],
+ _systemIdentities: [
+ {
+ public: false,
+ icon: "",
+ color: "",
+ name: "userContextIdInternal.thumbnail",
+ accessKey: "",
+ },
+ // This userContextId is used by ExtensionStorageIDB.jsm to create an IndexedDB database
+ // opened with the extension principal but not directly accessible to the extension code
+ // (do not change the userContextId assigned here, otherwise the installed extensions will
+ // not be able to access the data previously stored with the browser.storage.local API).
+ {
+ userContextId: MAX_USER_CONTEXT_ID,
+ public: false,
+ icon: "",
+ color: "",
+ name: "userContextIdInternal.webextStorageLocal",
+ accessKey: "",
+ },
+ ],
+
+ _defaultIdentities: [],
+
+ _identities: null,
+ _openedIdentities: new Set(),
+ _lastUserContextId: 0,
+
+ _path: null,
+ _dataReady: false,
+
+ _saver: null,
+
+ init(path) {
+ this._path = path;
+
+ Services.prefs.addObserver(CONTEXTUAL_IDENTITY_ENABLED_PREF, this);
+
+ // Initialize default identities based on policy if available
+ this._defaultIdentities = [];
+ let userContextId = 1;
+ let policyIdentities =
+ Services.policies?.getActivePolicies()?.Containers?.Default;
+ if (policyIdentities) {
+ for (let identity of policyIdentities) {
+ identity.public = true;
+ identity.userContextId = userContextId;
+ userContextId++;
+ this._defaultIdentities.push(identity);
+ }
+ } else {
+ for (let identity of this._userIdentities) {
+ identity.public = true;
+ identity.userContextId = userContextId;
+ userContextId++;
+ this._defaultIdentities.push(identity);
+ }
+ }
+ for (let identity of this._systemIdentities) {
+ if (!("userContextId" in identity)) {
+ identity.userContextId = userContextId;
+ userContextId++;
+ }
+ this._defaultIdentities.push(identity);
+ }
+ },
+
+ async observe(aSubject, aTopic) {
+ if (aTopic === "nsPref:changed") {
+ const contextualIdentitiesEnabled = Services.prefs.getBoolPref(
+ CONTEXTUAL_IDENTITY_ENABLED_PREF
+ );
+ if (!contextualIdentitiesEnabled) {
+ await this.closeContainerTabs();
+ this.notifyAllContainersCleared();
+ this.resetDefault();
+ }
+ }
+ },
+
+ load() {
+ return IOUtils.read(this._path).then(
+ bytes => {
+ // If synchronous loading happened in the meantime, exit now.
+ if (this._dataReady) {
+ return;
+ }
+
+ try {
+ this.parseData(bytes);
+ } catch (error) {
+ this.loadError(error);
+ }
+ },
+ error => {
+ this.loadError(error);
+ }
+ );
+ },
+
+ resetDefault() {
+ this._identities = [];
+
+ // Compute the last used context id (excluding the reserved userContextIds
+ // "userContextIdInternal.webextStorageLocal" which has UINT32_MAX as its
+ // userContextId).
+ this._lastUserContextId = this._defaultIdentities
+ .filter(identity => identity.userContextId < MAX_USER_CONTEXT_ID)
+ .map(identity => identity.userContextId)
+ .sort((a, b) => a >= b)
+ .pop();
+
+ // Clone the array
+ for (let identity of this._defaultIdentities) {
+ this._identities.push(Object.assign({}, identity));
+ }
+ this._openedIdentities = new Set();
+
+ this._dataReady = true;
+
+ // Let's delete all the data of any userContextId. 1 is the first valid
+ // userContextId value.
+ this.deleteContainerData();
+
+ this.saveSoon();
+ },
+
+ loadError(error) {
+ if (error != null && error.name != "NotFoundError") {
+ // Let's report the error.
+ console.error(error);
+ }
+
+ // If synchronous loading happened in the meantime, exit now.
+ if (this._dataReady) {
+ return;
+ }
+
+ this.resetDefault();
+ },
+
+ saveSoon() {
+ if (!this._saver) {
+ this._saverCallback = () => this._saver.finalize();
+
+ this._saver = new lazy.DeferredTask(() => this.save(), SAVE_DELAY_MS);
+ lazy.AsyncShutdown.profileBeforeChange.addBlocker(
+ "ContextualIdentityService: writing data",
+ this._saverCallback
+ );
+ } else {
+ this._saver.disarm();
+ }
+
+ this._saver.arm();
+ },
+
+ save() {
+ lazy.AsyncShutdown.profileBeforeChange.removeBlocker(this._saverCallback);
+
+ this._saver = null;
+ this._saverCallback = null;
+
+ let object = {
+ version: LAST_CONTAINERS_JSON_VERSION,
+ lastUserContextId: this._lastUserContextId,
+ identities: this._identities,
+ };
+
+ let bytes = lazy.gTextEncoder.encode(JSON.stringify(object));
+ return IOUtils.write(this._path, bytes, {
+ tmpPath: this._path + ".tmp",
+ });
+ },
+
+ create(name, icon, color) {
+ this.ensureDataReady();
+
+ // Retrieve the next userContextId available.
+ let userContextId = ++this._lastUserContextId;
+
+ // Throw an error if the next userContextId available is invalid (the one associated to
+ // MAX_USER_CONTEXT_ID is already reserved to "userContextIdInternal.webextStorageLocal", which
+ // is also the last valid userContextId available, because its userContextId is equal to UINT32_MAX).
+ if (userContextId >= MAX_USER_CONTEXT_ID) {
+ throw new Error(
+ `Unable to create a new userContext with id '${userContextId}'`
+ );
+ }
+
+ if (!name.trim()) {
+ throw new Error(
+ "Contextual identity names cannot contain only whitespace."
+ );
+ }
+
+ let identity = {
+ userContextId,
+ public: true,
+ icon,
+ color,
+ name,
+ };
+
+ this._identities.push(identity);
+ this.saveSoon();
+ Services.obs.notifyObservers(
+ this.getIdentityObserverOutput(identity),
+ "contextual-identity-created"
+ );
+
+ return Cu.cloneInto(identity, {});
+ },
+
+ update(userContextId, name, icon, color) {
+ this.ensureDataReady();
+
+ let identity = this._identities.find(
+ identity => identity.userContextId == userContextId && identity.public
+ );
+
+ if (!name.trim()) {
+ throw new Error(
+ "Contextual identity names cannot contain only whitespace."
+ );
+ }
+
+ if (identity && name) {
+ identity.name = name;
+ identity.color = color;
+ identity.icon = icon;
+ delete identity.l10nID;
+ delete identity.accessKey;
+
+ this.saveSoon();
+ Services.obs.notifyObservers(
+ this.getIdentityObserverOutput(identity),
+ "contextual-identity-updated"
+ );
+ }
+
+ return !!identity;
+ },
+
+ remove(userContextId) {
+ this.ensureDataReady();
+
+ let index = this._identities.findIndex(
+ i => i.userContextId == userContextId && i.public
+ );
+ if (index == -1) {
+ return false;
+ }
+
+ Services.clearData.deleteDataFromOriginAttributesPattern({ userContextId });
+
+ let deletedOutput = this.getIdentityObserverOutput(
+ this.getPublicIdentityFromId(userContextId)
+ );
+ this._identities.splice(index, 1);
+ this._openedIdentities.delete(userContextId);
+ this.saveSoon();
+ Services.obs.notifyObservers(deletedOutput, "contextual-identity-deleted");
+
+ return true;
+ },
+
+ getIdentityObserverOutput(identity) {
+ let wrappedJSObject = {
+ name: this.getUserContextLabel(identity.userContextId),
+ icon: identity.icon,
+ color: identity.color,
+ userContextId: identity.userContextId,
+ };
+
+ return { wrappedJSObject };
+ },
+
+ parseData(bytes) {
+ let data = JSON.parse(lazy.gTextDecoder.decode(bytes));
+ if (data.version == 1) {
+ this.resetDefault();
+ return;
+ }
+
+ let saveNeeded = false;
+
+ if (data.version == 2) {
+ data = this.migrate2to3(data);
+ saveNeeded = true;
+ }
+
+ if (data.version == 3) {
+ data = this.migrate3to4(data);
+ saveNeeded = true;
+ }
+
+ if (data.version != LAST_CONTAINERS_JSON_VERSION) {
+ dump(
+ "ERROR - ContextualIdentityService - Unknown version found in " +
+ this._path +
+ "\n"
+ );
+ this.loadError(null);
+ return;
+ }
+
+ this._identities = data.identities;
+ this._lastUserContextId = data.lastUserContextId;
+
+ // If we had a migration, let's force the saving of the file.
+ if (saveNeeded) {
+ this.saveSoon();
+ }
+
+ this._dataReady = true;
+ },
+
+ ensureDataReady() {
+ if (this._dataReady) {
+ return;
+ }
+
+ try {
+ // This reads the file and automatically detects the UTF-8 encoding.
+ let inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ inputStream.init(
+ new lazy.FileUtils.File(this._path),
+ lazy.FileUtils.MODE_RDONLY,
+ lazy.FileUtils.PERMS_FILE,
+ 0
+ );
+ try {
+ let bytes = lazy.NetUtil.readInputStream(
+ inputStream,
+ inputStream.available()
+ );
+ this.parseData(bytes);
+ } finally {
+ inputStream.close();
+ }
+ } catch (error) {
+ this.loadError(error);
+ }
+ },
+
+ getPublicUserContextIds() {
+ return this._identities
+ .filter(identity => identity.public)
+ .map(identity => identity.userContextId);
+ },
+
+ getPrivateUserContextIds() {
+ return this._identities
+ .filter(identity => !identity.public)
+ .map(identity => identity.userContextId);
+ },
+
+ getPublicIdentities() {
+ this.ensureDataReady();
+ return Cu.cloneInto(
+ this._identities.filter(info => info.public),
+ {}
+ );
+ },
+
+ getPrivateIdentity(name) {
+ this.ensureDataReady();
+ return Cu.cloneInto(
+ this._identities.find(info => !info.public && info.name == name),
+ {}
+ );
+ },
+
+ // getDefaultPrivateIdentity is similar to getPrivateIdentity but it only looks in the
+ // default identities (e.g. it is used in the data migration methods to retrieve a new default
+ // private identity and add it to the containers data stored on file).
+ getDefaultPrivateIdentity(name) {
+ return Cu.cloneInto(
+ this._defaultIdentities.find(info => !info.public && info.name == name),
+ {}
+ );
+ },
+
+ getPublicIdentityFromId(userContextId) {
+ this.ensureDataReady();
+ return Cu.cloneInto(
+ this._identities.find(
+ info => info.userContextId == userContextId && info.public
+ ),
+ {}
+ );
+ },
+
+ getUserContextLabel(userContextId) {
+ let identity = this.getPublicIdentityFromId(userContextId);
+ if (!identity) {
+ return "";
+ }
+
+ // We cannot localize the user-created identity names.
+ if (identity.name) {
+ return identity.name;
+ }
+
+ return lazy.gBrowserBundle.GetStringFromName(identity.l10nID);
+ },
+
+ setTabStyle(tab) {
+ if (!tab.hasAttribute("usercontextid")) {
+ return;
+ }
+
+ let userContextId = tab.getAttribute("usercontextid");
+ let identity = this.getPublicIdentityFromId(userContextId);
+
+ let prefix = "identity-color-";
+ /* Remove the existing container color highlight if it exists */
+ for (let className of tab.classList) {
+ if (className.startsWith(prefix)) {
+ tab.classList.remove(className);
+ }
+ }
+ if (identity && identity.color) {
+ tab.classList.add(prefix + identity.color);
+ }
+ },
+
+ countContainerTabs(userContextId = 0) {
+ let count = 0;
+ this._forEachContainerTab(function () {
+ ++count;
+ }, userContextId);
+ return count;
+ },
+
+ closeContainerTabs(userContextId = 0) {
+ return new Promise(resolve => {
+ let remoteTabIds = new Set();
+ this._forEachContainerTab((tab, tabbrowser) => {
+ let frameLoader = tab.linkedBrowser.frameLoader;
+
+ // We don't have remoteTab in non-e10s mode.
+ if (frameLoader.remoteTab) {
+ remoteTabIds.add(frameLoader.remoteTab.tabId);
+ }
+
+ tabbrowser.removeTab(tab);
+ }, userContextId);
+
+ if (remoteTabIds.size == 0) {
+ resolve();
+ return;
+ }
+
+ new _TabRemovalObserver(resolve, remoteTabIds);
+ });
+ },
+
+ notifyAllContainersCleared() {
+ for (let identity of this._identities) {
+ // Don't clear the data related to private identities (e.g. the one used internally
+ // for the thumbnails and the one used for the storage.local IndexedDB backend).
+ if (!identity.public) {
+ continue;
+ }
+ Services.clearData.deleteDataFromOriginAttributesPattern({
+ userContextId: identity.userContextId,
+ });
+ }
+ },
+
+ _forEachContainerTab(callback, userContextId = 0) {
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ if (win.closed || !win.gBrowser) {
+ continue;
+ }
+
+ let tabbrowser = win.gBrowser;
+ for (let i = tabbrowser.tabs.length - 1; i >= 0; --i) {
+ let tab = tabbrowser.tabs[i];
+ if (
+ tab.hasAttribute("usercontextid") &&
+ (!userContextId ||
+ parseInt(tab.getAttribute("usercontextid"), 10) == userContextId)
+ ) {
+ callback(tab, tabbrowser);
+ }
+ }
+ }
+ },
+
+ createNewInstanceForTesting(path) {
+ return new _ContextualIdentityService(path);
+ },
+
+ deleteContainerData() {
+ // The userContextId 0 is reserved to the default firefox identity,
+ // and it should not be clear when we delete the public containers data.
+ let minUserContextId = 1;
+
+ // Collect the userContextId related to the identities that should not be cleared
+ // (the ones marked as `public = false`).
+ const keepDataContextIds = this.getPrivateUserContextIds();
+
+ // Collect the userContextIds currently used by any stored cookie.
+ let cookiesUserContextIds = new Set();
+
+ for (let cookie of Services.cookies.cookies) {
+ // Skip any userContextIds that should not be cleared.
+ if (
+ cookie.originAttributes.userContextId >= minUserContextId &&
+ !keepDataContextIds.includes(cookie.originAttributes.userContextId)
+ ) {
+ cookiesUserContextIds.add(cookie.originAttributes.userContextId);
+ }
+ }
+
+ for (let userContextId of cookiesUserContextIds) {
+ Services.clearData.deleteDataFromOriginAttributesPattern({
+ userContextId,
+ });
+ }
+ },
+
+ migrate2to3(data) {
+ // migrating from 2 to 3 is basically just increasing the version id.
+ // This migration was needed for bug 1419591. See bug 1419591 to know more.
+ data.version = 3;
+
+ return data;
+ },
+
+ migrate3to4(data) {
+ // Migrating from 3 to 4 is:
+ // - adding the reserver userContextId used by the webextension storage.local API
+ // - add the keepData property to all the existent identities
+ // - increasing the version id.
+ //
+ // This migration was needed for Bug 1406181. See bug 1406181 for rationale.
+ const webextStorageLocalIdentity = this.getDefaultPrivateIdentity(
+ "userContextIdInternal.webextStorageLocal"
+ );
+
+ data.identities.push(webextStorageLocalIdentity);
+
+ data.version = 4;
+
+ return data;
+ },
+};
+
+let path = PathUtils.join(
+ Services.dirsvc.get("ProfD", Ci.nsIFile).path,
+ "containers.json"
+);
+export var ContextualIdentityService = new _ContextualIdentityService(path);
diff --git a/toolkit/components/contextualidentity/moz.build b/toolkit/components/contextualidentity/moz.build
new file mode 100644
index 0000000000..425d031f48
--- /dev/null
+++ b/toolkit/components/contextualidentity/moz.build
@@ -0,0 +1,14 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Core", "DOM: Security")
+
+EXTRA_JS_MODULES += [
+ "ContextualIdentityService.sys.mjs",
+]
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"]
diff --git a/toolkit/components/contextualidentity/tests/unit/test_basic.html b/toolkit/components/contextualidentity/tests/unit/test_basic.html
new file mode 100644
index 0000000000..3bad5f53e2
--- /dev/null
+++ b/toolkit/components/contextualidentity/tests/unit/test_basic.html
@@ -0,0 +1,71 @@
+"use strict";
+
+do_get_profile();
+
+const { ContextualIdentityService } = ChromeUtils.import(
+ "resource://gre/modules/ContextualIdentityService.jsm"
+);
+
+const TEST_STORE_FILE_NAME = "test-containers.json";
+
+let cis;
+
+// Basic tests
+add_task(function() {
+ ok(!!ContextualIdentityService, "ContextualIdentityService exists");
+
+ cis = ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_NAME);
+ ok(!!cis, "We have our instance of ContextualIdentityService");
+
+ equal(cis.getPublicIdentities().length, 4, "By default, 4 containers.");
+ equal(cis.getPublicIdentityFromId(0), null, "No identity with id 0");
+
+ ok(!!cis.getPublicIdentityFromId(1), "Identity 1 exists");
+ ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists");
+ ok(!!cis.getPublicIdentityFromId(3), "Identity 3 exists");
+ ok(!!cis.getPublicIdentityFromId(4), "Identity 4 exists");
+});
+
+// Create a new identity
+add_task(function() {
+ equal(cis.getPublicIdentities().length, 4, "By default, 4 containers.");
+
+ let identity = cis.create("New Container", "Icon", "Color");
+ ok(!!identity, "New container created");
+ equal(identity.name, "New Container", "Name matches");
+ equal(identity.icon, "Icon", "Icon matches");
+ equal(identity.color, "Color", "Color matches");
+
+ equal(cis.getPublicIdentities().length, 5, "Expected 5 containers.");
+
+ ok(!!cis.getPublicIdentityFromId(identity.userContextId), "Identity exists");
+ equal(cis.getPublicIdentityFromId(identity.userContextId).name, "New Container", "Identity name is OK");
+ equal(cis.getPublicIdentityFromId(identity.userContextId).icon, "Icon", "Identity icon is OK");
+ equal(cis.getPublicIdentityFromId(identity.userContextId).color, "Color", "Identity color is OK");
+ equal(cis.getUserContextLabel(identity.userContextId), "New Container", "Identity label is OK");
+});
+
+// Remove an identity
+add_task(function() {
+ equal(cis.getPublicIdentities().length, 5, "Expected 5 containers.");
+
+ equal(cis.remove(-1), false, "cis.remove() returns false if identity doesn't exist.");
+ equal(cis.remove(1), true, "cis.remove() returns true if identity exists.");
+
+ equal(cis.getPublicIdentities().length, 4, "Expected 4 containers.");
+});
+
+// Update an identity
+add_task(function() {
+ ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists");
+
+ equal(cis.update(-1, "Container", "Icon", "Color"), false, "Update returns true if everything is OK");
+
+ equal(cis.update(2, "Container", "Icon", "Color"), true, "Update returns true if everything is OK");
+
+ ok(!!cis.getPublicIdentityFromId(2), "Identity exists");
+ equal(cis.getPublicIdentityFromId(2).name, "Container", "Identity name is OK");
+ equal(cis.getPublicIdentityFromId(2).icon, "Icon", "Identity icon is OK");
+ equal(cis.getPublicIdentityFromId(2).color, "Color", "Identity color is OK");
+ equal(cis.getUserContextLabel(2), "Container", "Identity label is OK");
+});
diff --git a/toolkit/components/contextualidentity/tests/unit/test_basic.js b/toolkit/components/contextualidentity/tests/unit/test_basic.js
new file mode 100644
index 0000000000..6c5fba75bf
--- /dev/null
+++ b/toolkit/components/contextualidentity/tests/unit/test_basic.js
@@ -0,0 +1,149 @@
+"use strict";
+
+const profileDir = do_get_profile();
+
+const { ContextualIdentityService } = ChromeUtils.importESModule(
+ "resource://gre/modules/ContextualIdentityService.sys.mjs"
+);
+
+const TEST_STORE_FILE_PATH = PathUtils.join(
+ profileDir.path,
+ "test-containers.json"
+);
+
+let cis;
+
+// Basic tests
+add_task(function () {
+ ok(!!ContextualIdentityService, "ContextualIdentityService exists");
+
+ cis =
+ ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH);
+ ok(!!cis, "We have our instance of ContextualIdentityService");
+
+ equal(cis.getPublicIdentities().length, 4, "By default, 4 containers.");
+ equal(cis.getPublicIdentityFromId(0), null, "No identity with id 0");
+
+ ok(!!cis.getPublicIdentityFromId(1), "Identity 1 exists");
+ ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists");
+ ok(!!cis.getPublicIdentityFromId(3), "Identity 3 exists");
+ ok(!!cis.getPublicIdentityFromId(4), "Identity 4 exists");
+
+ Assert.deepEqual(
+ cis.getPublicUserContextIds(),
+ cis.getPublicIdentities().map(ident => ident.userContextId),
+ "getPublicUserContextIds has matching user context IDs"
+ );
+});
+
+// Make sure we are not allowed to only use whitespaces as a container name
+add_task(function () {
+ Assert.throws(
+ () =>
+ cis.create(
+ "\u0009\u000B\u000C\u0020\u00A0\uFEFF\u000A\u000D\u2028\u2029",
+ "icon",
+ "color"
+ ),
+ /Contextual identity names cannot contain only whitespace./,
+ "Contextual identity names cannot contain only whitespace."
+ );
+});
+
+add_task(function () {
+ ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists");
+ Assert.throws(
+ () =>
+ cis.update(
+ 2,
+ "\u0009\u000B\u000C\u0020\u00A0\uFEFF\u000A\u000D\u2028\u2029",
+ "icon",
+ "color"
+ ),
+ /Contextual identity names cannot contain only whitespace./,
+ "Contextual identity names cannot contain only whitespace."
+ );
+});
+
+// Create a new identity
+add_task(function () {
+ equal(cis.getPublicIdentities().length, 4, "By default, 4 containers.");
+
+ let identity = cis.create("New Container", "Icon", "Color");
+ ok(!!identity, "New container created");
+ equal(identity.name, "New Container", "Name matches");
+ equal(identity.icon, "Icon", "Icon matches");
+ equal(identity.color, "Color", "Color matches");
+
+ equal(cis.getPublicIdentities().length, 5, "Expected 5 containers.");
+
+ ok(!!cis.getPublicIdentityFromId(identity.userContextId), "Identity exists");
+ equal(
+ cis.getPublicIdentityFromId(identity.userContextId).name,
+ "New Container",
+ "Identity name is OK"
+ );
+ equal(
+ cis.getPublicIdentityFromId(identity.userContextId).icon,
+ "Icon",
+ "Identity icon is OK"
+ );
+ equal(
+ cis.getPublicIdentityFromId(identity.userContextId).color,
+ "Color",
+ "Identity color is OK"
+ );
+ equal(
+ cis.getUserContextLabel(identity.userContextId),
+ "New Container",
+ "Identity label is OK"
+ );
+
+ Assert.deepEqual(
+ cis.getPublicUserContextIds(),
+ cis.getPublicIdentities().map(ident => ident.userContextId),
+ "getPublicUserContextIds has matching user context IDs"
+ );
+
+ // Remove an identity
+ equal(
+ cis.remove(-1),
+ false,
+ "cis.remove() returns false if identity doesn't exist."
+ );
+ equal(cis.remove(1), true, "cis.remove() returns true if identity exists.");
+
+ equal(cis.getPublicIdentities().length, 4, "Expected 4 containers.");
+ Assert.deepEqual(
+ cis.getPublicUserContextIds(),
+ cis.getPublicIdentities().map(ident => ident.userContextId),
+ "getPublicUserContextIds has matching user context IDs"
+ );
+});
+
+// Update an identity
+add_task(function () {
+ ok(!!cis.getPublicIdentityFromId(2), "Identity 2 exists");
+
+ equal(
+ cis.update(-1, "Container", "Icon", "Color"),
+ false,
+ "Update returns false if the identity doesn't exist"
+ );
+
+ equal(
+ cis.update(2, "Container", "Icon", "Color"),
+ true,
+ "Update returns true if everything is OK"
+ );
+
+ ok(!!cis.getPublicIdentityFromId(2), "Identity exists");
+ equal(
+ cis.getPublicIdentityFromId(2).name,
+ "Container",
+ "Identity name is OK"
+ );
+ equal(cis.getPublicIdentityFromId(2).icon, "Icon", "Identity icon is OK");
+ equal(cis.getPublicIdentityFromId(2).color, "Color", "Identity color is OK");
+ equal(cis.getUserContextLabel(2), "Container", "Identity label is OK");
+});
diff --git a/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js b/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js
new file mode 100644
index 0000000000..9dfa13ae2e
--- /dev/null
+++ b/toolkit/components/contextualidentity/tests/unit/test_corruptedFile.js
@@ -0,0 +1,169 @@
+"use strict";
+
+const profileDir = do_get_profile();
+
+const { ContextualIdentityService } = ChromeUtils.importESModule(
+ "resource://gre/modules/ContextualIdentityService.sys.mjs"
+);
+
+const TEST_STORE_FILE_PATH = PathUtils.join(
+ profileDir.path,
+ "test-containers.json"
+);
+
+const BASE_URL = "http://example.org/";
+
+const COOKIE = {
+ host: BASE_URL,
+ path: "/",
+ name: "test",
+ value: "yes",
+ isSecure: false,
+ isHttpOnly: false,
+ isSession: true,
+ expiry: 2145934800,
+};
+
+function createCookie(userContextId) {
+ Services.cookies.add(
+ COOKIE.host,
+ COOKIE.path,
+ COOKIE.name,
+ COOKIE.value,
+ COOKIE.isSecure,
+ COOKIE.isHttpOnly,
+ COOKIE.isSession,
+ COOKIE.expiry,
+ { userContextId },
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTP
+ );
+}
+
+function hasCookie(userContextId) {
+ let found = false;
+ for (let cookie of Services.cookies.getCookiesFromHost(BASE_URL, {
+ userContextId,
+ })) {
+ if (cookie.originAttributes.userContextId == userContextId) {
+ found = true;
+ break;
+ }
+ }
+ return found;
+}
+
+// Correpted file should delete all.
+add_task(async function corruptedFile() {
+ const thumbnailPrivateId = ContextualIdentityService._defaultIdentities
+ .filter(identity => identity.name === "userContextIdInternal.thumbnail")
+ .pop().userContextId;
+
+ const webextStoragePrivateId = ContextualIdentityService._defaultIdentities
+ .filter(
+ identity => identity.name === "userContextIdInternal.webextStorageLocal"
+ )
+ .pop().userContextId;
+
+ // Create a cookie in the default Firefox identity (userContextId 0).
+ createCookie(0);
+
+ // Create a cookie in the userContextId 1.
+ createCookie(1);
+
+ // Create a cookie in the thumbnail private userContextId.
+ createCookie(thumbnailPrivateId);
+
+ // Create a cookie in the extension storage private userContextId.
+ createCookie(webextStoragePrivateId);
+
+ ok(hasCookie(0), "We have the new cookie the default firefox identity!");
+ ok(hasCookie(1), "We have the new cookie in a public identity!");
+ ok(
+ hasCookie(thumbnailPrivateId),
+ "We have the new cookie in the thumbnail private identity!"
+ );
+ ok(
+ hasCookie(webextStoragePrivateId),
+ "We have the new cookie in the extension storage private identity!"
+ );
+
+ // Let's create a corrupted file.
+ await IOUtils.writeUTF8(TEST_STORE_FILE_PATH, "{ vers", {
+ tmpPath: TEST_STORE_FILE_PATH + ".tmp",
+ });
+
+ let cis =
+ ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH);
+ ok(!!cis, "We have our instance of ContextualIdentityService");
+
+ equal(
+ cis.getPublicIdentities().length,
+ 4,
+ "We should have the default public identities"
+ );
+
+ Assert.deepEqual(
+ cis.getPublicUserContextIds(),
+ cis.getPublicIdentities().map(identity => identity.userContextId),
+ "getPublicUserContextIds has matching user context IDs"
+ );
+
+ // Verify that when the containers.json file is being rebuilt, the computed lastUserContextId
+ // is the expected one.
+ equal(
+ cis._lastUserContextId,
+ thumbnailPrivateId,
+ "Expect cis._lastUserContextId to be equal to the thumbnails userContextId"
+ );
+
+ const privThumbnailIdentity = cis.getPrivateIdentity(
+ "userContextIdInternal.thumbnail"
+ );
+ equal(
+ privThumbnailIdentity && privThumbnailIdentity.userContextId,
+ thumbnailPrivateId,
+ "We should have the default thumbnail private identity"
+ );
+
+ const privWebextStorageIdentity = cis.getPrivateIdentity(
+ "userContextIdInternal.webextStorageLocal"
+ );
+ equal(
+ privWebextStorageIdentity && privWebextStorageIdentity.userContextId,
+ webextStoragePrivateId,
+ "We should have the default extensions storage.local private identity"
+ );
+
+ // Cookie is gone!
+ ok(
+ !hasCookie(1),
+ "We should not have the new cookie in the userContextId 1!"
+ );
+
+ // The data stored in the Firefox default userContextId (0), should have not be cleared.
+ ok(
+ hasCookie(0),
+ "We should not have the new cookie in the default Firefox identity!"
+ );
+
+ // The data stored in the non-public userContextId (e.g. thumbnails private identity)
+ // should have not be cleared.
+ ok(
+ hasCookie(thumbnailPrivateId),
+ "We should have the new cookie in the thumbnail private userContextId!"
+ );
+ ok(
+ hasCookie(webextStoragePrivateId),
+ "We should have the new cookie in the extension storage private userContextId!"
+ );
+
+ // Verify the version of the newly created containers.json file.
+ cis.save();
+ const stateFileText = await IOUtils.readUTF8(TEST_STORE_FILE_PATH);
+ equal(
+ JSON.parse(stateFileText).version,
+ cis.LAST_CONTAINERS_JSON_VERSION,
+ "Expect the new containers.json file to have the expected version"
+ );
+});
diff --git a/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js b/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js
new file mode 100644
index 0000000000..dd05581342
--- /dev/null
+++ b/toolkit/components/contextualidentity/tests/unit/test_migratedFile.js
@@ -0,0 +1,121 @@
+"use strict";
+
+const profileDir = do_get_profile();
+
+const { ContextualIdentityService } = ChromeUtils.importESModule(
+ "resource://gre/modules/ContextualIdentityService.sys.mjs"
+);
+
+const TEST_STORE_FILE_PATH = PathUtils.join(
+ profileDir.path,
+ "test-containers.json"
+);
+
+// Test the containers JSON file migrations.
+add_task(async function migratedFile() {
+ // Let's create a file that has to be migrated.
+ const oldFileData = {
+ version: 2,
+ lastUserContextId: 6,
+ identities: [
+ {
+ userContextId: 1,
+ public: true,
+ icon: "fingerprint",
+ color: "blue",
+ l10nID: "userContextPersonal.label",
+ accessKey: "userContextPersonal.accesskey",
+ },
+ {
+ userContextId: 2,
+ public: true,
+ icon: "briefcase",
+ color: "orange",
+ l10nID: "userContextWork.label",
+ accessKey: "userContextWork.accesskey",
+ },
+ {
+ userContextId: 3,
+ public: true,
+ icon: "dollar",
+ color: "green",
+ l10nID: "userContextBanking.label",
+ accessKey: "userContextBanking.accesskey",
+ },
+ {
+ userContextId: 4,
+ public: true,
+ icon: "cart",
+ color: "pink",
+ l10nID: "userContextShopping.label",
+ accessKey: "userContextShopping.accesskey",
+ },
+ {
+ userContextId: 5,
+ public: false,
+ icon: "",
+ color: "",
+ name: "userContextIdInternal.thumbnail",
+ accessKey: "",
+ },
+ {
+ userContextId: 6,
+ public: true,
+ icon: "cart",
+ color: "ping",
+ name: "Custom user-created identity",
+ },
+ ],
+ };
+
+ await IOUtils.writeJSON(TEST_STORE_FILE_PATH, oldFileData, {
+ tmpPath: TEST_STORE_FILE_PATH + ".tmp",
+ });
+
+ let cis =
+ ContextualIdentityService.createNewInstanceForTesting(TEST_STORE_FILE_PATH);
+ ok(!!cis, "We have our instance of ContextualIdentityService");
+
+ // Check that the custom user-created identity exists.
+
+ const expectedPublicLength = oldFileData.identities.filter(
+ identity => identity.public
+ ).length;
+ const publicIdentities = cis.getPublicIdentities();
+ const oldLastIdentity =
+ oldFileData.identities[oldFileData.identities.length - 1];
+ const customUserCreatedIdentity = publicIdentities
+ .filter(identity => identity.name === oldLastIdentity.name)
+ .pop();
+
+ equal(
+ publicIdentities.length,
+ expectedPublicLength,
+ "We should have the expected number of public identities"
+ );
+ ok(!!customUserCreatedIdentity, "Got the custom user-created identity");
+
+ Assert.deepEqual(
+ cis.getPublicUserContextIds(),
+ cis.getPublicIdentities().map(identity => identity.userContextId),
+ "getPublicUserContextIds has matching user context IDs"
+ );
+
+ // Check that the reserved userContextIdInternal.webextStorageLocal identity exists.
+
+ const webextStorageLocalPrivateId =
+ ContextualIdentityService._defaultIdentities
+ .filter(
+ identity => identity.name === "userContextIdInternal.webextStorageLocal"
+ )
+ .pop().userContextId;
+
+ const privWebExtStorageLocal = cis.getPrivateIdentity(
+ "userContextIdInternal.webextStorageLocal"
+ );
+ equal(
+ privWebExtStorageLocal && privWebExtStorageLocal.userContextId,
+ webextStorageLocalPrivateId,
+ "We should have the default userContextIdInternal.webextStorageLocal private identity"
+ );
+});
diff --git a/toolkit/components/contextualidentity/tests/unit/xpcshell.ini b/toolkit/components/contextualidentity/tests/unit/xpcshell.ini
new file mode 100644
index 0000000000..400314f3cd
--- /dev/null
+++ b/toolkit/components/contextualidentity/tests/unit/xpcshell.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+firefox-appdir = browser
+
+[test_basic.js]
+skip-if = appname == "thunderbird"
+[test_corruptedFile.js]
+skip-if = appname == "thunderbird"
+[test_migratedFile.js]
+skip-if = appname == "thunderbird"