/* 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/. */ /** * 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"}}] */ import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; import { XPIExports } from "resource://gre/modules/addons/XPIExports.sys.mjs"; const lazy = {}; XPCOMUtils.defineLazyServiceGetters(lazy, { ThirdPartyUtil: ["@mozilla.org/thirdpartyutil;1", "mozIThirdPartyUtil"], }); ChromeUtils.defineESModuleGetters(lazy, { AddonManager: "resource://gre/modules/AddonManager.sys.mjs", AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", Blocklist: "resource://gre/modules/Blocklist.sys.mjs", DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", ExtensionData: "resource://gre/modules/Extension.sys.mjs", ExtensionUtils: "resource://gre/modules/ExtensionUtils.sys.mjs", ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs", PermissionsUtils: "resource://gre/modules/PermissionsUtils.sys.mjs", QuarantinedDomains: "resource://gre/modules/ExtensionPermissions.sys.mjs", }); // WARNING: BuiltInThemes.sys.mjs may be provided by the host application (e.g. // Firefox), or it might not exist at all. Use with caution, as we don't // want things to completely fail if that module can't be loaded. ChromeUtils.defineLazyGetter(lazy, "BuiltInThemes", () => { try { let { BuiltInThemes } = ChromeUtils.importESModule( "resource:///modules/BuiltInThemes.sys.mjs" ); return BuiltInThemes; } catch (e) { Cu.reportError(`Unable to load BuiltInThemes.sys.mjs: ${e}`); } return undefined; }); // A set of helpers to account from a single place that in some builds // (e.g. GeckoView and Thunderbird) the BuiltInThemes module may either // not be bundled at all or not be exposing the same methods provided // by the module as defined in Firefox Desktop. export const BuiltInThemesHelpers = { getLocalizedColorwayGroupName(addonId) { return lazy.BuiltInThemes?.getLocalizedColorwayGroupName?.(addonId); }, getLocalizedColorwayDescription(addonId) { return lazy.BuiltInThemes?.getLocalizedColorwayGroupDescription?.(addonId); }, isActiveTheme(addonId) { return lazy.BuiltInThemes?.isActiveTheme?.(addonId); }, isRetainedExpiredTheme(addonId) { return lazy.BuiltInThemes?.isRetainedExpiredTheme?.(addonId); }, themeIsExpired(addonId) { return lazy.BuiltInThemes?.themeIsExpired?.(addonId); }, // Helper function called form XPInstall.sys.mjs to remove from the retained // themes list the built-in colorways theme that have been migrated to a non // built-in. unretainMigratedColorwayTheme(addonId) { lazy.BuiltInThemes?.unretainMigratedColorwayTheme?.(addonId); }, }; XPCOMUtils.defineLazyPreferenceGetter( BuiltInThemesHelpers, "isColorwayMigrationEnabled", "browser.theme.colorway-migration", false ); // A temporary hidden pref just meant to be used as a last resort, in case // we need to force-disable the "per-addon quarantined domains user controls" // feature during the beta cycle, e.g. if unexpected issues are caught late and // it shouldn't ride the train. // // TODO(Bug 1839616): remove this pref after the user controls features have been // released. XPCOMUtils.defineLazyPreferenceGetter( lazy, "isQuarantineUIDisabled", "extensions.quarantinedDomains.uiDisabled", false ); const { nsIBlocklistService } = Ci; import { Log } from "resource://gre/modules/Log.sys.mjs"; 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.sys.mjs) var logger = Log.repository.getLogger(LOGGER_ID); 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", "sourceURI", "releaseNotesURI", "installDate", "updateDate", "applyBackgroundUpdates", "installTelemetryInfo", ]; // Properties to save in JSON file const PROP_JSON_FIELDS = [ "id", "syncGUID", "version", "type", "loader", "updateURL", "installOrigins", "manifestVersion", "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", "signedTypes", "signedDate", "seen", "dependencies", "incognito", "userPermissions", "optionalPermissions", "sitePermissions", "siteOrigin", "icons", "iconURL", "blocklistState", "blocklistURL", "startupData", "previewImage", "hidden", "installTelemetryInfo", "recommendationState", "rootURI", ]; const SIGNED_TYPES = new Set([ "extension", "locale", "theme", // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. "sitepermission-deprecated", ]); // Time to wait before async save of XPI JSON database, in milliseconds const ASYNC_SAVE_DELAY_MS = 20; const l10n = new Localization(["browser/appExtensionFields.ftl"], true); /** * 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} */ 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 lazy.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. */ export 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} 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 = XPIExports.XPIInternal.getURIForResourceInFile( file, "" ).spec; } } get wrapper() { if (!this._wrapper) { this._wrapper = new AddonWrapper(this); } return this._wrapper; } get resolvedRootURI() { return XPIExports.XPIInternal.maybeResolveURI( Services.io.newURI(this.rootURI) ); } get isBuiltinColorwayTheme() { return ( this.type === "theme" && this.location.isBuiltin && this.id.endsWith("-colorway@mozilla.org") ); } /** * Validate a list of origins are contained in the installOrigins array (defined in manifest.json). * * SitePermission addons are a special case, where the triggering install site may be a subdomain * of a valid xpi origin. * * @param {Object} origins Object containing URIs related to install. * @params {nsIURI} origins.installFrom The nsIURI of the website that has triggered the install flow. * @params {nsIURI} origins.source The nsIURI where the xpi is hosted. * @returns {boolean} */ validInstallOrigins({ installFrom, source }) { if ( !Services.prefs.getBoolPref("extensions.install_origins.enabled", true) ) { return true; } let { installOrigins, manifestVersion } = this; if (!installOrigins) { // Install origins are mandatory in MV3 and optional // in MV2. Old addons need to keep installing per the // old install flow. return manifestVersion < 3; } // An empty install_origins prevents any install from 3rd party websites. if (!installOrigins.length) { return false; } // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. if (this.type == "sitepermission-deprecated") { // NOTE: This may move into a check for all addons later. for (let origin of installOrigins) { let host = new URL(origin).host; // install_origin cannot be on a known etld (e.g. github.io). if (Services.eTLD.getKnownPublicSuffixFromHost(host) == host) { logger.warn( `Addon ${this.id} Installation not allowed from the install_origin ${host} that is an eTLD` ); return false; } } if (!installOrigins.includes(new URL(source.spec).origin)) { logger.warn( `Addon ${this.id} Installation not allowed, "${source.spec}" is not included in the Addon install_origins` ); return false; } if (lazy.ThirdPartyUtil.isThirdPartyURI(source, installFrom)) { logger.warn( `Addon ${this.id} Installation not allowed, installFrom "${installFrom.spec}" is third party to the Addon install_origins` ); return false; } return true; } for (const [name, uri] of Object.entries({ installFrom, source })) { if (!installOrigins.includes(new URL(uri.spec).origin)) { logger.warn( `Addon ${this.id} Installation not allowed, ${name} "${uri.spec}" is not included in the Addon install_origins` ); return false; } } return true; } 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 [ lazy.AddonManager.SIGNEDSTATE_SYSTEM, lazy.AddonManager.SIGNEDSTATE_PRIVILEGED, ].includes(this.signedState); case KEY_APP_SYSTEM_ADDONS: // System add-ons must be signed by the system key. return this.signedState == lazy.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 === lazy.AddonManager.SIGNEDSTATE_NOT_REQUIRED) { return true; } return this.signedState > lazy.AddonManager.SIGNEDSTATE_MISSING; } get isCompatible() { return this.isCompatibleWith(); } get isPrivileged() { return lazy.ExtensionData.getIsPrivileged({ signedState: this.signedState, builtIn: this.location.isBuiltin, temporarilyInstalled: this.location.isTemporary, }); } get hidden() { return ( this.location.hidden || // The hidden flag is intended to only be used for features that are part // of the application. Temporary add-ons should not be hidden. (this._hidden && this.isPrivileged && !this.location.isTemporary) || 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); lazy.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 && (!lazy.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 lazy.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; } } } recordAddonBlockChangeTelemetry(reason) { lazy.Blocklist.recordAddonBlockChangeTelemetry(this.wrapper, reason); } 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 |= lazy.AddonManager.PERM_CAN_ENABLE; } else if (this.type != "theme" || this.id != DEFAULT_THEME_ID) { // We do not expose disabling the default theme. permissions |= lazy.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 |= lazy.AddonManager.PERM_CAN_UPGRADE; } // Allow active and retained colorways builtin themes to be updated to // the same theme hosted on AMO (the PERM_CAN_UPGRADE permission will // ensure we will be asking AMO for an update, then the AMO addon xpi // will be installed in the profile location, overridden in the // `createUpdate` defined in `XPIInstall.sys.mjs` and called from // `UpdateChecker` `onUpdateCheckComplete` method). if ( this.isBuiltinColorwayTheme && BuiltInThemesHelpers.isColorwayMigrationEnabled && BuiltInThemesHelpers.themeIsExpired(this.id) && (BuiltInThemesHelpers.isActiveTheme(this.id) || BuiltInThemesHelpers.isRetainedExpiredTheme(this.id)) ) { permissions |= lazy.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 & lazy.AddonSettings.SCOPES_SIDELOAD); if (changesAllowed || isLegacySideload) { permissions |= lazy.AddonManager.PERM_API_CAN_UNINSTALL; if (!this.location.isBuiltin) { permissions |= lazy.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 ( (this.type === "extension" || // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. this.type == "sitepermission-deprecated") && this.incognito !== "not_allowed" && this.signedState !== lazy.AddonManager.SIGNEDSTATE_PRIVILEGED && this.signedState !== lazy.AddonManager.SIGNEDSTATE_SYSTEM && !this.location.isBuiltin ) { permissions |= lazy.AddonManager.PERM_CAN_CHANGE_PRIVATEBROWSING_ACCESS; } if (Services.policies) { if (!Services.policies.isAllowed(`uninstall-extension:${this.id}`)) { permissions &= ~lazy.AddonManager.PERM_CAN_UNINSTALL; } if (!Services.policies.isAllowed(`disable-extension:${this.id}`)) { permissions &= ~lazy.AddonManager.PERM_CAN_DISABLE; } if (Services.policies.getExtensionSettings(this.id)?.updates_disabled) { permissions &= ~lazy.AddonManager.PERM_CAN_UPGRADE; } } 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. * * NOTE: Do not add any new logic here. Add it to AddonInternal and expose * through defineAddonWrapperProperty after this class definition. * * @param {AddonInternal} aAddon * The add-on object to wrap. */ AddonWrapper = class { constructor(aAddon) { wrapperMap.set(this, aAddon); } get __AddonInternal__() { return addonFor(this); } get quarantineIgnoredByApp() { return this.isPrivileged || !!this.recommendationStates?.length; } get quarantineIgnoredByUser() { // NOTE: confirm if this getter could be replaced by a // lazy preference getter and the addon wrapper to not be // kept around longer by the pref observer registered // internally by the lazy getter. return lazy.QuarantinedDomains.isUserAllowedAddonId(this.id); } set quarantineIgnoredByUser(val) { lazy.QuarantinedDomains.setUserAllowedAddonIdPref(this.id, !!val); } get canChangeQuarantineIgnored() { // Never show the quarantined domains user controls UI if the // quarantined domains feature is disabled. return ( WebExtensionPolicy.quarantinedDomainsEnabled && !lazy.isQuarantineUIDisabled && this.type === "extension" && !this.quarantineIgnoredByApp ); } 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 lazy.AddonManager.OPTIONS_TYPE_TAB: case lazy.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 lazy.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 lazy.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 []; } // NOTE: this boolean getter doesn't return true for all recommendation // states at the moment. For the states actually supported on the autograph // side see: // https://github.com/mozilla-services/autograph/blob/8a34847a/autograph.yaml#L1456-L1460 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 == lazy.AddonManager.SIGNEDSTATE_PRIVILEGED || (this.signedState >= lazy.AddonManager.SIGNEDSTATE_SIGNED && this.recommendationStates.length) ); } get applyBackgroundUpdates() { return addonFor(this).applyBackgroundUpdates; } set applyBackgroundUpdates(val) { let addon = addonFor(this); if ( val != lazy.AddonManager.AUTOUPDATE_DEFAULT && val != lazy.AddonManager.AUTOUPDATE_DISABLE && val != lazy.AddonManager.AUTOUPDATE_ENABLE ) { val = val ? lazy.AddonManager.AUTOUPDATE_DEFAULT : lazy.AddonManager.AUTOUPDATE_DISABLE; } if (val == addon.applyBackgroundUpdates) { return; } XPIDatabase.setAddonProperties(addon, { applyBackgroundUpdates: val, }); lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [ "applyBackgroundUpdates", ]); } set syncGUID(val) { let addon = addonFor(this); if (addon.syncGUID == val) { return; } if (addon.inDatabase) { XPIDatabase.setAddonSyncGUID(addon, val); } addon.syncGUID = 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 lazy.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 == lazy.AddonManager.STATE_INSTALLING || addon._install.state == lazy.AddonManager.STATE_INSTALLED ) { return lazy.AddonManager.PENDING_INSTALL; } } else if (addon.pendingUninstall) { // If an add-on is pending uninstall then we ignore any other pending // operations return lazy.AddonManager.PENDING_UNINSTALL; } if (addon.active && addon.disabled) { pending |= lazy.AddonManager.PENDING_DISABLE; } else if (!addon.active && !addon.disabled) { pending |= lazy.AddonManager.PENDING_ENABLE; } if (addon.pendingUpgrade) { pending |= lazy.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 XPIExports.XPIInternal.canRunInSafeMode(addon); } get startupPromise() { let addon = addonFor(this); if (!this.isActive) { return null; } let activeAddon = XPIExports.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 (!lazy.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 (!lazy.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 XPIExports.XPIInstall.uninstallAddon(addon, alwaysAllowUndo); } cancelUninstall() { let addon = addonFor(this); XPIExports.XPIInstall.cancelUninstallAddon(addon); } findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) { new XPIExports.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 lazy.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]; } 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", "installOrigins", "manifestVersion", "validInstallOrigins", "dependencies", "signedState", "signedTypes", "sitePermissions", "siteOrigin", "isCorrectlySigned", "isBuiltinColorwayTheme", ].forEach(function (aProp) { defineAddonWrapperProperty(aProp, function () { let addon = addonFor(this); return aProp in addon ? addon[aProp] : undefined; }); }); [ "fullDescription", "supportURL", "contributionURL", "averageRating", "reviewCount", "reviewURL", "weeklyDownloads", "amoListingURL", ].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); }); }); // Add to this Map if you need to change an addon's Fluent ID. Keep it in sync // with the list in browser_verify_l10n_strings.js const updatedAddonFluentIds = new Map([ ["extension-default-theme-name", "extension-default-theme-name-auto"], ]); ["name", "description", "creator", "homepageURL"].forEach(function (aProp) { defineAddonWrapperProperty(aProp, function () { let addon = addonFor(this); let formattedMessage; // We want to make sure that all built-in themes that are localizable can // actually localized, particularly those for thunderbird and desktop. if ( (aProp === "name" || aProp === "description") && addon.location.name === KEY_APP_BUILTINS && addon.type === "theme" ) { // Built-in themes are localized with Fluent instead of the WebExtension API. let addonIdPrefix = addon.id.replace("@mozilla.org", ""); const colorwaySuffix = "colorway"; if (addonIdPrefix.endsWith(colorwaySuffix)) { // FIXME: Depending on BuiltInThemes here is sort of a hack. Bug 1733466 // would provide a more generalized way of doing this. if (aProp == "description") { return BuiltInThemesHelpers.getLocalizedColorwayDescription(addon.id); } // Colorway collections are usually divided into and presented as // "groups". A group either contains closely related colorways, e.g. // stemming from the same base color but with different intensities, or // if the current collection doesn't have intensities, each colorway is // their own group. Colorway names combine the group name with an // intensity. Their ids have the format // {colorwayGroup}-{intensity}-colorway@mozilla.org or // {colorwayGroupName}-colorway@mozilla.org). L10n for colorway group // names is optional and falls back on the unlocalized name from the // theme's manifest. The intensity part, if present, must be localized. let localizedColorwayGroupName = BuiltInThemesHelpers.getLocalizedColorwayGroupName(addon.id); let [colorwayGroupName, intensity] = addonIdPrefix.split("-", 2); if (intensity == colorwaySuffix) { // This theme doesn't have an intensity. return localizedColorwayGroupName || addon.defaultLocale.name; } // We're not using toLocaleUpperCase because these color names are // always in English. colorwayGroupName = localizedColorwayGroupName || colorwayGroupName[0].toUpperCase() + colorwayGroupName.slice(1); let defaultFluentId = `extension-colorways-${intensity}-name`; let fluentId = updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId; [formattedMessage] = l10n.formatMessagesSync([ { id: fluentId, args: { "colorway-name": colorwayGroupName, }, }, ]); } else { let defaultFluentId = `extension-${addonIdPrefix}-${aProp}`; let fluentId = updatedAddonFluentIds.get(defaultFluentId) || defaultFluentId; [formattedMessage] = l10n.formatMessagesSync([{ id: fluentId }]); } return formattedMessage.value; } 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 lazy.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 lazy.AddonManagerPrivate.AddonAuthor(aResult); }); } return results; }); }); /** * @typedef {Map} 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} * The list of matching add-ons. */ function _filterDB(addonDB, aFilter) { return Array.from(addonDB.values()).filter(aFilter); } export const XPIDatabase = { // true if the database connection has been opened initialized: false, // The database file jsonFilePath: PathUtils.join(PathUtils.profileDir, FILE_JSON_DB), 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 { await IOUtils.writeJSON(this.jsonFilePath, this, { tmpPath: `${this.jsonFilePath}.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 " + XPIExports.XPIInternal.DB_SCHEMA ); Services.prefs.setIntPref( PREF_DB_SCHEMA, XPIExports.XPIInternal.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; if (!DOMException.isInstance(error) || error.name !== "AbortError") { 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 (XPIExports.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); lazy.AddonManagerPrivate.recordSimpleMeasure( "XPIDB_late_stack", Log.stackTrace(err) ); } if (!this._saveTask) { this._saveTask = new lazy.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: XPIExports.XPIInternal.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); lazy.AddonManagerPrivate.recordSimpleMeasure( "XPIDB_sync_stack", Log.stackTrace(err) ); try { this.syncLoadingDB = true; XPIExports.XPIInternal.awaitPromise(this.asyncLoadDB(aRebuildOnError)); } finally { this.syncLoadingDB = false; } }, _recordStartupError(reason) { lazy.AddonManagerPrivate.recordSimpleMeasure("XPIDB_startupError", reason); }, /** * Parse loaded data, reconstructing the database if the loaded data is not valid * * @param {object} aInputAddons * The add-on JSON to parse. * @param {boolean} aRebuildOnError * If true, synchronously reconstruct the database from installed add-ons */ async parseDB(aInputAddons, aRebuildOnError) { try { let parseTimer = lazy.AddonManagerPrivate.simpleTimer("XPIDB_parseDB_MS"); if (!("schemaVersion" in aInputAddons) || !("addons" in aInputAddons)) { let error = new Error("Bad JSON file contents"); error.rebuildReason = "XPIDB_rebuildBadJSON_MS"; throw error; } if (aInputAddons.schemaVersion <= 27) { // Types were translated in bug 857456. for (let addon of aInputAddons.addons) { XPIExports.XPIInternal.migrateAddonLoader(addon); } } else if ( aInputAddons.schemaVersion != XPIExports.XPIInternal.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-${aInputAddons.schemaVersion}` ); logger.debug( `JSON schema mismatch: expected ${XPIExports.XPIInternal.DB_SCHEMA}, actual ${aInputAddons.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(aInputAddons.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 = XPIExports.XPIInternal.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} * Resolves to the Map of loaded JSON data stored in * this.addonDB; rejects in case of shutdown. */ asyncLoadDB(aRebuildOnError = true) { // Already started (and possibly finished) loading if (this._dbPromise) { return this._dbPromise; } if (XPIExports.XPIProvider._closing) { // use an Error here so we get a stack trace. let err = new Error( "XPIDatabase.asyncLoadDB attempt after XPIProvider shutdown." ); logger.warn("Fail to load AddonDB: ${error}", { error: err }); lazy.AddonManagerPrivate.recordSimpleMeasure( "XPIDB_late_load", Log.stackTrace(err) ); this._dbPromise = Promise.reject(err); XPIExports.XPIInternal.resolveDBReady(this._dbPromise); return this._dbPromise; } logger.debug(`Starting async load of XPI database ${this.jsonFilePath}`); this._dbPromise = (async () => { try { let json = await IOUtils.readJSON(this.jsonFilePath); logger.debug("Finished async read of XPI database, parsing..."); await this.maybeIdleDispatch(); await this.parseDB(json, true); } catch (error) { if (DOMException.isInstance(error) && error.name === "NotFoundError") { if (Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) { this._recordStartupError("dbMissing"); } } else { logger.warn( `Extensions database ${this.jsonFilePath} exists but is not readable; rebuilding`, error ); this._loadError = error; } this.timeRebuildDatabase( "XPIDB_rebuildUnreadableDB_MS", aRebuildOnError ); } return this.addonDB; })(); XPIExports.XPIInternal.resolveDBReady(this._dbPromise); return this._dbPromise; }, timeRebuildDatabase(timerName, rebuildOnError) { lazy.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 (XPIExports.XPIInternal.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(() => 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, signedTypes } = await XPIExports.verifyBundleSignedState(addon._sourceBundle, addon); const changedProperties = []; if (signedState != addon.signedState) { addon.signedState = signedState; changedProperties.push("signedState"); } if ( !lazy.ObjectUtils.deepEqual( signedTypes?.toSorted(), addon.signedTypes?.toSorted() ) ) { addon.signedTypes = signedTypes; changedProperties.push("signedTypes"); } if (changedProperties.length) { lazy.AddonManagerPrivate.callAddonListeners( "onPropertyChanged", addon.wrapper, changedProperties ); } 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() { lazy.PermissionsUtils.importFromPrefs( PREF_XPI_PERMISSIONS_BRANCH, XPIExports.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"); let updateDisabledStatePromises = []; for (let theme of addons) { if (theme.visible) { if (!aId && theme.id == DEFAULT_THEME_ID) { enableTheme = theme; } else if (theme.id != aId && !theme.pendingUninstall) { updateDisabledStatePromises.push( this.updateAddonDisabledState(theme, { userDisabled: true, becauseSelecting: true, }) ); } } } await Promise.all(updateDisabledStatePromises); 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} * 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} */ 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} */ 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>} */ 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} */ getVisibleAddonForID(aId) { return this.getAddon(aAddon => aAddon.id == aId && aAddon.visible); }, /** * Asynchronously gets the visible add-ons, optionally restricting by type. * * @param {Set?} aTypes * An array of types to include or null to include all types * @returns {Promise>} */ 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} aTypes * The type(s) of add-on to retrieve * @returns {Array} */ 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?} aTypes * The types of add-ons to retrieve or null to get all types * @returns {Promise>} */ 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} */ getAddons() { if (!this.addonDB) { return []; } return _filterDB(this.addonDB, () => 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?} 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 lazy.AddonSettings.LANGPACKS_REQUIRE_SIGNING; } return lazy.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 !== lazy.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 ( lazy.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 = XPIExports.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 (lazy.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); XPIExports.XPIInternal.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} * 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 (!lazy.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) { lazy.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) { lazy.AddonManagerPrivate.callAddonListeners( "onOperationCancelled", wrapper ); } else { if (isDisabled) { lazy.AddonManagerPrivate.callAddonListeners( "onDisabling", wrapper, false ); } else { lazy.AddonManagerPrivate.callAddonListeners( "onEnabling", wrapper, false ); } this.updateAddonActive(aAddon, !isDisabled); let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon); if (isDisabled) { await bootstrap.disable(); lazy.AddonManagerPrivate.callAddonListeners("onDisabled", wrapper); } else { await bootstrap.startup( XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_ENABLE ); lazy.AddonManagerPrivate.callAddonListeners("onEnabled", wrapper); } } // Notify any other providers that a new theme has been enabled if (aAddon.type === "theme") { if (!isDisabled) { await lazy.AddonManagerPrivate.notifyAddonChanged( aAddon.id, aAddon.type ); } else if (isDisabled && !becauseSelecting) { await lazy.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 => lazy.AddonRepository.getCachedAddonByID(addon.id).then(aRepoAddon => { if (aRepoAddon) { logger.debug("updateAddonRepositoryData got info for " + addon.id); addon._repositoryAddon = aRepoAddon; return this.updateAddonDisabledState(addon); } return undefined; }) ) ); }, /** * 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; XPIExports.XPIProvider.addTelemetry(aAddon.id, { name: locale.name, creator: locale.creator, }); }, }; export const 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} addonMap * The add-on map to flatten. * @param {string?} [hideLocation] * An optional location from which to hide any add-ons. * @returns {Map} */ flattenByID(addonMap, hideLocation) { let map = new Map(); for (let loc of XPIExports.XPIInternal.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} addonMap * The add-on map to filter. * @returns {Map} */ 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 * @returns {boolean} * A boolean indicating if flushing caches is required to complete * changing this add-on */ addMetadata(aLocation, aId, aAddonState, aNewAddon) { 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 = XPIExports.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 = XPIExports.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; XPIExports.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 = XPIExports.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); // signedTypes must be set if signedState is set. let signedTypesMissing = aOldAddon.signedTypes === 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 || signedTypesMissing ) { try { manifest = XPIExports.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; } if (signedTypesMissing) { aOldAddon.signedTypes = manifest.signedTypes; } // 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.sys.mjs 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 lazy.ExtensionUtils.DefaultMap(() => new Map()); let currentAddons = new lazy.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 XPIExports.XPIInternal.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 = XPIExports.XPIInternal.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)) { XPIExports.XPIInternal.awaitPromise(Promise.all(promises)); } for (let [id, addon] of previousVisible) { if (addon.location) { if (addon.location.name == KEY_APP_BUILTINS) { continue; } XPIExports.XPIInternal.BootstrapScope.get(addon).uninstall(); addon.location.removeAddon(id); addon.visible = false; addon.active = false; } lazy.AddonManagerPrivate.addStartupChange( lazy.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 = XPIExports.XPIInternal.XPIStates.getAddon( locationName, id ); xpiState.syncWithDB(addon); } } XPIExports.XPIInternal.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. lazy.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 lazy.AddonManager.getAddonsByIDs( addonsToCheckAgainstBlocklist ); await Promise.all( addons.map(async addon => { if (!addon) { return; } let oldState = addon.blocklistState; // TODO 1712316: updateBlocklistState with object parameter only // works if addon is an AddonInternal instance. But addon is an // AddonWrapper instead. Consequently updateDate:false is ignored. await addon.updateBlocklistState({ updateDatabase: false }); if (oldState !== addon.blocklistState) { lazy.Blocklist.recordAddonBlockChangeTelemetry( addon, "addon_db_modified" ); } }) ); 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) { lazy.AddonManagerPrivate.addStartupChange( lazy.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 && XPIExports.XPIInternal.BootstrapScope.get(currentAddon).started; if (restart) { logger.warn( `Updating and restart addon ${previousAddon.id} that changed on disk after being already started.` ); } promise = XPIExports.XPIInternal.BootstrapScope.get( previousAddon ).update(currentAddon, restart); } if (isActive != wasActive) { let change = isActive ? lazy.AddonManager.STARTUP_CHANGE_ENABLED : lazy.AddonManager.STARTUP_CHANGE_DISABLED; lazy.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 { lazy.AddonManagerPrivate.addStartupChange( lazy.AddonManager.STARTUP_CHANGE_INSTALLED, id ); let scope = XPIExports.XPIInternal.BootstrapScope.get(currentAddon); scope.install(); } XPIDatabase.makeAddonVisible(currentAddon); currentAddon.active = isActive; return promise; }, };