diff options
Diffstat (limited to 'toolkit/mozapps/extensions/internal/XPIDatabase.jsm')
-rw-r--r-- | toolkit/mozapps/extensions/internal/XPIDatabase.jsm | 3500 |
1 files changed, 3500 insertions, 0 deletions
diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm new file mode 100644 index 0000000000..ee2c0ca668 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm @@ -0,0 +1,3500 @@ +/* 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 file contains most of the logic required to maintain the + * extensions database, including querying and modifying extension + * metadata. In general, we try to avoid loading it during startup when + * at all possible. Please keep that in mind when deciding whether to + * add code here or elsewhere. + */ + +/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */ + +var EXPORTED_SYMBOLS = ["AddonInternal", "XPIDatabase", "XPIDatabaseReconcile"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.jsm", + AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm", + AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm", + AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm", + DeferredTask: "resource://gre/modules/DeferredTask.jsm", + ExtensionUtils: "resource://gre/modules/ExtensionUtils.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", + PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + + Blocklist: "resource://gre/modules/Blocklist.jsm", + UpdateChecker: "resource://gre/modules/addons/XPIInstall.jsm", + XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm", + XPIInternal: "resource://gre/modules/addons/XPIProvider.jsm", + XPIProvider: "resource://gre/modules/addons/XPIProvider.jsm", + verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "allowPrivateBrowsingByDefault", + "extensions.allowPrivateBrowsingByDefault", + true +); + +const { nsIBlocklistService } = Ci; + +// These are injected from XPIProvider.jsm +/* globals BOOTSTRAP_REASONS, DB_SCHEMA, XPIStates, migrateAddonLoader */ + +for (let sym of [ + "BOOTSTRAP_REASONS", + "DB_SCHEMA", + "XPIStates", + "migrateAddonLoader", +]) { + XPCOMUtils.defineLazyGetter(this, sym, () => XPIInternal[sym]); +} + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const LOGGER_ID = "addons.xpi-utils"; + +const nsIFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" +); + +// Create a new logger for use by the Addons XPI Provider Utils +// (Requires AddonManager.jsm) +var logger = Log.repository.getLogger(LOGGER_ID); + +const KEY_PROFILEDIR = "ProfD"; +const FILE_JSON_DB = "extensions.json"; + +const PREF_DB_SCHEMA = "extensions.databaseSchema"; +const PREF_EM_AUTO_DISABLED_SCOPES = "extensions.autoDisableScopes"; +const PREF_PENDING_OPERATIONS = "extensions.pendingOperations"; +const PREF_XPI_PERMISSIONS_BRANCH = "xpinstall."; +const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root"; + +const TOOLKIT_ID = "toolkit@mozilla.org"; + +const KEY_APP_SYSTEM_ADDONS = "app-system-addons"; +const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults"; +const KEY_APP_SYSTEM_PROFILE = "app-system-profile"; +const KEY_APP_BUILTINS = "app-builtin"; +const KEY_APP_SYSTEM_LOCAL = "app-system-local"; +const KEY_APP_SYSTEM_SHARE = "app-system-share"; +const KEY_APP_GLOBAL = "app-global"; +const KEY_APP_PROFILE = "app-profile"; +const KEY_APP_TEMPORARY = "app-temporary"; + +const DEFAULT_THEME_ID = "default-theme@mozilla.org"; + +// Properties to cache and reload when an addon installation is pending +const PENDING_INSTALL_METADATA = [ + "syncGUID", + "targetApplications", + "userDisabled", + "softDisabled", + "embedderDisabled", + "existingAddonID", + "sourceURI", + "releaseNotesURI", + "installDate", + "updateDate", + "applyBackgroundUpdates", + "installTelemetryInfo", +]; + +// Properties to save in JSON file +const PROP_JSON_FIELDS = [ + "id", + "syncGUID", + "version", + "type", + "loader", + "updateURL", + "optionsURL", + "optionsType", + "optionsBrowserStyle", + "aboutURL", + "defaultLocale", + "visible", + "active", + "userDisabled", + "appDisabled", + "embedderDisabled", + "pendingUninstall", + "installDate", + "updateDate", + "applyBackgroundUpdates", + "path", + "skinnable", + "sourceURI", + "releaseNotesURI", + "softDisabled", + "foreignInstall", + "strictCompatibility", + "locales", + "targetApplications", + "targetPlatforms", + "signedState", + "signedDate", + "seen", + "dependencies", + "incognito", + "userPermissions", + "optionalPermissions", + "icons", + "iconURL", + "blocklistState", + "blocklistURL", + "startupData", + "previewImage", + "hidden", + "installTelemetryInfo", + "recommendationState", + "rootURI", +]; + +const SIGNED_TYPES = new Set(["extension", "locale", "theme"]); + +// Time to wait before async save of XPI JSON database, in milliseconds +const ASYNC_SAVE_DELAY_MS = 20; + +const LOCALE_BUNDLES = [ + "chrome://global/locale/global-extension-fields.properties", + "chrome://global/locale/app-extension-fields.properties", +].map(url => Services.strings.createBundle(url)); + +/** + * Schedules an idle task, and returns a promise which resolves to an + * IdleDeadline when an idle slice is available. The caller should + * perform all of its idle work in the same micro-task, before the + * deadline is reached. + * + * @returns {Promise<IdleDeadline>} + */ +function promiseIdleSlice() { + return new Promise(resolve => { + ChromeUtils.idleDispatch(resolve); + }); +} + +let arrayForEach = Function.call.bind(Array.prototype.forEach); + +/** + * Loops over the given array, in the same way as Array forEach, but + * splitting the work among idle tasks. + * + * @param {Array} array + * The array to loop over. + * @param {function} func + * The function to call on each array element. + * @param {integer} [taskTimeMS = 5] + * The minimum time to allocate to each task. If less time than + * this is available in a given idle slice, and there are more + * elements to loop over, they will be deferred until the next + * idle slice. + */ +async function idleForEach(array, func, taskTimeMS = 5) { + let deadline; + for (let i = 0; i < array.length; i++) { + if (!deadline || deadline.timeRemaining() < taskTimeMS) { + deadline = await promiseIdleSlice(); + } + func(array[i], i); + } +} + +/** + * Asynchronously fill in the _repositoryAddon field for one addon + * + * @param {AddonInternal} aAddon + * The add-on to annotate. + * @returns {AddonInternal} + * The annotated add-on. + */ +async function getRepositoryAddon(aAddon) { + if (aAddon) { + aAddon._repositoryAddon = await AddonRepository.getCachedAddonByID( + aAddon.id + ); + } + return aAddon; +} + +/** + * Copies properties from one object to another. If no target object is passed + * a new object will be created and returned. + * + * @param {object} aObject + * An object to copy from + * @param {string[]} aProperties + * An array of properties to be copied + * @param {object?} [aTarget] + * An optional target object to copy the properties to + * @returns {Object} + * The object that the properties were copied onto + */ +function copyProperties(aObject, aProperties, aTarget) { + if (!aTarget) { + aTarget = {}; + } + aProperties.forEach(function(aProp) { + if (aProp in aObject) { + aTarget[aProp] = aObject[aProp]; + } + }); + return aTarget; +} + +// Maps instances of AddonInternal to AddonWrapper +const wrapperMap = new WeakMap(); +let addonFor = wrapper => wrapperMap.get(wrapper); + +const EMPTY_ARRAY = Object.freeze([]); + +let AddonWrapper; + +/** + * The AddonInternal is an internal only representation of add-ons. It + * may have come from the database or an extension manifest. + */ +class AddonInternal { + constructor(addonData) { + this._wrapper = null; + this._selectedLocale = null; + this.active = false; + this.visible = false; + this.userDisabled = false; + this.appDisabled = false; + this.softDisabled = false; + this.embedderDisabled = false; + this.blocklistState = Ci.nsIBlocklistService.STATE_NOT_BLOCKED; + this.blocklistURL = null; + this.sourceURI = null; + this.releaseNotesURI = null; + this.foreignInstall = false; + this.seen = true; + this.skinnable = false; + this.startupData = null; + this._hidden = false; + this.installTelemetryInfo = null; + this.rootURI = null; + this._updateInstall = null; + this.recommendationState = null; + + this.inDatabase = false; + + /** + * @property {Array<string>} dependencies + * An array of bootstrapped add-on IDs on which this add-on depends. + * The add-on will remain appDisabled if any of the dependent + * add-ons is not installed and enabled. + */ + this.dependencies = EMPTY_ARRAY; + + if (addonData) { + copyProperties(addonData, PROP_JSON_FIELDS, this); + this.location = addonData.location; + + if (!this.dependencies) { + this.dependencies = []; + } + Object.freeze(this.dependencies); + + if (this.location) { + this.addedToDatabase(); + } + + this.sourceBundle = addonData._sourceBundle; + } + } + + get sourceBundle() { + return this._sourceBundle; + } + + set sourceBundle(file) { + this._sourceBundle = file; + if (file) { + this.rootURI = XPIInternal.getURIForResourceInFile(file, "").spec; + } + } + + get wrapper() { + if (!this._wrapper) { + this._wrapper = new AddonWrapper(this); + } + return this._wrapper; + } + + get resolvedRootURI() { + return XPIInternal.maybeResolveURI(Services.io.newURI(this.rootURI)); + } + + addedToDatabase() { + this._key = `${this.location.name}:${this.id}`; + this.inDatabase = true; + } + + get isWebExtension() { + return this.loader == null; + } + + get selectedLocale() { + if (this._selectedLocale) { + return this._selectedLocale; + } + + /** + * this.locales is a list of objects that have property `locales`. + * It's value is an array of locale codes. + * + * First, we reduce this nested structure to a flat list of locale codes. + */ + const locales = [].concat(...this.locales.map(loc => loc.locales)); + + let requestedLocales = Services.locale.requestedLocales; + + /** + * If en-US is not in the list, add it as the last fallback. + */ + if (!requestedLocales.includes("en-US")) { + requestedLocales.push("en-US"); + } + + /** + * Then we negotiate best locale code matching the app locales. + */ + let bestLocale = Services.locale.negotiateLanguages( + requestedLocales, + locales, + "und", + Services.locale.langNegStrategyLookup + )[0]; + + /** + * If no match has been found, we'll assign the default locale as + * the selected one. + */ + if (bestLocale === "und") { + this._selectedLocale = this.defaultLocale; + } else { + /** + * Otherwise, we'll go through all locale entries looking for the one + * that has the best match in it's locales list. + */ + this._selectedLocale = this.locales.find(loc => + loc.locales.includes(bestLocale) + ); + } + + return this._selectedLocale; + } + + get providesUpdatesSecurely() { + return !this.updateURL || this.updateURL.startsWith("https:"); + } + + get isCorrectlySigned() { + switch (this.location.name) { + case KEY_APP_SYSTEM_PROFILE: + // Add-ons installed via Normandy must be signed by the system + // key or the "Mozilla Extensions" key. + return [ + AddonManager.SIGNEDSTATE_SYSTEM, + AddonManager.SIGNEDSTATE_PRIVILEGED, + ].includes(this.signedState); + case KEY_APP_SYSTEM_ADDONS: + // System add-ons must be signed by the system key. + return this.signedState == AddonManager.SIGNEDSTATE_SYSTEM; + + case KEY_APP_SYSTEM_DEFAULTS: + case KEY_APP_BUILTINS: + case KEY_APP_TEMPORARY: + // Temporary and built-in add-ons do not require signing. + return true; + + case KEY_APP_SYSTEM_SHARE: + case KEY_APP_SYSTEM_LOCAL: + // On UNIX platforms except OSX, an additional location for system + // add-ons exists in /usr/{lib,share}/mozilla/extensions. Add-ons + // installed there do not require signing. + if (Services.appinfo.OS != "Darwin") { + return true; + } + break; + } + + if (this.signedState === AddonManager.SIGNEDSTATE_NOT_REQUIRED) { + return true; + } + return this.signedState > AddonManager.SIGNEDSTATE_MISSING; + } + + get isCompatible() { + return this.isCompatibleWith(); + } + + // This matches Extension.isPrivileged with the exception of temporarily installed extensions. + get isPrivileged() { + return ( + this.signedState === AddonManager.SIGNEDSTATE_PRIVILEGED || + this.signedState === AddonManager.SIGNEDSTATE_SYSTEM || + this.location.isBuiltin + ); + } + + get hidden() { + return this.location.hidden || (this._hidden && this.isPrivileged) || false; + } + + set hidden(val) { + this._hidden = val; + } + + get disabled() { + return ( + this.userDisabled || + this.appDisabled || + this.softDisabled || + this.embedderDisabled + ); + } + + get isPlatformCompatible() { + if (!this.targetPlatforms.length) { + return true; + } + + let matchedOS = false; + + // If any targetPlatform matches the OS and contains an ABI then we will + // only match a targetPlatform that contains both the current OS and ABI + let needsABI = false; + + // Some platforms do not specify an ABI, test against null in that case. + let abi = null; + try { + abi = Services.appinfo.XPCOMABI; + } catch (e) {} + + // Something is causing errors in here + try { + for (let platform of this.targetPlatforms) { + if (platform.os == Services.appinfo.OS) { + if (platform.abi) { + needsABI = true; + if (platform.abi === abi) { + return true; + } + } else { + matchedOS = true; + } + } + } + } catch (e) { + let message = + "Problem with addon " + + this.id + + " targetPlatforms " + + JSON.stringify(this.targetPlatforms); + logger.error(message, e); + AddonManagerPrivate.recordException("XPI", message, e); + // don't trust this add-on + return false; + } + + return matchedOS && !needsABI; + } + + isCompatibleWith(aAppVersion, aPlatformVersion) { + let app = this.matchingTargetApplication; + if (!app) { + return false; + } + + // set reasonable defaults for minVersion and maxVersion + let minVersion = app.minVersion || "0"; + let maxVersion = app.maxVersion || "*"; + + if (!aAppVersion) { + aAppVersion = Services.appinfo.version; + } + if (!aPlatformVersion) { + aPlatformVersion = Services.appinfo.platformVersion; + } + + let version; + if (app.id == Services.appinfo.ID) { + version = aAppVersion; + } else if (app.id == TOOLKIT_ID) { + version = aPlatformVersion; + } + + // Only extensions and dictionaries can be compatible by default; themes + // and language packs always use strict compatibility checking. + // Dictionaries are compatible by default unless requested by the dictinary. + if ( + !this.strictCompatibility && + (!AddonManager.strictCompatibility || this.type == "dictionary") + ) { + return Services.vc.compare(version, minVersion) >= 0; + } + + return ( + Services.vc.compare(version, minVersion) >= 0 && + Services.vc.compare(version, maxVersion) <= 0 + ); + } + + get matchingTargetApplication() { + let app = null; + for (let targetApp of this.targetApplications) { + if (targetApp.id == Services.appinfo.ID) { + return targetApp; + } + if (targetApp.id == TOOLKIT_ID) { + app = targetApp; + } + } + return app; + } + + async findBlocklistEntry() { + return Blocklist.getAddonBlocklistEntry(this.wrapper); + } + + async updateBlocklistState(options = {}) { + if (this.location.isSystem || this.location.isBuiltin) { + return; + } + + let { applySoftBlock = true, updateDatabase = true } = options; + + let oldState = this.blocklistState; + + let entry = await this.findBlocklistEntry(); + let newState = entry ? entry.state : Services.blocklist.STATE_NOT_BLOCKED; + + this.blocklistState = newState; + this.blocklistURL = entry && entry.url; + + let userDisabled, softDisabled; + // After a blocklist update, the blocklist service manually applies + // new soft blocks after displaying a UI, in which cases we need to + // skip updating it here. + if (applySoftBlock && oldState != newState) { + if (newState == Services.blocklist.STATE_SOFTBLOCKED) { + if (this.type == "theme") { + userDisabled = true; + } else { + softDisabled = !this.userDisabled; + } + } else { + softDisabled = false; + } + } + + if (this.inDatabase && updateDatabase) { + await XPIDatabase.updateAddonDisabledState(this, { + userDisabled, + softDisabled, + }); + XPIDatabase.saveChanges(); + } else { + this.appDisabled = !XPIDatabase.isUsableAddon(this); + if (userDisabled !== undefined) { + this.userDisabled = userDisabled; + } + if (softDisabled !== undefined) { + this.softDisabled = softDisabled; + } + } + } + + async setUserDisabled(val, allowSystemAddons = false) { + if (val == (this.userDisabled || this.softDisabled)) { + return; + } + + if (this.inDatabase) { + // System add-ons should not be user disabled, as there is no UI to + // re-enable them. + if (this.location.isSystem && !allowSystemAddons) { + throw new Error(`Cannot disable system add-on ${this.id}`); + } + await XPIDatabase.updateAddonDisabledState(this, { userDisabled: val }); + } else { + this.userDisabled = val; + // When enabling remove the softDisabled flag + if (!val) { + this.softDisabled = false; + } + } + } + + applyCompatibilityUpdate(aUpdate, aSyncCompatibility) { + let wasCompatible = this.isCompatible; + + for (let targetApp of this.targetApplications) { + for (let updateTarget of aUpdate.targetApplications) { + if ( + targetApp.id == updateTarget.id && + (aSyncCompatibility || + Services.vc.compare(targetApp.maxVersion, updateTarget.maxVersion) < + 0) + ) { + targetApp.minVersion = updateTarget.minVersion; + targetApp.maxVersion = updateTarget.maxVersion; + + if (this.inDatabase) { + XPIDatabase.saveChanges(); + } + } + } + } + + if (wasCompatible != this.isCompatible) { + if (this.inDatabase) { + XPIDatabase.updateAddonDisabledState(this); + } else { + this.appDisabled = !XPIDatabase.isUsableAddon(this); + } + } + } + + toJSON() { + let obj = copyProperties(this, PROP_JSON_FIELDS); + obj.location = this.location.name; + return obj; + } + + /** + * When an add-on install is pending its metadata will be cached in a file. + * This method reads particular properties of that metadata that may be newer + * than that in the extension manifest, like compatibility information. + * + * @param {Object} aObj + * A JS object containing the cached metadata + */ + importMetadata(aObj) { + for (let prop of PENDING_INSTALL_METADATA) { + if (!(prop in aObj)) { + continue; + } + + this[prop] = aObj[prop]; + } + + // Compatibility info may have changed so update appDisabled + this.appDisabled = !XPIDatabase.isUsableAddon(this); + } + + permissions() { + let permissions = 0; + + // Add-ons that aren't installed cannot be modified in any way + if (!this.inDatabase) { + return permissions; + } + + if (!this.appDisabled) { + if (this.userDisabled || this.softDisabled) { + permissions |= AddonManager.PERM_CAN_ENABLE; + } else if (this.type != "theme" || this.id != DEFAULT_THEME_ID) { + // We do not expose disabling the default theme. + permissions |= AddonManager.PERM_CAN_DISABLE; + } + } + + // Add-ons that are in locked install locations, or are pending uninstall + // cannot be uninstalled or upgraded. One caveat is extensions sideloaded + // from non-profile locations. Since Firefox 73(?), new sideloaded extensions + // from outside the profile have not been installed so any such extensions + // must be from an older profile. Users may uninstall such an extension which + // removes the related state from this profile but leaves the actual file alone + // (since it is outside this profile and may be in use in other profiles) + let changesAllowed = !this.location.locked && !this.pendingUninstall; + if (changesAllowed) { + // System add-on upgrades are triggered through a different mechanism (see updateSystemAddons()) + // Builtin addons are only upgraded with Firefox (or app) updates. + let isSystem = this.location.isSystem || this.location.isBuiltin; + // Add-ons that are installed by a file link cannot be upgraded. + if (!isSystem && !this.location.isLinkedAddon(this.id)) { + permissions |= AddonManager.PERM_CAN_UPGRADE; + } + } + + // We allow uninstall of legacy sideloaded extensions, even when in locked locations, + // but we do not remove the addon file in that case. + let isLegacySideload = + this.foreignInstall && + !(this.location.scope & AddonSettings.SCOPES_SIDELOAD); + if (changesAllowed || isLegacySideload) { + permissions |= AddonManager.PERM_API_CAN_UNINSTALL; + if (!this.location.isBuiltin) { + permissions |= AddonManager.PERM_CAN_UNINSTALL; + } + } + + // The permission to "toggle the private browsing access" is locked down + // when the extension has opted out or it gets the permission automatically + // on every extension startup (as system, privileged and builtin addons). + if ( + !allowPrivateBrowsingByDefault && + this.type === "extension" && + this.incognito !== "not_allowed" && + this.signedState !== AddonManager.SIGNEDSTATE_PRIVILEGED && + this.signedState !== AddonManager.SIGNEDSTATE_SYSTEM && + !this.location.isBuiltin + ) { + permissions |= AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS; + } + + if (Services.policies) { + if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) { + permissions &= ~AddonManager.PERM_CAN_UNINSTALL; + } + if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) { + permissions &= ~AddonManager.PERM_CAN_DISABLE; + } + } + + return permissions; + } + + propagateDisabledState(oldAddon) { + if (oldAddon) { + this.userDisabled = oldAddon.userDisabled; + this.embedderDisabled = oldAddon.embedderDisabled; + this.softDisabled = oldAddon.softDisabled; + this.blocklistState = oldAddon.blocklistState; + } + } +} + +/** + * The AddonWrapper wraps an Addon to provide the data visible to consumers of + * the public API. + * + * @param {AddonInternal} aAddon + * The add-on object to wrap. + */ +AddonWrapper = class { + constructor(aAddon) { + wrapperMap.set(this, aAddon); + } + + get __AddonInternal__() { + return addonFor(this); + } + + get seen() { + return addonFor(this).seen; + } + + markAsSeen() { + addonFor(this).seen = true; + XPIDatabase.saveChanges(); + } + + get installTelemetryInfo() { + const addon = addonFor(this); + if (!addon.installTelemetryInfo && addon.location) { + if (addon.location.isSystem) { + return { source: "system-addon" }; + } + + if (addon.location.isTemporary) { + return { source: "temporary-addon" }; + } + } + + return addon.installTelemetryInfo; + } + + get temporarilyInstalled() { + return addonFor(this).location.isTemporary; + } + + get aboutURL() { + return this.isActive ? addonFor(this).aboutURL : null; + } + + get optionsURL() { + if (!this.isActive) { + return null; + } + + let addon = addonFor(this); + if (addon.optionsURL) { + if (this.isWebExtension) { + // The internal object's optionsURL property comes from the addons + // DB and should be a relative URL. However, extensions with + // options pages installed before bug 1293721 was fixed got absolute + // URLs in the addons db. This code handles both cases. + let policy = WebExtensionPolicy.getByID(addon.id); + if (!policy) { + return null; + } + let base = policy.getURL(); + return new URL(addon.optionsURL, base).href; + } + return addon.optionsURL; + } + + return null; + } + + get optionsType() { + if (!this.isActive) { + return null; + } + + let addon = addonFor(this); + let hasOptionsURL = !!this.optionsURL; + + if (addon.optionsType) { + switch (parseInt(addon.optionsType, 10)) { + case AddonManager.OPTIONS_TYPE_TAB: + case AddonManager.OPTIONS_TYPE_INLINE_BROWSER: + return hasOptionsURL ? addon.optionsType : null; + } + return null; + } + + return null; + } + + get optionsBrowserStyle() { + let addon = addonFor(this); + return addon.optionsBrowserStyle; + } + + get incognito() { + return addonFor(this).incognito; + } + + async getBlocklistURL() { + return addonFor(this).blocklistURL; + } + + get iconURL() { + return AddonManager.getPreferredIconURL(this, 48); + } + + get icons() { + let addon = addonFor(this); + let icons = {}; + + if (addon._repositoryAddon) { + for (let size in addon._repositoryAddon.icons) { + icons[size] = addon._repositoryAddon.icons[size]; + } + } + + if (addon.icons) { + for (let size in addon.icons) { + let path = addon.icons[size].replace(/^\//, ""); + icons[size] = this.getResourceURI(path).spec; + } + } + + let canUseIconURLs = this.isActive; + if (canUseIconURLs && addon.iconURL) { + icons[32] = addon.iconURL; + icons[48] = addon.iconURL; + } + + Object.freeze(icons); + return icons; + } + + get screenshots() { + let addon = addonFor(this); + let repositoryAddon = addon._repositoryAddon; + if (repositoryAddon && "screenshots" in repositoryAddon) { + let repositoryScreenshots = repositoryAddon.screenshots; + if (repositoryScreenshots && repositoryScreenshots.length) { + return repositoryScreenshots; + } + } + + if (addon.previewImage) { + let url = this.getResourceURI(addon.previewImage).spec; + return [new AddonManagerPrivate.AddonScreenshot(url)]; + } + + return null; + } + + get recommendationStates() { + let addon = addonFor(this); + let state = addon.recommendationState; + if ( + state && + state.validNotBefore < addon.updateDate && + state.validNotAfter > addon.updateDate && + addon.isCorrectlySigned && + !this.temporarilyInstalled + ) { + return state.states; + } + return []; + } + + get isRecommended() { + return this.recommendationStates.includes("recommended"); + } + + get canBypassThirdParyInstallPrompt() { + // We only bypass if the extension is signed (to support distributions + // that turn off the signing requirement) and has recommendation states, + // or the extension is signed as privileged. + return ( + this.signedState == AddonManager.SIGNEDSTATE_PRIVILEGED || + (this.signedState >= AddonManager.SIGNEDSTATE_SIGNED && + this.recommendationStates.length) + ); + } + + get applyBackgroundUpdates() { + return addonFor(this).applyBackgroundUpdates; + } + set applyBackgroundUpdates(val) { + let addon = addonFor(this); + if ( + val != AddonManager.AUTOUPDATE_DEFAULT && + val != AddonManager.AUTOUPDATE_DISABLE && + val != AddonManager.AUTOUPDATE_ENABLE + ) { + val = val + ? AddonManager.AUTOUPDATE_DEFAULT + : AddonManager.AUTOUPDATE_DISABLE; + } + + if (val == addon.applyBackgroundUpdates) { + return val; + } + + XPIDatabase.setAddonProperties(addon, { + applyBackgroundUpdates: val, + }); + AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [ + "applyBackgroundUpdates", + ]); + + return val; + } + + set syncGUID(val) { + let addon = addonFor(this); + if (addon.syncGUID == val) { + return val; + } + + if (addon.inDatabase) { + XPIDatabase.setAddonSyncGUID(addon, val); + } + + addon.syncGUID = val; + + return val; + } + + get install() { + let addon = addonFor(this); + if (!("_install" in addon) || !addon._install) { + return null; + } + return addon._install.wrapper; + } + + get updateInstall() { + let addon = addonFor(this); + return addon._updateInstall ? addon._updateInstall.wrapper : null; + } + + get pendingUpgrade() { + let addon = addonFor(this); + return addon.pendingUpgrade ? addon.pendingUpgrade.wrapper : null; + } + + get scope() { + let addon = addonFor(this); + if (addon.location) { + return addon.location.scope; + } + + return AddonManager.SCOPE_PROFILE; + } + + get pendingOperations() { + let addon = addonFor(this); + let pending = 0; + if (!addon.inDatabase) { + // Add-on is pending install if there is no associated install (shouldn't + // happen here) or if the install is in the process of or has successfully + // completed the install. If an add-on is pending install then we ignore + // any other pending operations. + if ( + !addon._install || + addon._install.state == AddonManager.STATE_INSTALLING || + addon._install.state == AddonManager.STATE_INSTALLED + ) { + return AddonManager.PENDING_INSTALL; + } + } else if (addon.pendingUninstall) { + // If an add-on is pending uninstall then we ignore any other pending + // operations + return AddonManager.PENDING_UNINSTALL; + } + + if (addon.active && addon.disabled) { + pending |= AddonManager.PENDING_DISABLE; + } else if (!addon.active && !addon.disabled) { + pending |= AddonManager.PENDING_ENABLE; + } + + if (addon.pendingUpgrade) { + pending |= AddonManager.PENDING_UPGRADE; + } + + return pending; + } + + get operationsRequiringRestart() { + return 0; + } + + get isDebuggable() { + return this.isActive; + } + + get permissions() { + return addonFor(this).permissions(); + } + + get isActive() { + let addon = addonFor(this); + if (!addon.active) { + return false; + } + if (!Services.appinfo.inSafeMode) { + return true; + } + return XPIInternal.canRunInSafeMode(addon); + } + + get startupPromise() { + let addon = addonFor(this); + if (!this.isActive) { + return null; + } + + let activeAddon = XPIProvider.activeAddons.get(addon.id); + if (activeAddon) { + return activeAddon.startupPromise || null; + } + return null; + } + + updateBlocklistState(applySoftBlock = true) { + return addonFor(this).updateBlocklistState({ applySoftBlock }); + } + + get userDisabled() { + let addon = addonFor(this); + return addon.softDisabled || addon.userDisabled; + } + + /** + * Get the embedderDisabled property for this addon. + * + * This is intended for embedders of Gecko like GeckoView apps to control + * which addons are usable on their app. + * + * @returns {boolean} + */ + get embedderDisabled() { + if (!AddonSettings.IS_EMBEDDED) { + return undefined; + } + + return addonFor(this).embedderDisabled; + } + + /** + * Set the embedderDisabled property for this addon. + * + * This is intended for embedders of Gecko like GeckoView apps to control + * which addons are usable on their app. + * + * Embedders can disable addons for various reasons, e.g. the addon is not + * compatible with their implementation of the WebExtension API. + * + * When an addon is embedderDisabled it will behave like it was appDisabled. + * + * @param {boolean} val + * whether this addon should be embedder disabled or not. + */ + async setEmbedderDisabled(val) { + if (!AddonSettings.IS_EMBEDDED) { + throw new Error("Setting embedder disabled while not embedding."); + } + + let addon = addonFor(this); + if (addon.embedderDisabled == val) { + return val; + } + + if (addon.inDatabase) { + await XPIDatabase.updateAddonDisabledState(addon, { + embedderDisabled: val, + }); + } else { + addon.embedderDisabled = val; + } + + return val; + } + + enable(options = {}) { + const { allowSystemAddons = false } = options; + return addonFor(this).setUserDisabled(false, allowSystemAddons); + } + + disable(options = {}) { + const { allowSystemAddons = false } = options; + return addonFor(this).setUserDisabled(true, allowSystemAddons); + } + + async setSoftDisabled(val) { + let addon = addonFor(this); + if (val == addon.softDisabled) { + return val; + } + + if (addon.inDatabase) { + // When softDisabling a theme just enable the active theme + if (addon.type === "theme" && val && !addon.userDisabled) { + if (addon.isWebExtension) { + await XPIDatabase.updateAddonDisabledState(addon, { + softDisabled: val, + }); + } + } else { + await XPIDatabase.updateAddonDisabledState(addon, { + softDisabled: val, + }); + } + } else if (!addon.userDisabled) { + // Only set softDisabled if not already disabled + addon.softDisabled = val; + } + + return val; + } + + get isPrivileged() { + return addonFor(this).isPrivileged; + } + + get hidden() { + return addonFor(this).hidden; + } + + get isSystem() { + let addon = addonFor(this); + return addon.location.isSystem; + } + + get isBuiltin() { + return addonFor(this).location.isBuiltin; + } + + // Returns true if Firefox Sync should sync this addon. Only addons + // in the profile install location are considered syncable. + get isSyncable() { + let addon = addonFor(this); + return addon.location.name == KEY_APP_PROFILE; + } + + get userPermissions() { + return addonFor(this).userPermissions; + } + + get optionalPermissions() { + return addonFor(this).optionalPermissions; + } + + isCompatibleWith(aAppVersion, aPlatformVersion) { + return addonFor(this).isCompatibleWith(aAppVersion, aPlatformVersion); + } + + async uninstall(alwaysAllowUndo) { + let addon = addonFor(this); + return XPIInstall.uninstallAddon(addon, alwaysAllowUndo); + } + + cancelUninstall() { + let addon = addonFor(this); + XPIInstall.cancelUninstallAddon(addon); + } + + findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) { + new UpdateChecker( + addonFor(this), + aListener, + aReason, + aAppVersion, + aPlatformVersion + ); + } + + // Returns true if there was an update in progress, false if there was no update to cancel + cancelUpdate() { + let addon = addonFor(this); + if (addon._updateCheck) { + addon._updateCheck.cancel(); + return true; + } + return false; + } + + /** + * Reloads the add-on. + * + * For temporarily installed add-ons, this uninstalls and re-installs the + * add-on. Otherwise, the addon is disabled and then re-enabled, and the cache + * is flushed. + */ + async reload() { + const addon = addonFor(this); + + logger.debug(`reloading add-on ${addon.id}`); + + if (!this.temporarilyInstalled) { + await XPIDatabase.updateAddonDisabledState(addon, { userDisabled: true }); + await XPIDatabase.updateAddonDisabledState(addon, { + userDisabled: false, + }); + } else { + // This function supports re-installing an existing add-on. + await AddonManager.installTemporaryAddon(addon._sourceBundle); + } + } + + /** + * Returns a URI to the selected resource or to the add-on bundle if aPath + * is null. URIs to the bundle will always be file: URIs. URIs to resources + * will be file: URIs if the add-on is unpacked or jar: URIs if the add-on is + * still an XPI file. + * + * @param {string?} aPath + * The path in the add-on to get the URI for or null to get a URI to + * the file or directory the add-on is installed as. + * @returns {nsIURI} + */ + getResourceURI(aPath) { + let addon = addonFor(this); + let url = Services.io.newURI(addon.rootURI); + if (aPath) { + if (aPath.startsWith("/")) { + throw new Error("getResourceURI() must receive a relative path"); + } + url = Services.io.newURI(aPath, null, url); + } + return url; + } +}; + +function chooseValue(aAddon, aObj, aProp) { + let repositoryAddon = aAddon._repositoryAddon; + let objValue = aObj[aProp]; + + if ( + repositoryAddon && + aProp in repositoryAddon && + (aProp === "creator" || objValue == null) + ) { + return [repositoryAddon[aProp], true]; + } + + let id = `extension.${aAddon.id}.${aProp}`; + for (let bundle of LOCALE_BUNDLES) { + try { + return [bundle.GetStringFromName(id), false]; + } catch (e) { + // Ignore missing overrides. + } + } + + return [objValue, false]; +} + +function defineAddonWrapperProperty(name, getter) { + Object.defineProperty(AddonWrapper.prototype, name, { + get: getter, + enumerable: true, + }); +} + +[ + "id", + "syncGUID", + "version", + "type", + "isWebExtension", + "isCompatible", + "isPlatformCompatible", + "providesUpdatesSecurely", + "blocklistState", + "appDisabled", + "softDisabled", + "skinnable", + "foreignInstall", + "strictCompatibility", + "updateURL", + "dependencies", + "signedState", + "isCorrectlySigned", +].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + return aProp in addon ? addon[aProp] : undefined; + }); +}); + +[ + "fullDescription", + "developerComments", + "supportURL", + "contributionURL", + "averageRating", + "reviewCount", + "reviewURL", + "weeklyDownloads", +].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + if (addon._repositoryAddon) { + return addon._repositoryAddon[aProp]; + } + + return null; + }); +}); + +["installDate", "updateDate"].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + // installDate is always set, updateDate is sometimes missing. + return new Date(addon[aProp] ?? addon.installDate); + }); +}); + +defineAddonWrapperProperty("signedDate", function() { + let addon = addonFor(this); + let { signedDate } = addon; + if (signedDate != null) { + return new Date(signedDate); + } + return null; +}); + +["sourceURI", "releaseNotesURI"].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + + // Temporary Installed Addons do not have a "sourceURI", + // But we can use the "_sourceBundle" as an alternative, + // which points to the path of the addon xpi installed + // or its source dir (if it has been installed from a + // directory). + if (aProp == "sourceURI" && this.temporarilyInstalled) { + return Services.io.newFileURI(addon._sourceBundle); + } + + let [target, fromRepo] = chooseValue(addon, addon, aProp); + if (!target) { + return null; + } + if (fromRepo) { + return target; + } + return Services.io.newURI(target); + }); +}); + +["name", "description", "creator", "homepageURL"].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + + let [result, usedRepository] = chooseValue( + addon, + addon.selectedLocale, + aProp + ); + + if (result == null) { + // Legacy add-ons may be partially localized. Fall back to the default + // locale ensure that the result is a string where possible. + [result, usedRepository] = chooseValue(addon, addon.defaultLocale, aProp); + } + + if (result && !usedRepository && aProp == "creator") { + return new AddonManagerPrivate.AddonAuthor(result); + } + + return result; + }); +}); + +["developers", "translators", "contributors"].forEach(function(aProp) { + defineAddonWrapperProperty(aProp, function() { + let addon = addonFor(this); + + let [results, usedRepository] = chooseValue( + addon, + addon.selectedLocale, + aProp + ); + + if (results && !usedRepository) { + results = results.map(function(aResult) { + return new AddonManagerPrivate.AddonAuthor(aResult); + }); + } + + return results; + }); +}); + +/** + * @typedef {Map<string, AddonInternal>} AddonDB + */ + +/** + * Internal interface: find an addon from an already loaded addonDB. + * + * @param {AddonDB} addonDB + * The add-on database. + * @param {function(AddonInternal) : boolean} aFilter + * The filter predecate. The first add-on for which it returns + * true will be returned. + * @returns {AddonInternal?} + * The first matching add-on, if one is found. + */ +function _findAddon(addonDB, aFilter) { + for (let addon of addonDB.values()) { + if (aFilter(addon)) { + return addon; + } + } + return null; +} + +/** + * Internal interface to get a filtered list of addons from a loaded addonDB + * + * @param {AddonDB} addonDB + * The add-on database. + * @param {function(AddonInternal) : boolean} aFilter + * The filter predecate. Add-ons which match this predicate will + * be returned. + * @returns {Array<AddonInternal>} + * The list of matching add-ons. + */ +function _filterDB(addonDB, aFilter) { + return Array.from(addonDB.values()).filter(aFilter); +} + +this.XPIDatabase = { + // true if the database connection has been opened + initialized: false, + // The database file + jsonFile: FileUtils.getFile(KEY_PROFILEDIR, [FILE_JSON_DB], true), + rebuildingDatabase: false, + syncLoadingDB: false, + // Add-ons from the database in locations which are no longer + // supported. + orphanedAddons: [], + + _saveTask: null, + + // Saved error object if we fail to read an existing database + _loadError: null, + + // Saved error object if we fail to save the database + _saveError: null, + + // Error reported by our most recent attempt to read or write the database, if any + get lastError() { + if (this._loadError) { + return this._loadError; + } + if (this._saveError) { + return this._saveError; + } + return null; + }, + + async _saveNow() { + try { + let json = JSON.stringify(this); + let path = this.jsonFile.path; + await OS.File.writeAtomic(path, json, { tmpPath: `${path}.tmp` }); + + if (!this._schemaVersionSet) { + // Update the XPIDB schema version preference the first time we + // successfully save the database. + logger.debug( + "XPI Database saved, setting schema version preference to " + + DB_SCHEMA + ); + Services.prefs.setIntPref(PREF_DB_SCHEMA, DB_SCHEMA); + this._schemaVersionSet = true; + + // Reading the DB worked once, so we don't need the load error + this._loadError = null; + } + } catch (error) { + logger.warn("Failed to save XPI database", error); + this._saveError = error; + throw error; + } + }, + + /** + * Mark the current stored data dirty, and schedule a flush to disk + */ + saveChanges() { + if (!this.initialized) { + throw new Error("Attempt to use XPI database when it is not initialized"); + } + + if (XPIProvider._closing) { + // use an Error here so we get a stack trace. + let err = new Error("XPI database modified after shutdown began"); + logger.warn(err); + AddonManagerPrivate.recordSimpleMeasure( + "XPIDB_late_stack", + Log.stackTrace(err) + ); + } + + if (!this._saveTask) { + this._saveTask = new DeferredTask( + () => this._saveNow(), + ASYNC_SAVE_DELAY_MS + ); + } + + this._saveTask.arm(); + }, + + async finalize() { + // handle the "in memory only" and "saveChanges never called" cases + if (!this._saveTask) { + return; + } + + await this._saveTask.finalize(); + }, + + /** + * Converts the current internal state of the XPI addon database to + * a JSON.stringify()-ready structure + * + * @returns {Object} + */ + toJSON() { + if (!this.addonDB) { + // We never loaded the database? + throw new Error("Attempt to save database without loading it first"); + } + + let toSave = { + schemaVersion: DB_SCHEMA, + addons: Array.from(this.addonDB.values()).filter( + addon => !addon.location.isTemporary + ), + }; + return toSave; + }, + + /** + * Synchronously loads the database, by running the normal async load + * operation with idle dispatch disabled, and spinning the event loop + * until it finishes. + * + * @param {boolean} aRebuildOnError + * A boolean indicating whether add-on information should be loaded + * from the install locations if the database needs to be rebuilt. + * (if false, caller is XPIProvider.checkForChanges() which will rebuild) + */ + syncLoadDB(aRebuildOnError) { + let err = new Error("Synchronously loading the add-ons database"); + logger.debug(err.message); + AddonManagerPrivate.recordSimpleMeasure( + "XPIDB_sync_stack", + Log.stackTrace(err) + ); + try { + this.syncLoadingDB = true; + XPIInternal.awaitPromise(this.asyncLoadDB(aRebuildOnError)); + } finally { + this.syncLoadingDB = false; + } + }, + + _recordStartupError(reason) { + AddonManagerPrivate.recordSimpleMeasure("XPIDB_startupError", reason); + }, + + /** + * Parse loaded data, reconstructing the database if the loaded data is not valid + * + * @param {string} aData + * The stringified add-on JSON to parse. + * @param {boolean} aRebuildOnError + * If true, synchronously reconstruct the database from installed add-ons + */ + async parseDB(aData, aRebuildOnError) { + try { + let parseTimer = AddonManagerPrivate.simpleTimer("XPIDB_parseDB_MS"); + let inputAddons = JSON.parse(aData); + + if (!("schemaVersion" in inputAddons) || !("addons" in inputAddons)) { + let error = new Error("Bad JSON file contents"); + error.rebuildReason = "XPIDB_rebuildBadJSON_MS"; + throw error; + } + + if (inputAddons.schemaVersion <= 27) { + // Types were translated in bug 857456. + for (let addon of inputAddons.addons) { + migrateAddonLoader(addon); + } + } else if (inputAddons.schemaVersion != DB_SCHEMA) { + // For now, we assume compatibility for JSON data with a + // mismatched schema version, though we throw away any fields we + // don't know about (bug 902956) + this._recordStartupError(`schemaMismatch-${inputAddons.schemaVersion}`); + logger.debug( + `JSON schema mismatch: expected ${DB_SCHEMA}, actual ${inputAddons.schemaVersion}` + ); + } + + let forEach = this.syncLoadingDB ? arrayForEach : idleForEach; + + // If we got here, we probably have good data + // Make AddonInternal instances from the loaded data and save them + let addonDB = new Map(); + await forEach(inputAddons.addons, loadedAddon => { + if (loadedAddon.path) { + try { + loadedAddon._sourceBundle = new nsIFile(loadedAddon.path); + } catch (e) { + // We can fail here when the path is invalid, usually from the + // wrong OS + logger.warn( + "Could not find source bundle for add-on " + loadedAddon.id, + e + ); + } + } + loadedAddon.location = XPIStates.getLocation(loadedAddon.location); + + let newAddon = new AddonInternal(loadedAddon); + if (loadedAddon.location) { + addonDB.set(newAddon._key, newAddon); + } else { + this.orphanedAddons.push(newAddon); + } + }); + + parseTimer.done(); + this.addonDB = addonDB; + logger.debug("Successfully read XPI database"); + this.initialized = true; + } catch (e) { + if (e.name == "SyntaxError") { + logger.error("Syntax error parsing saved XPI JSON data"); + this._recordStartupError("syntax"); + } else { + logger.error("Failed to load XPI JSON data from profile", e); + this._recordStartupError("other"); + } + + this.timeRebuildDatabase( + e.rebuildReason || "XPIDB_rebuildReadFailed_MS", + aRebuildOnError + ); + } + }, + + async maybeIdleDispatch() { + if (!this.syncLoadingDB) { + await promiseIdleSlice(); + } + }, + + /** + * Open and read the XPI database asynchronously, upgrading if + * necessary. If any DB load operation fails, we need to + * synchronously rebuild the DB from the installed extensions. + * + * @param {boolean} [aRebuildOnError = true] + * A boolean indicating whether add-on information should be loaded + * from the install locations if the database needs to be rebuilt. + * (if false, caller is XPIProvider.checkForChanges() which will rebuild) + * @returns {Promise<AddonDB>} + * Resolves to the Map of loaded JSON data stored in + * this.addonDB; never rejects. + */ + asyncLoadDB(aRebuildOnError = true) { + // Already started (and possibly finished) loading + if (this._dbPromise) { + return this._dbPromise; + } + + logger.debug(`Starting async load of XPI database ${this.jsonFile.path}`); + this._dbPromise = (async () => { + try { + let byteArray = await OS.File.read(this.jsonFile.path, null); + + logger.debug("Finished async read of XPI database, parsing..."); + await this.maybeIdleDispatch(); + let text = new TextDecoder().decode(byteArray); + + await this.maybeIdleDispatch(); + await this.parseDB(text, true); + } catch (error) { + if (error.becauseNoSuchFile) { + if (Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) { + this._recordStartupError("dbMissing"); + } + } else { + logger.warn( + `Extensions database ${this.jsonFile.path} exists but is not readable; rebuilding`, + error + ); + this._loadError = error; + } + this.timeRebuildDatabase( + "XPIDB_rebuildUnreadableDB_MS", + aRebuildOnError + ); + } + return this.addonDB; + })(); + + XPIInternal.resolveDBReady(this._dbPromise); + + return this._dbPromise; + }, + + timeRebuildDatabase(timerName, rebuildOnError) { + AddonManagerPrivate.recordTiming(timerName, () => { + return this.rebuildDatabase(rebuildOnError); + }); + }, + + /** + * Rebuild the database from addon install directories. + * + * @param {boolean} aRebuildOnError + * A boolean indicating whether add-on information should be loaded + * from the install locations if the database needs to be rebuilt. + * (if false, caller is XPIProvider.checkForChanges() which will rebuild) + */ + rebuildDatabase(aRebuildOnError) { + this.addonDB = new Map(); + this.initialized = true; + + if (XPIStates.size == 0) { + // No extensions installed, so we're done + logger.debug("Rebuilding XPI database with no extensions"); + return; + } + + this.rebuildingDatabase = !!aRebuildOnError; + + if (aRebuildOnError) { + logger.warn("Rebuilding add-ons database from installed extensions."); + try { + XPIDatabaseReconcile.processFileChanges({}, false); + } catch (e) { + logger.error( + "Failed to rebuild XPI database from installed extensions", + e + ); + } + // Make sure to update the active add-ons and add-ons list on shutdown + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true); + } + }, + + /** + * Shuts down the database connection and releases all cached objects. + * Return: Promise{integer} resolves / rejects with the result of the DB + * flush after the database is flushed and + * all cleanup is done + */ + async shutdown() { + logger.debug("shutdown"); + if (this.initialized) { + // If our last database I/O had an error, try one last time to save. + if (this.lastError) { + this.saveChanges(); + } + + this.initialized = false; + + // If we're shutting down while still loading, finish loading + // before everything else! + if (this._dbPromise) { + await this._dbPromise; + } + + // Await any pending DB writes and finish cleaning up. + await this.finalize(); + + if (this._saveError) { + // If our last attempt to read or write the DB failed, force a new + // extensions.ini to be written to disk on the next startup + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true); + } + + // Clear out the cached addons data loaded from JSON + delete this.addonDB; + delete this._dbPromise; + // same for the deferred save + delete this._saveTask; + // re-enable the schema version setter + delete this._schemaVersionSet; + } + }, + + /** + * Verifies that all installed add-ons are still correctly signed. + */ + async verifySignatures() { + try { + let addons = await this.getAddonList(a => true); + + let changes = { + enabled: [], + disabled: [], + }; + + for (let addon of addons) { + // The add-on might have vanished, we'll catch that on the next startup + if (!addon._sourceBundle || !addon._sourceBundle.exists()) { + continue; + } + + let signedState = await verifyBundleSignedState( + addon._sourceBundle, + addon + ); + + if (signedState != addon.signedState) { + addon.signedState = signedState; + AddonManagerPrivate.callAddonListeners( + "onPropertyChanged", + addon.wrapper, + ["signedState"] + ); + } + + let disabled = await this.updateAddonDisabledState(addon); + if (disabled !== undefined) { + changes[disabled ? "disabled" : "enabled"].push(addon.id); + } + } + + this.saveChanges(); + + Services.obs.notifyObservers( + null, + "xpi-signature-changed", + JSON.stringify(changes) + ); + } catch (err) { + logger.error("XPI_verifySignature: " + err); + } + }, + + /** + * Imports the xpinstall permissions from preferences into the permissions + * manager for the user to change later. + */ + importPermissions() { + PermissionsUtils.importFromPrefs( + PREF_XPI_PERMISSIONS_BRANCH, + XPIInternal.XPI_PERMISSION + ); + }, + + /** + * Called when a new add-on has been enabled when only one add-on of that type + * can be enabled. + * + * @param {string} aId + * The ID of the newly enabled add-on + * @param {string} aType + * The type of the newly enabled add-on + */ + async addonChanged(aId, aType) { + // We only care about themes in this provider + if (aType !== "theme") { + return; + } + + Services.prefs.setCharPref( + "extensions.activeThemeID", + aId || DEFAULT_THEME_ID + ); + + let enableTheme; + + let addons = this.getAddonsByType("theme"); + for (let theme of addons) { + if (theme.visible) { + if (!aId && theme.id == DEFAULT_THEME_ID) { + enableTheme = theme; + } else if (theme.id != aId && !theme.pendingUninstall) { + this.updateAddonDisabledState(theme, { + userDisabled: true, + becauseSelecting: true, + }); + } + } + } + + if (enableTheme) { + await this.updateAddonDisabledState(enableTheme, { + userDisabled: false, + becauseSelecting: true, + }); + } + }, + + SIGNED_TYPES, + + /** + * Asynchronously list all addons that match the filter function + * + * @param {function(AddonInternal) : boolean} aFilter + * Function that takes an addon instance and returns + * true if that addon should be included in the selected array + * + * @returns {Array<AddonInternal>} + * A Promise that resolves to the list of add-ons matching + * aFilter or an empty array if none match + */ + async getAddonList(aFilter) { + try { + let addonDB = await this.asyncLoadDB(); + let addonList = _filterDB(addonDB, aFilter); + let addons = await Promise.all( + addonList.map(addon => getRepositoryAddon(addon)) + ); + return addons; + } catch (error) { + logger.error("getAddonList failed", error); + return []; + } + }, + + /** + * Get the first addon that matches the filter function + * + * @param {function(AddonInternal) : boolean} aFilter + * Function that takes an addon instance and returns + * true if that addon should be selected + * @returns {Promise<AddonInternal?>} + */ + getAddon(aFilter) { + return this.asyncLoadDB() + .then(addonDB => getRepositoryAddon(_findAddon(addonDB, aFilter))) + .catch(error => { + logger.error("getAddon failed", error); + }); + }, + + /** + * Asynchronously gets an add-on with a particular ID in a particular + * install location. + * + * @param {string} aId + * The ID of the add-on to retrieve + * @param {string} aLocation + * The name of the install location + * @returns {Promise<AddonInternal?>} + */ + getAddonInLocation(aId, aLocation) { + return this.asyncLoadDB().then(addonDB => + getRepositoryAddon(addonDB.get(aLocation + ":" + aId)) + ); + }, + + /** + * Asynchronously get all the add-ons in a particular install location. + * + * @param {string} aLocation + * The name of the install location + * @returns {Promise<Array<AddonInternal>>} + */ + getAddonsInLocation(aLocation) { + return this.getAddonList(aAddon => aAddon.location.name == aLocation); + }, + + /** + * Asynchronously gets the add-on with the specified ID that is visible. + * + * @param {string} aId + * The ID of the add-on to retrieve + * @returns {Promise<AddonInternal?>} + */ + getVisibleAddonForID(aId) { + return this.getAddon(aAddon => aAddon.id == aId && aAddon.visible); + }, + + /** + * Asynchronously gets the visible add-ons, optionally restricting by type. + * + * @param {Set<string>?} aTypes + * An array of types to include or null to include all types + * @returns {Promise<Array<AddonInternal>>} + */ + getVisibleAddons(aTypes) { + return this.getAddonList( + aAddon => aAddon.visible && (!aTypes || aTypes.has(aAddon.type)) + ); + }, + + /** + * Synchronously gets all add-ons of a particular type(s). + * + * @param {Array<string>} aTypes + * The type(s) of add-on to retrieve + * @returns {Array<AddonInternal>} + */ + getAddonsByType(...aTypes) { + if (!this.addonDB) { + // jank-tastic! Must synchronously load DB if the theme switches from + // an XPI theme to a lightweight theme before the DB has loaded, + // because we're called from sync XPIProvider.addonChanged + logger.warn( + `Synchronous load of XPI database due to ` + + `getAddonsByType([${aTypes.join(", ")}]) ` + + `Stack: ${Error().stack}` + ); + this.syncLoadDB(true); + } + + return _filterDB(this.addonDB, aAddon => aTypes.includes(aAddon.type)); + }, + + /** + * Asynchronously gets all add-ons with pending operations. + * + * @param {Set<string>?} aTypes + * The types of add-ons to retrieve or null to get all types + * @returns {Promise<Array<AddonInternal>>} + */ + getVisibleAddonsWithPendingOperations(aTypes) { + return this.getAddonList( + aAddon => + aAddon.visible && + aAddon.pendingUninstall && + (!aTypes || aTypes.has(aAddon.type)) + ); + }, + + /** + * Synchronously gets all add-ons in the database. + * This is only called from the preference observer for the default + * compatibility version preference, so we can return an empty list if + * we haven't loaded the database yet. + * + * @returns {Array<AddonInternal>} + */ + getAddons() { + if (!this.addonDB) { + return []; + } + return _filterDB(this.addonDB, aAddon => true); + }, + + /** + * Called to get an Addon with a particular ID. + * + * @param {string} aId + * The ID of the add-on to retrieve + * @returns {Addon?} + */ + async getAddonByID(aId) { + let aAddon = await this.getVisibleAddonForID(aId); + return aAddon ? aAddon.wrapper : null; + }, + + /** + * Obtain an Addon having the specified Sync GUID. + * + * @param {string} aGUID + * String GUID of add-on to retrieve + * @returns {Addon?} + */ + async getAddonBySyncGUID(aGUID) { + let addon = await this.getAddon(aAddon => aAddon.syncGUID == aGUID); + return addon ? addon.wrapper : null; + }, + + /** + * Called to get Addons of a particular type. + * + * @param {Array<string>?} aTypes + * An array of types to fetch. Can be null to get all types. + * @returns {Addon[]} + */ + async getAddonsByTypes(aTypes) { + let addons = await this.getVisibleAddons(aTypes ? new Set(aTypes) : null); + return addons.map(a => a.wrapper); + }, + + /** + * Returns true if signing is required for the given add-on type. + * + * @param {string} aType + * The add-on type to check. + * @returns {boolean} + */ + mustSign(aType) { + if (!SIGNED_TYPES.has(aType)) { + return false; + } + + if (aType == "locale") { + return AddonSettings.LANGPACKS_REQUIRE_SIGNING; + } + + return AddonSettings.REQUIRE_SIGNING; + }, + + /** + * Determine if this addon should be disabled due to being legacy + * + * @param {Addon} addon The addon to check + * + * @returns {boolean} Whether the addon should be disabled for being legacy + */ + isDisabledLegacy(addon) { + // We still have tests that use a legacy addon type, allow them + // if we're in automation. Otherwise, disable if not a webextension. + if (!Cu.isInAutomation) { + return !addon.isWebExtension; + } + + return ( + !addon.isWebExtension && + addon.type === "extension" && + // Test addons are privileged unless forced otherwise. + addon.signedState !== AddonManager.SIGNEDSTATE_PRIVILEGED + ); + }, + + /** + * Calculates whether an add-on should be appDisabled or not. + * + * @param {AddonInternal} aAddon + * The add-on to check + * @returns {boolean} + * True if the add-on should not be appDisabled + */ + isUsableAddon(aAddon) { + if (this.mustSign(aAddon.type) && !aAddon.isCorrectlySigned) { + logger.warn(`Add-on ${aAddon.id} is not correctly signed.`); + if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) { + logger.warn(`Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`); + } + return false; + } + + if (aAddon.blocklistState == nsIBlocklistService.STATE_BLOCKED) { + logger.warn(`Add-on ${aAddon.id} is blocklisted.`); + return false; + } + + // If we can't read it, it's not usable: + if (aAddon.brokenManifest) { + return false; + } + + if (AddonManager.checkUpdateSecurity && !aAddon.providesUpdatesSecurely) { + logger.warn( + `Updates for add-on ${aAddon.id} must be provided over HTTPS.` + ); + return false; + } + + if (!aAddon.isPlatformCompatible) { + logger.warn(`Add-on ${aAddon.id} is not compatible with platform.`); + return false; + } + + if (aAddon.dependencies.length) { + let isActive = id => { + let active = XPIProvider.activeAddons.get(id); + return active && !active._pendingDisable; + }; + + if (aAddon.dependencies.some(id => !isActive(id))) { + return false; + } + } + + if (this.isDisabledLegacy(aAddon)) { + logger.warn(`disabling legacy extension ${aAddon.id}`); + return false; + } + + if (AddonManager.checkCompatibility) { + if (!aAddon.isCompatible) { + logger.warn( + `Add-on ${aAddon.id} is not compatible with application version.` + ); + return false; + } + } else { + let app = aAddon.matchingTargetApplication; + if (!app) { + logger.warn( + `Add-on ${aAddon.id} is not compatible with target application.` + ); + return false; + } + } + + if (aAddon.location.isSystem || aAddon.location.isBuiltin) { + return true; + } + + if (Services.policies && !Services.policies.mayInstallAddon(aAddon)) { + return false; + } + + return true; + }, + + /** + * Synchronously adds an AddonInternal's metadata to the database. + * + * @param {AddonInternal} aAddon + * AddonInternal to add + * @param {string} aPath + * The file path of the add-on + * @returns {AddonInternal} + * the AddonInternal that was added to the database + */ + addToDatabase(aAddon, aPath) { + aAddon.addedToDatabase(); + aAddon.path = aPath; + this.addonDB.set(aAddon._key, aAddon); + if (aAddon.visible) { + this.makeAddonVisible(aAddon); + } + + this.saveChanges(); + return aAddon; + }, + + /** + * Synchronously updates an add-on's metadata in the database. Currently just + * removes and recreates. + * + * @param {AddonInternal} aOldAddon + * The AddonInternal to be replaced + * @param {AddonInternal} aNewAddon + * The new AddonInternal to add + * @param {string} aPath + * The file path of the add-on + * @returns {AddonInternal} + * The AddonInternal that was added to the database + */ + updateAddonMetadata(aOldAddon, aNewAddon, aPath) { + this.removeAddonMetadata(aOldAddon); + aNewAddon.syncGUID = aOldAddon.syncGUID; + aNewAddon.installDate = aOldAddon.installDate; + aNewAddon.applyBackgroundUpdates = aOldAddon.applyBackgroundUpdates; + aNewAddon.foreignInstall = aOldAddon.foreignInstall; + aNewAddon.seen = aOldAddon.seen; + aNewAddon.active = + aNewAddon.visible && !aNewAddon.disabled && !aNewAddon.pendingUninstall; + aNewAddon.installTelemetryInfo = aOldAddon.installTelemetryInfo; + + return this.addToDatabase(aNewAddon, aPath); + }, + + /** + * Synchronously removes an add-on from the database. + * + * @param {AddonInternal} aAddon + * The AddonInternal being removed + */ + removeAddonMetadata(aAddon) { + this.addonDB.delete(aAddon._key); + this.saveChanges(); + }, + + updateXPIStates(addon) { + let state = addon.location && addon.location.get(addon.id); + if (state) { + state.syncWithDB(addon); + XPIStates.save(); + } + }, + + /** + * Synchronously marks a AddonInternal as visible marking all other + * instances with the same ID as not visible. + * + * @param {AddonInternal} aAddon + * The AddonInternal to make visible + */ + makeAddonVisible(aAddon) { + logger.debug("Make addon " + aAddon._key + " visible"); + for (let [, otherAddon] of this.addonDB) { + if (otherAddon.id == aAddon.id && otherAddon._key != aAddon._key) { + logger.debug("Hide addon " + otherAddon._key); + otherAddon.visible = false; + otherAddon.active = false; + + this.updateXPIStates(otherAddon); + } + } + aAddon.visible = true; + this.updateXPIStates(aAddon); + this.saveChanges(); + }, + + /** + * Synchronously marks a given add-on ID visible in a given location, + * instances with the same ID as not visible. + * + * @param {string} aId + * The ID of the add-on to make visible + * @param {XPIStateLocation} aLocation + * The location in which to make the add-on visible. + * @returns {AddonInternal?} + * The add-on instance which was marked visible, if any. + */ + makeAddonLocationVisible(aId, aLocation) { + logger.debug(`Make addon ${aId} visible in location ${aLocation}`); + let result; + for (let [, addon] of this.addonDB) { + if (addon.id != aId) { + continue; + } + if (addon.location == aLocation) { + logger.debug("Reveal addon " + addon._key); + addon.visible = true; + addon.active = true; + this.updateXPIStates(addon); + result = addon; + } else { + logger.debug("Hide addon " + addon._key); + addon.visible = false; + addon.active = false; + this.updateXPIStates(addon); + } + } + this.saveChanges(); + return result; + }, + + /** + * Synchronously sets properties for an add-on. + * + * @param {AddonInternal} aAddon + * The AddonInternal being updated + * @param {Object} aProperties + * A dictionary of properties to set + */ + setAddonProperties(aAddon, aProperties) { + for (let key in aProperties) { + aAddon[key] = aProperties[key]; + } + this.saveChanges(); + }, + + /** + * Synchronously sets the Sync GUID for an add-on. + * Only called when the database is already loaded. + * + * @param {AddonInternal} aAddon + * The AddonInternal being updated + * @param {string} aGUID + * GUID string to set the value to + * @throws if another addon already has the specified GUID + */ + setAddonSyncGUID(aAddon, aGUID) { + // Need to make sure no other addon has this GUID + function excludeSyncGUID(otherAddon) { + return otherAddon._key != aAddon._key && otherAddon.syncGUID == aGUID; + } + let otherAddon = _findAddon(this.addonDB, excludeSyncGUID); + if (otherAddon) { + throw new Error( + "Addon sync GUID conflict for addon " + + aAddon._key + + ": " + + otherAddon._key + + " already has GUID " + + aGUID + ); + } + aAddon.syncGUID = aGUID; + this.saveChanges(); + }, + + /** + * Synchronously updates an add-on's active flag in the database. + * + * @param {AddonInternal} aAddon + * The AddonInternal to update + * @param {boolean} aActive + * The new active state for the add-on. + */ + updateAddonActive(aAddon, aActive) { + logger.debug( + "Updating active state for add-on " + aAddon.id + " to " + aActive + ); + + aAddon.active = aActive; + this.saveChanges(); + }, + + /** + * Synchronously calculates and updates all the active flags in the database. + */ + updateActiveAddons() { + logger.debug("Updating add-on states"); + for (let [, addon] of this.addonDB) { + let newActive = + addon.visible && !addon.disabled && !addon.pendingUninstall; + if (newActive != addon.active) { + addon.active = newActive; + this.saveChanges(); + } + } + + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false); + }, + + /** + * Updates the disabled state for an add-on. Its appDisabled property will be + * calculated and if the add-on is changed the database will be saved and + * appropriate notifications will be sent out to the registered AddonListeners. + * + * @param {AddonInternal} aAddon + * The AddonInternal to update + * @param {Object} properties - Properties to set on the addon + * @param {boolean?} [properties.userDisabled] + * Value for the userDisabled property. If undefined the value will + * not change + * @param {boolean?} [properties.softDisabled] + * Value for the softDisabled property. If undefined the value will + * not change. If true this will force userDisabled to be true + * @param {boolean?} [properties.embedderDisabled] + * Value for the embedderDisabled property. If undefined the value will + * not change. + * @param {boolean?} [properties.becauseSelecting] + * True if we're disabling this add-on because we're selecting + * another. + * @returns {Promise<boolean?>} + * A tri-state indicating the action taken for the add-on: + * - undefined: The add-on did not change state + * - true: The add-on became disabled + * - false: The add-on became enabled + * @throws if addon is not a AddonInternal + */ + async updateAddonDisabledState( + aAddon, + { userDisabled, softDisabled, embedderDisabled, becauseSelecting } = {} + ) { + if (!aAddon.inDatabase) { + throw new Error("Can only update addon states for installed addons."); + } + if (userDisabled !== undefined && softDisabled !== undefined) { + throw new Error( + "Cannot change userDisabled and softDisabled at the same time" + ); + } + + if (userDisabled === undefined) { + userDisabled = aAddon.userDisabled; + } else if (!userDisabled) { + // If enabling the add-on then remove softDisabled + softDisabled = false; + } + + // If not changing softDisabled or the add-on is already userDisabled then + // use the existing value for softDisabled + if (softDisabled === undefined || userDisabled) { + softDisabled = aAddon.softDisabled; + } + + if (!AddonSettings.IS_EMBEDDED) { + // If embedderDisabled was accidentally set somehow, this will revert it + // back to false. + embedderDisabled = false; + } else if (embedderDisabled === undefined) { + embedderDisabled = aAddon.embedderDisabled; + } + + let appDisabled = !this.isUsableAddon(aAddon); + // No change means nothing to do here + if ( + aAddon.userDisabled == userDisabled && + aAddon.appDisabled == appDisabled && + aAddon.softDisabled == softDisabled && + aAddon.embedderDisabled == embedderDisabled + ) { + return undefined; + } + + let wasDisabled = aAddon.disabled; + let isDisabled = + userDisabled || softDisabled || appDisabled || embedderDisabled; + + // If appDisabled changes but addon.disabled doesn't, + // no onDisabling/onEnabling is sent - so send a onPropertyChanged. + let appDisabledChanged = aAddon.appDisabled != appDisabled; + + // Update the properties in the database. + this.setAddonProperties(aAddon, { + userDisabled, + appDisabled, + softDisabled, + embedderDisabled, + }); + + let wrapper = aAddon.wrapper; + + if (appDisabledChanged) { + AddonManagerPrivate.callAddonListeners("onPropertyChanged", wrapper, [ + "appDisabled", + ]); + } + + // If the add-on is not visible or the add-on is not changing state then + // there is no need to do anything else + if (!aAddon.visible || wasDisabled == isDisabled) { + return undefined; + } + + // Flag that active states in the database need to be updated on shutdown + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true); + + this.updateXPIStates(aAddon); + + // Have we just gone back to the current state? + if (isDisabled != aAddon.active) { + AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper); + } else { + if (isDisabled) { + AddonManagerPrivate.callAddonListeners("onDisabling", wrapper, false); + } else { + AddonManagerPrivate.callAddonListeners("onEnabling", wrapper, false); + } + + this.updateAddonActive(aAddon, !isDisabled); + + let bootstrap = XPIInternal.BootstrapScope.get(aAddon); + if (isDisabled) { + await bootstrap.disable(); + AddonManagerPrivate.callAddonListeners("onDisabled", wrapper); + } else { + await bootstrap.startup(BOOTSTRAP_REASONS.ADDON_ENABLE); + AddonManagerPrivate.callAddonListeners("onEnabled", wrapper); + } + } + + // Notify any other providers that a new theme has been enabled + if (aAddon.type === "theme") { + if (!isDisabled) { + AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type); + this.updateXPIStates(aAddon); + } else if (isDisabled && !becauseSelecting) { + AddonManagerPrivate.notifyAddonChanged(null, "theme"); + } + } + + return isDisabled; + }, + + /** + * Update the appDisabled property for all add-ons. + */ + updateAddonAppDisabledStates() { + for (let addon of this.getAddons()) { + this.updateAddonDisabledState(addon); + } + }, + + /** + * Update the repositoryAddon property for all add-ons. + */ + async updateAddonRepositoryData() { + let addons = await this.getVisibleAddons(null); + logger.debug( + "updateAddonRepositoryData found " + addons.length + " visible add-ons" + ); + + await Promise.all( + addons.map(addon => + AddonRepository.getCachedAddonByID(addon.id).then(aRepoAddon => { + if (aRepoAddon) { + logger.debug("updateAddonRepositoryData got info for " + addon.id); + addon._repositoryAddon = aRepoAddon; + this.updateAddonDisabledState(addon); + } + }) + ) + ); + }, + + /** + * Adds the add-on's name and creator to the telemetry payload. + * + * @param {AddonInternal} aAddon + * The addon to record + */ + recordAddonTelemetry(aAddon) { + let locale = aAddon.defaultLocale; + XPIProvider.addTelemetry(aAddon.id, { + name: locale.name, + creator: locale.creator, + }); + }, +}; + +this.XPIDatabaseReconcile = { + /** + * Returns a map of ID -> add-on. When the same add-on ID exists in multiple + * install locations the highest priority location is chosen. + * + * @param {Map<String, AddonInternal>} addonMap + * The add-on map to flatten. + * @param {string?} [hideLocation] + * An optional location from which to hide any add-ons. + * @returns {Map<string, AddonInternal>} + */ + flattenByID(addonMap, hideLocation) { + let map = new Map(); + + for (let loc of XPIStates.locations()) { + if (loc.name == hideLocation) { + continue; + } + + let locationMap = addonMap.get(loc.name); + if (!locationMap) { + continue; + } + + for (let [id, addon] of locationMap) { + if (!map.has(id)) { + map.set(id, addon); + } + } + } + + return map; + }, + + /** + * Finds the visible add-ons from the map. + * + * @param {Map<String, AddonInternal>} addonMap + * The add-on map to filter. + * @returns {Map<string, AddonInternal>} + */ + getVisibleAddons(addonMap) { + let map = new Map(); + + for (let addons of addonMap.values()) { + for (let [id, addon] of addons) { + if (!addon.visible) { + continue; + } + + if (map.has(id)) { + logger.warn( + "Previous database listed more than one visible add-on with id " + + id + ); + continue; + } + + map.set(id, addon); + } + } + + return map; + }, + + /** + * Called to add the metadata for an add-on in one of the install locations + * to the database. This can be called in three different cases. Either an + * add-on has been dropped into the location from outside of Firefox, or + * an add-on has been installed through the application, or the database + * has been upgraded or become corrupt and add-on data has to be reloaded + * into it. + * + * @param {XPIStateLocation} aLocation + * The install location containing the add-on + * @param {string} aId + * The ID of the add-on + * @param {XPIState} aAddonState + * The new state of the add-on + * @param {AddonInternal?} [aNewAddon] + * The manifest for the new add-on if it has already been loaded + * @param {string?} [aOldAppVersion] + * The version of the application last run with this profile or null + * if it is a new profile or the version is unknown + * @param {string?} [aOldPlatformVersion] + * The version of the platform last run with this profile or null + * if it is a new profile or the version is unknown + * @returns {boolean} + * A boolean indicating if flushing caches is required to complete + * changing this add-on + */ + addMetadata( + aLocation, + aId, + aAddonState, + aNewAddon, + aOldAppVersion, + aOldPlatformVersion + ) { + logger.debug(`New add-on ${aId} installed in ${aLocation.name}`); + + // We treat this is a new install if, + // + // a) It was explicitly registered as a staged install in the last + // session, or, + // b) We're not currently migrating or rebuilding a corrupt database. In + // that case, we can assume this add-on was found during a routine + // directory scan. + let isNewInstall = !!aNewAddon || !XPIDatabase.rebuildingDatabase; + + // If it's a new install and we haven't yet loaded the manifest then it + // must be something dropped directly into the install location + let isDetectedInstall = isNewInstall && !aNewAddon; + + // Load the manifest if necessary and sanity check the add-on ID + let unsigned; + try { + // Do not allow third party installs if xpinstall is disabled by policy + if ( + isDetectedInstall && + Services.policies && + !Services.policies.isAllowed("xpinstall") + ) { + throw new Error( + "Extension installs are disabled by enterprise policy." + ); + } + + if (!aNewAddon) { + // Load the manifest from the add-on. + aNewAddon = XPIInstall.syncLoadManifest(aAddonState, aLocation); + } + // The add-on in the manifest should match the add-on ID. + if (aNewAddon.id != aId) { + throw new Error( + `Invalid addon ID: expected addon ID ${aId}, found ${aNewAddon.id} in manifest` + ); + } + + unsigned = + XPIDatabase.mustSign(aNewAddon.type) && !aNewAddon.isCorrectlySigned; + if (unsigned) { + throw Error(`Extension ${aNewAddon.id} is not correctly signed`); + } + } catch (e) { + logger.warn(`addMetadata: Add-on ${aId} is invalid`, e); + + // Remove the invalid add-on from the install location if the install + // location isn't locked + if (aLocation.isLinkedAddon(aId)) { + logger.warn("Not uninstalling invalid item because it is a proxy file"); + } else if (aLocation.locked) { + logger.warn( + "Could not uninstall invalid item from locked install location" + ); + } else if (unsigned && !isNewInstall) { + logger.warn("Not uninstalling existing unsigned add-on"); + } else if (aLocation.name == KEY_APP_BUILTINS) { + // If a builtin has been removed from the build, we need to remove it from our + // data sets. We cannot use location.isBuiltin since the system addon locations + // mix it up. + XPIDatabase.removeAddonMetadata(aAddonState); + aLocation.removeAddon(aId); + } else { + aLocation.installer.uninstallAddon(aId); + } + return null; + } + + // Update the AddonInternal properties. + aNewAddon.installDate = aAddonState.mtime; + aNewAddon.updateDate = aAddonState.mtime; + + // Assume that add-ons in the system add-ons install location aren't + // foreign and should default to enabled. + aNewAddon.foreignInstall = + isDetectedInstall && !aLocation.isSystem && !aLocation.isBuiltin; + + // appDisabled depends on whether the add-on is a foreignInstall so update + aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon); + + if (isDetectedInstall && aNewAddon.foreignInstall) { + // Add the installation source info for the sideloaded extension. + aNewAddon.installTelemetryInfo = { + source: aLocation.name, + method: "sideload", + }; + + // If the add-on is a foreign install and is in a scope where add-ons + // that were dropped in should default to disabled then disable it + let disablingScopes = Services.prefs.getIntPref( + PREF_EM_AUTO_DISABLED_SCOPES, + 0 + ); + if (aLocation.scope & disablingScopes) { + logger.warn( + `Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}` + ); + aNewAddon.userDisabled = true; + aNewAddon.seen = false; + } + } + + return XPIDatabase.addToDatabase(aNewAddon, aAddonState.path); + }, + + /** + * Called when an add-on has been removed. + * + * @param {AddonInternal} aOldAddon + * The AddonInternal as it appeared the last time the application + * ran + */ + removeMetadata(aOldAddon) { + // This add-on has disappeared + logger.debug( + "Add-on " + aOldAddon.id + " removed from " + aOldAddon.location.name + ); + XPIDatabase.removeAddonMetadata(aOldAddon); + }, + + /** + * Updates an add-on's metadata and determines. This is called when either the + * add-on's install directory path or last modified time has changed. + * + * @param {XPIStateLocation} aLocation + * The install location containing the add-on + * @param {AddonInternal} aOldAddon + * The AddonInternal as it appeared the last time the application + * ran + * @param {XPIState} aAddonState + * The new state of the add-on + * @param {AddonInternal?} [aNewAddon] + * The manifest for the new add-on if it has already been loaded + * @returns {AddonInternal} + * The AddonInternal that was added to the database + */ + updateMetadata(aLocation, aOldAddon, aAddonState, aNewAddon) { + logger.debug(`Add-on ${aOldAddon.id} modified in ${aLocation.name}`); + + try { + // If there isn't an updated install manifest for this add-on then load it. + if (!aNewAddon) { + aNewAddon = XPIInstall.syncLoadManifest( + aAddonState, + aLocation, + aOldAddon + ); + } else { + aNewAddon.rootURI = aOldAddon.rootURI; + } + + // The ID in the manifest that was loaded must match the ID of the old + // add-on. + if (aNewAddon.id != aOldAddon.id) { + throw new Error( + `Incorrect id in install manifest for existing add-on ${aOldAddon.id}` + ); + } + } catch (e) { + logger.warn(`updateMetadata: Add-on ${aOldAddon.id} is invalid`, e); + + XPIDatabase.removeAddonMetadata(aOldAddon); + aOldAddon.location.removeAddon(aOldAddon.id); + + if (!aLocation.locked) { + aLocation.installer.uninstallAddon(aOldAddon.id); + } else { + logger.warn( + "Could not uninstall invalid item from locked install location" + ); + } + + return null; + } + + // Set the additional properties on the new AddonInternal + aNewAddon.updateDate = aAddonState.mtime; + + XPIProvider.persistStartupData(aNewAddon, aAddonState); + + // Update the database + return XPIDatabase.updateAddonMetadata( + aOldAddon, + aNewAddon, + aAddonState.path + ); + }, + + /** + * Updates an add-on's path for when the add-on has moved in the + * filesystem but hasn't changed in any other way. + * + * @param {XPIStateLocation} aLocation + * The install location containing the add-on + * @param {AddonInternal} aOldAddon + * The AddonInternal as it appeared the last time the application + * ran + * @param {XPIState} aAddonState + * The new state of the add-on + * @returns {AddonInternal} + */ + updatePath(aLocation, aOldAddon, aAddonState) { + logger.debug(`Add-on ${aOldAddon.id} moved to ${aAddonState.path}`); + aOldAddon.path = aAddonState.path; + aOldAddon._sourceBundle = new nsIFile(aAddonState.path); + aOldAddon.rootURI = XPIInternal.getURIForResourceInFile( + aOldAddon._sourceBundle, + "" + ).spec; + + return aOldAddon; + }, + + /** + * Called when no change has been detected for an add-on's metadata but the + * application has changed so compatibility may have changed. + * + * @param {XPIStateLocation} aLocation + * The install location containing the add-on + * @param {AddonInternal} aOldAddon + * The AddonInternal as it appeared the last time the application + * ran + * @param {XPIState} aAddonState + * The new state of the add-on + * @param {boolean} [aReloadMetadata = false] + * A boolean which indicates whether metadata should be reloaded from + * the addon manifests. Default to false. + * @returns {AddonInternal} + * The new addon. + */ + updateCompatibility(aLocation, aOldAddon, aAddonState, aReloadMetadata) { + logger.debug( + `Updating compatibility for add-on ${aOldAddon.id} in ${aLocation.name}` + ); + + let checkSigning = + aOldAddon.signedState === undefined && SIGNED_TYPES.has(aOldAddon.type); + // signedDate must be set if signedState is set. + let signedDateMissing = + aOldAddon.signedDate === undefined && + (aOldAddon.signedState || checkSigning); + + // If maxVersion was inadvertently updated for a locale, force a reload + // from the manifest. See Bug 1646016 for details. + if ( + !aReloadMetadata && + aOldAddon.type === "locale" && + aOldAddon.matchingTargetApplication + ) { + aReloadMetadata = aOldAddon.matchingTargetApplication.maxVersion === "*"; + } + + let manifest = null; + if (checkSigning || aReloadMetadata || signedDateMissing) { + try { + manifest = XPIInstall.syncLoadManifest(aAddonState, aLocation); + } catch (err) { + // If we can no longer read the manifest, it is no longer compatible. + aOldAddon.brokenManifest = true; + aOldAddon.appDisabled = true; + return aOldAddon; + } + } + + // If updating from a version of the app that didn't support signedState + // then update that property now + if (checkSigning) { + aOldAddon.signedState = manifest.signedState; + } + + if (signedDateMissing) { + aOldAddon.signedDate = manifest.signedDate; + } + + // May be updating from a version of the app that didn't support all the + // properties of the currently-installed add-ons. + if (aReloadMetadata) { + // Avoid re-reading these properties from manifest, + // use existing addon instead. + let remove = [ + "syncGUID", + "foreignInstall", + "visible", + "active", + "userDisabled", + "embedderDisabled", + "applyBackgroundUpdates", + "sourceURI", + "releaseNotesURI", + "installTelemetryInfo", + ]; + + // TODO - consider re-scanning for targetApplications for other addon types. + if (aOldAddon.type !== "locale") { + remove.push("targetApplications"); + } + + let props = PROP_JSON_FIELDS.filter(a => !remove.includes(a)); + copyProperties(manifest, props, aOldAddon); + } + + aOldAddon.appDisabled = !XPIDatabase.isUsableAddon(aOldAddon); + + return aOldAddon; + }, + + /** + * Returns true if this install location is part of the application + * bundle. Add-ons in these locations are expected to change whenever + * the application updates. + * + * @param {XPIStateLocation} location + * The install location to check. + * @returns {boolean} + * True if this location is part of the application bundle. + */ + isAppBundledLocation(location) { + return ( + location.name == KEY_APP_GLOBAL || + location.name == KEY_APP_SYSTEM_DEFAULTS || + location.name == KEY_APP_BUILTINS + ); + }, + + /** + * Returns true if this install location holds system addons. + * + * @param {XPIStateLocation} location + * The install location to check. + * @returns {boolean} + * True if this location contains system add-ons. + */ + isSystemAddonLocation(location) { + return ( + location.name === KEY_APP_SYSTEM_DEFAULTS || + location.name === KEY_APP_SYSTEM_ADDONS + ); + }, + + /** + * Updates the databse metadata for an existing add-on during database + * reconciliation. + * + * @param {AddonInternal} oldAddon + * The existing database add-on entry. + * @param {XPIState} xpiState + * The XPIStates entry for this add-on. + * @param {AddonInternal?} newAddon + * The new add-on metadata for the add-on, as loaded from a + * staged update in addonStartup.json. + * @param {boolean} aUpdateCompatibility + * true to update add-ons appDisabled property when the application + * version has changed + * @param {boolean} aSchemaChange + * The schema has changed and all add-on manifests should be re-read. + * @returns {AddonInternal?} + * The updated AddonInternal object for the add-on, if one + * could be created. + */ + updateExistingAddon( + oldAddon, + xpiState, + newAddon, + aUpdateCompatibility, + aSchemaChange + ) { + XPIDatabase.recordAddonTelemetry(oldAddon); + + let installLocation = oldAddon.location; + + // Update the add-on's database metadata from on-disk metadata if: + // + // a) The add-on was staged for install in the last session, + // b) The add-on has been modified since the last session, or, + // c) The app has been updated since the last session, and the + // add-on is part of the application bundle (and has therefore + // likely been replaced in the update process). + if ( + newAddon || + oldAddon.updateDate != xpiState.mtime || + (aUpdateCompatibility && this.isAppBundledLocation(installLocation)) + ) { + newAddon = this.updateMetadata( + installLocation, + oldAddon, + xpiState, + newAddon + ); + } else if (oldAddon.path != xpiState.path) { + newAddon = this.updatePath(installLocation, oldAddon, xpiState); + } else if (aUpdateCompatibility || aSchemaChange) { + newAddon = this.updateCompatibility( + installLocation, + oldAddon, + xpiState, + aSchemaChange + ); + } else { + newAddon = oldAddon; + } + + if (newAddon) { + newAddon.rootURI = newAddon.rootURI || xpiState.rootURI; + } + + return newAddon; + }, + + /** + * Compares the add-ons that are currently installed to those that were + * known to be installed when the application last ran and applies any + * changes found to the database. + * Always called after XPIDatabase.jsm and extensions.json have been loaded. + * + * @param {Object} aManifests + * A dictionary of cached AddonInstalls for add-ons that have been + * installed + * @param {boolean} aUpdateCompatibility + * true to update add-ons appDisabled property when the application + * version has changed + * @param {string?} [aOldAppVersion] + * The version of the application last run with this profile or null + * if it is a new profile or the version is unknown + * @param {string?} [aOldPlatformVersion] + * The version of the platform last run with this profile or null + * if it is a new profile or the version is unknown + * @param {boolean} aSchemaChange + * The schema has changed and all add-on manifests should be re-read. + * @returns {boolean} + * A boolean indicating if a change requiring flushing the caches was + * detected + */ + processFileChanges( + aManifests, + aUpdateCompatibility, + aOldAppVersion, + aOldPlatformVersion, + aSchemaChange + ) { + let findManifest = (loc, id) => { + return (aManifests[loc.name] && aManifests[loc.name][id]) || null; + }; + + let previousAddons = new ExtensionUtils.DefaultMap(() => new Map()); + let currentAddons = new ExtensionUtils.DefaultMap(() => new Map()); + + // Get the previous add-ons from the database and put them into maps by location + for (let addon of XPIDatabase.getAddons()) { + previousAddons.get(addon.location.name).set(addon.id, addon); + } + + // Keep track of add-ons whose blocklist status may have changed. We'll check this + // after everything else. + let addonsToCheckAgainstBlocklist = []; + + // Build the list of current add-ons into similar maps. When add-ons are still + // present we re-use the add-on objects from the database and update their + // details directly + let addonStates = new Map(); + for (let location of XPIStates.locations()) { + let locationAddons = currentAddons.get(location.name); + + // Get all the on-disk XPI states for this location, and keep track of which + // ones we see in the database. + let dbAddons = previousAddons.get(location.name) || new Map(); + for (let [id, oldAddon] of dbAddons) { + // Check if the add-on is still installed + let xpiState = location.get(id); + if (xpiState && !xpiState.missing) { + let newAddon = this.updateExistingAddon( + oldAddon, + xpiState, + findManifest(location, id), + aUpdateCompatibility, + aSchemaChange + ); + if (newAddon) { + locationAddons.set(newAddon.id, newAddon); + + // We need to do a blocklist check later, but the add-on may have changed by then. + // Avoid storing the current copy and just get one when we need one instead. + addonsToCheckAgainstBlocklist.push(newAddon.id); + } + } else { + // The add-on is in the DB, but not in xpiState (and thus not on disk). + this.removeMetadata(oldAddon); + } + } + + for (let [id, xpiState] of location) { + if (locationAddons.has(id) || xpiState.missing) { + continue; + } + let newAddon = findManifest(location, id); + let addon = this.addMetadata( + location, + id, + xpiState, + newAddon, + aOldAppVersion, + aOldPlatformVersion + ); + if (addon) { + locationAddons.set(addon.id, addon); + addonStates.set(addon, xpiState); + } + } + + if (this.isSystemAddonLocation(location)) { + for (let [id, addon] of locationAddons.entries()) { + const pref = `extensions.${id.split("@")[0]}.enabled`; + addon.userDisabled = !Services.prefs.getBoolPref(pref, true); + } + } + } + + // Validate the updated system add-ons + let hideLocation; + { + let systemAddonLocation = XPIStates.getLocation(KEY_APP_SYSTEM_ADDONS); + let addons = currentAddons.get(systemAddonLocation.name); + + if (!systemAddonLocation.installer.isValid(addons)) { + // Hide the system add-on updates if any are invalid. + logger.info( + "One or more updated system add-ons invalid, falling back to defaults." + ); + hideLocation = systemAddonLocation.name; + } + } + + // Apply startup changes to any currently-visible add-ons, and + // uninstall any which were previously visible, but aren't anymore. + let previousVisible = this.getVisibleAddons(previousAddons); + let currentVisible = this.flattenByID(currentAddons, hideLocation); + + for (let addon of XPIDatabase.orphanedAddons.splice(0)) { + if (addon.visible) { + previousVisible.set(addon.id, addon); + } + } + + let promises = []; + for (let [id, addon] of currentVisible) { + // If we have a stored manifest for the add-on, it came from the + // startup data cache, and supersedes any previous XPIStates entry. + let xpiState = + !findManifest(addon.location, id) && addonStates.get(addon); + + promises.push( + this.applyStartupChange(addon, previousVisible.get(id), xpiState) + ); + previousVisible.delete(id); + } + + if (promises.some(p => p)) { + XPIInternal.awaitPromise(Promise.all(promises)); + } + + for (let [id, addon] of previousVisible) { + if (addon.location) { + if (addon.location.name == KEY_APP_BUILTINS) { + continue; + } + XPIInternal.BootstrapScope.get(addon).uninstall(); + addon.location.removeAddon(id); + addon.visible = false; + addon.active = false; + } + + AddonManagerPrivate.addStartupChange( + AddonManager.STARTUP_CHANGE_UNINSTALLED, + id + ); + } + + // Finally update XPIStates to match everything + for (let [locationName, locationAddons] of currentAddons) { + for (let [id, addon] of locationAddons) { + let xpiState = XPIStates.getAddon(locationName, id); + xpiState.syncWithDB(addon); + } + } + XPIStates.save(); + XPIDatabase.saveChanges(); + XPIDatabase.rebuildingDatabase = false; + + if (aUpdateCompatibility || aSchemaChange) { + // Do some blocklist checks. These will happen after we've just saved everything, + // because they're async and depend on the blocklist loading. When we're done, save + // the data if any of the add-ons' blocklist state has changed. + AddonManager.beforeShutdown.addBlocker( + "Update add-on blocklist state into add-on DB", + (async () => { + // Avoid querying the AddonManager immediately to give startup a chance + // to complete. + await Promise.resolve(); + + let addons = await AddonManager.getAddonsByIDs( + addonsToCheckAgainstBlocklist + ); + await Promise.all( + addons.map(addon => { + return ( + addon && addon.updateBlocklistState({ updateDatabase: false }) + ); + }) + ); + + XPIDatabase.saveChanges(); + })() + ); + } + + return true; + }, + + /** + * Applies a startup change for the given add-on. + * + * @param {AddonInternal} currentAddon + * The add-on as it exists in this session. + * @param {AddonInternal?} previousAddon + * The add-on as it existed in the previous session. + * @param {XPIState?} xpiState + * The XPIState entry for this add-on, if one exists. + * @returns {Promise?} + * If an update was performed, returns a promise which resolves + * when the appropriate bootstrap methods have been called. + */ + applyStartupChange(currentAddon, previousAddon, xpiState) { + let promise; + let { id } = currentAddon; + + let isActive = !currentAddon.disabled; + let wasActive = previousAddon ? previousAddon.active : currentAddon.active; + + if (previousAddon) { + if (previousAddon !== currentAddon) { + AddonManagerPrivate.addStartupChange( + AddonManager.STARTUP_CHANGE_CHANGED, + id + ); + + // Bug 1664144: If the addon changed on disk we will catch it during + // the second scan initiated by getNewSideloads. The addon may have + // already started, if so we need to ensure it restarts during the + // update, otherwise we're left in a state where the addon is enabled + // but not started. We use the bootstrap started state to check that. + // isActive alone is not sufficient as that changes the characteristics + // of other updates and breaks many tests. + let restart = + isActive && XPIInternal.BootstrapScope.get(currentAddon).started; + if (restart) { + logger.warn( + `Updating and restart addon ${previousAddon.id} that changed on disk after being already started.` + ); + } + promise = XPIInternal.BootstrapScope.get(previousAddon).update( + currentAddon, + restart + ); + } + + if (isActive != wasActive) { + let change = isActive + ? AddonManager.STARTUP_CHANGE_ENABLED + : AddonManager.STARTUP_CHANGE_DISABLED; + AddonManagerPrivate.addStartupChange(change, id); + } + } else if (xpiState && xpiState.wasRestored) { + isActive = xpiState.enabled; + + if (currentAddon.isWebExtension && currentAddon.type == "theme") { + currentAddon.userDisabled = !isActive; + } + + // If the add-on wasn't active and it isn't already disabled in some way + // then it was probably either softDisabled or userDisabled + if (!isActive && !currentAddon.disabled) { + // If the add-on is softblocked then assume it is softDisabled + if ( + currentAddon.blocklistState == Services.blocklist.STATE_SOFTBLOCKED + ) { + currentAddon.softDisabled = true; + } else { + currentAddon.userDisabled = true; + } + } + } else { + AddonManagerPrivate.addStartupChange( + AddonManager.STARTUP_CHANGE_INSTALLED, + id + ); + let scope = XPIInternal.BootstrapScope.get(currentAddon); + scope.install(); + } + + XPIDatabase.makeAddonVisible(currentAddon); + currentAddon.active = isActive; + return promise; + }, +}; |