diff options
Diffstat (limited to 'toolkit/components/contextualidentity')
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" |