From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../extensions/internal/AddonRepository.sys.mjs | 1257 +++++ .../extensions/internal/AddonSettings.sys.mjs | 138 + .../extensions/internal/AddonTestUtils.sys.mjs | 1876 ++++++++ .../extensions/internal/AddonUpdateChecker.sys.mjs | 643 +++ .../extensions/internal/GMPProvider.sys.mjs | 934 ++++ .../internal/ProductAddonChecker.sys.mjs | 601 +++ .../internal/SitePermsAddonProvider.sys.mjs | 661 +++ .../extensions/internal/XPIDatabase.sys.mjs | 3832 +++++++++++++++ .../mozapps/extensions/internal/XPIExports.sys.mjs | 36 + .../mozapps/extensions/internal/XPIInstall.sys.mjs | 4897 ++++++++++++++++++++ .../extensions/internal/XPIProvider.sys.mjs | 3377 ++++++++++++++ .../extensions/internal/crypto-utils.sys.mjs | 57 + toolkit/mozapps/extensions/internal/moz.build | 29 + .../internal/siteperms-addon-utils.sys.mjs | 72 + 14 files changed, 18410 insertions(+) create mode 100644 toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/XPIExports.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs create mode 100644 toolkit/mozapps/extensions/internal/moz.build create mode 100644 toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs (limited to 'toolkit/mozapps/extensions/internal') diff --git a/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs new file mode 100644 index 0000000000..e854e04b3c --- /dev/null +++ b/toolkit/mozapps/extensions/internal/AddonRepository.sys.mjs @@ -0,0 +1,1257 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs", +}); + +// The current platform as specified in the AMO API: +// http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#addon-detail-platform +ChromeUtils.defineLazyGetter(lazy, "PLATFORM", () => { + let platform = Services.appinfo.OS; + switch (platform) { + case "Darwin": + return "mac"; + + case "Linux": + return "linux"; + + case "Android": + return "android"; + + case "WINNT": + return "windows"; + } + return platform; +}); + +const PREF_GETADDONS_CACHE_ENABLED = "extensions.getAddons.cache.enabled"; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "getAddonsCacheEnabled", + PREF_GETADDONS_CACHE_ENABLED +); + +const PREF_GETADDONS_CACHE_TYPES = "extensions.getAddons.cache.types"; +const PREF_GETADDONS_CACHE_ID_ENABLED = + "extensions.%ID%.getAddons.cache.enabled"; +const PREF_GETADDONS_BROWSEADDONS = "extensions.getAddons.browseAddons"; +const PREF_GETADDONS_BYIDS = "extensions.getAddons.get.url"; +const PREF_GETADDONS_BROWSESEARCHRESULTS = + "extensions.getAddons.search.browseURL"; +const PREF_GETADDONS_DB_SCHEMA = "extensions.getAddons.databaseSchema"; +const PREF_GET_LANGPACKS = "extensions.getAddons.langpacks.url"; +const PREF_GET_BROWSER_MAPPINGS = "extensions.getAddons.browserMappings.url"; + +const PREF_METADATA_LASTUPDATE = "extensions.getAddons.cache.lastUpdate"; +const PREF_METADATA_UPDATETHRESHOLD_SEC = + "extensions.getAddons.cache.updateThreshold"; +const DEFAULT_METADATA_UPDATETHRESHOLD_SEC = 172800; // two days + +const DEFAULT_CACHE_TYPES = "extension,theme,locale,dictionary"; + +const FILE_DATABASE = "addons.json"; +const DB_SCHEMA = 6; +const DB_MIN_JSON_SCHEMA = 5; +const DB_BATCH_TIMEOUT_MS = 50; + +const BLANK_DB = function () { + return { + addons: new Map(), + schema: DB_SCHEMA, + }; +}; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const LOGGER_ID = "addons.repository"; + +// Create a new logger for use by the Addons Repository +// (Requires AddonManager.jsm) +var logger = Log.repository.getLogger(LOGGER_ID); + +function convertHTMLToPlainText(html) { + if (!html) { + return html; + } + var converter = Cc[ + "@mozilla.org/widget/htmlformatconverter;1" + ].createInstance(Ci.nsIFormatConverter); + + var input = Cc["@mozilla.org/supports-string;1"].createInstance( + Ci.nsISupportsString + ); + input.data = html.replace(/\n/g, "
"); + + var output = {}; + converter.convert("text/html", input, "text/plain", output); + + if (output.value instanceof Ci.nsISupportsString) { + return output.value.data.replace(/\r\n/g, "\n"); + } + return html; +} + +async function getAddonsToCache(aIds) { + let types = Services.prefs.getStringPref( + PREF_GETADDONS_CACHE_TYPES, + DEFAULT_CACHE_TYPES + ); + + types = types.split(","); + + let addons = await lazy.AddonManager.getAddonsByIDs(aIds); + let enabledIds = []; + + for (let [i, addon] of addons.entries()) { + var preference = PREF_GETADDONS_CACHE_ID_ENABLED.replace("%ID%", aIds[i]); + // If the preference doesn't exist caching is enabled by default + if (!Services.prefs.getBoolPref(preference, true)) { + continue; + } + + // The add-ons manager may not know about this ID yet if it is a pending + // install. In that case we'll just cache it regardless + + // Don't cache add-ons of the wrong types + if (addon && !types.includes(addon.type)) { + continue; + } + + // Don't cache system add-ons + if (addon && addon.isSystem) { + continue; + } + + enabledIds.push(aIds[i]); + } + + return enabledIds; +} + +function AddonSearchResult(aId) { + this.id = aId; + this.icons = {}; + this._unsupportedProperties = {}; +} + +AddonSearchResult.prototype = { + /** + * The ID of the add-on + */ + id: null, + + /** + * The add-on type (e.g. "extension" or "theme") + */ + type: null, + + /** + * The name of the add-on + */ + name: null, + + /** + * The version of the add-on + */ + version: null, + + /** + * The creator of the add-on + */ + creator: null, + + /** + * The developers of the add-on + */ + developers: null, + + /** + * A short description of the add-on + */ + description: null, + + /** + * The full description of the add-on + */ + fullDescription: null, + + /** + * The end-user licensing agreement (EULA) of the add-on + */ + eula: null, + + /** + * The url of the add-on's icon + */ + get iconURL() { + return this.icons && this.icons[32]; + }, + + /** + * The URLs of the add-on's icons, as an object with icon size as key + */ + icons: null, + + /** + * An array of screenshot urls for the add-on + */ + screenshots: null, + + /** + * The homepage for the add-on + */ + homepageURL: null, + + /** + * The support URL for the add-on + */ + supportURL: null, + + /** + * The contribution url of the add-on + */ + contributionURL: null, + + /** + * The rating of the add-on, 0-5 + */ + averageRating: null, + + /** + * The number of reviews for this add-on + */ + reviewCount: null, + + /** + * The URL to the list of reviews for this add-on + */ + reviewURL: null, + + /** + * The number of times the add-on was downloaded the current week + */ + weeklyDownloads: null, + + /** + * The URL to the AMO detail page of this (listed) add-on + */ + amoListingURL: null, + + /** + * AddonInstall object generated from the add-on XPI url + */ + install: null, + + /** + * nsIURI storing where this add-on was installed from + */ + sourceURI: null, + + /** + * The Date that the add-on was most recently updated + */ + updateDate: null, + + toJSON() { + let json = {}; + + for (let property of Object.keys(this)) { + let value = this[property]; + if (property.startsWith("_") || typeof value === "function") { + continue; + } + + try { + switch (property) { + case "sourceURI": + json.sourceURI = value ? value.spec : ""; + break; + + case "updateDate": + json.updateDate = value ? value.getTime() : ""; + break; + + default: + json[property] = value; + } + } catch (ex) { + logger.warn("Error writing property value for " + property); + } + } + + for (let property of Object.keys(this._unsupportedProperties)) { + let value = this._unsupportedProperties[property]; + if (!property.startsWith("_")) { + json[property] = value; + } + } + + return json; + }, +}; + +/** + * The add-on repository is a source of add-ons that can be installed. It can + * be searched in three ways. The first takes a list of IDs and returns a + * list of the corresponding add-ons. The second returns a list of add-ons that + * come highly recommended. This list should change frequently. The third is to + * search for specific search terms entered by the user. Searches are + * asynchronous and results should be passed to the provided callback object + * when complete. The results passed to the callback should only include add-ons + * that are compatible with the current application and are not already + * installed. + */ +export var AddonRepository = { + /** + * The homepage for visiting this repository. If the corresponding preference + * is not defined, defaults to about:blank. + */ + get homepageURL() { + let url = this._formatURLPref(PREF_GETADDONS_BROWSEADDONS, {}); + return url != null ? url : "about:blank"; + }, + + get appIsShuttingDown() { + return Services.startup.shuttingDown; + }, + + /** + * Retrieves the url that can be visited to see search results for the given + * terms. If the corresponding preference is not defined, defaults to + * about:blank. + * + * @param aSearchTerms + * Search terms used to search the repository + */ + getSearchURL(aSearchTerms) { + let url = this._formatURLPref(PREF_GETADDONS_BROWSESEARCHRESULTS, { + TERMS: aSearchTerms, + }); + return url != null ? url : "about:blank"; + }, + + /** + * Whether caching is currently enabled + */ + get cacheEnabled() { + return lazy.getAddonsCacheEnabled; + }, + + /** + * Shut down AddonRepository + * return: promise{integer} resolves with the result of flushing + * the AddonRepository database + */ + shutdown() { + return AddonDatabase.shutdown(false); + }, + + metadataAge() { + let now = Math.round(Date.now() / 1000); + let lastUpdate = Services.prefs.getIntPref(PREF_METADATA_LASTUPDATE, 0); + return Math.max(0, now - lastUpdate); + }, + + isMetadataStale() { + let threshold = Services.prefs.getIntPref( + PREF_METADATA_UPDATETHRESHOLD_SEC, + DEFAULT_METADATA_UPDATETHRESHOLD_SEC + ); + return this.metadataAge() > threshold; + }, + + /** + * Asynchronously get a cached add-on by id. The add-on (or null if the + * add-on is not found) is passed to the specified callback. If caching is + * disabled, null is passed to the specified callback. + * + * The callback variant exists only for existing code in XPIProvider.sys.mjs + * and XPIDatabase.sys.mjs that requires a synchronous callback, yuck. + * + * @param aId + * The id of the add-on to get + */ + async getCachedAddonByID(aId, aCallback) { + if (!aId || !this.cacheEnabled) { + if (aCallback) { + aCallback(null); + } + return null; + } + + if (aCallback && AddonDatabase._loaded) { + let addon = AddonDatabase.getAddon(aId); + aCallback(addon); + return addon; + } + + await AddonDatabase.openConnection(); + + let addon = AddonDatabase.getAddon(aId); + if (aCallback) { + aCallback(addon); + } + return addon; + }, + + /* + * Clear and delete the AddonRepository database + * @return Promise{null} resolves when the database is deleted + */ + _clearCache() { + return AddonDatabase.delete().then(() => + lazy.AddonManagerPrivate.updateAddonRepositoryData() + ); + }, + + /* + * Create a ServiceRequest instance. + * @return ServiceRequest returns a ServiceRequest instance. + */ + _createServiceRequest() { + return new lazy.ServiceRequest({ mozAnon: true }); + }, + + /** + * Fetch data from an API where the results may span multiple "pages". + * This function will take care of issuing multiple requests until all + * the results have been fetched, and will coalesce them all into a + * single return value. The handling here is specific to the way AMO + * implements paging (ie a JSON result with a "next" property). + * + * @param {string} pref + * The pref name that contains the API URL to call. + * @param {object} params + * A key-value object that contains the parameters to replace + * in the API URL. + * @param {function} handler + * This function will be called once per page of results, + * it should return an array of objects (the type depends + * on the particular API being called of course). + * + * @returns Promise{array} An array of all the individual results from + * the API call(s). + */ + _fetchPaged(pref, params, handler) { + const startURL = this._formatURLPref(pref, params); + + let results = []; + const fetchNextPage = url => { + return new Promise((resolve, reject) => { + if (this.appIsShuttingDown) { + logger.debug( + "Rejecting AddonRepository._fetchPaged call, shutdown already in progress" + ); + reject( + new Error( + `Reject ServiceRequest for "${url}", shutdown already in progress` + ) + ); + return; + } + let request = this._createServiceRequest(); + request.mozBackgroundRequest = true; + request.open("GET", url, true); + request.responseType = "json"; + + request.addEventListener("error", aEvent => { + reject(new Error(`GET ${url} failed`)); + }); + request.addEventListener("timeout", aEvent => { + reject(new Error(`GET ${url} timed out`)); + }); + request.addEventListener("load", aEvent => { + let response = request.response; + if (!response || (request.status != 200 && request.status != 0)) { + reject(new Error(`GET ${url} failed (status ${request.status})`)); + return; + } + + try { + results.push(...handler(response.results)); + } catch (err) { + reject(err); + } + + if (response.next) { + resolve(fetchNextPage(response.next)); + } + + resolve(results); + }); + + request.send(null); + }); + }; + + return fetchNextPage(startURL); + }, + + /** + * Fetch metadata for a given set of addons from AMO. + * + * @param aIDs + * The array of ids to retrieve metadata for. + * @returns {array} + */ + async getAddonsByIDs(aIDs) { + const idCheck = aIDs.map(id => { + if (id.startsWith("rta:")) { + return atob(id.split(":")[1]); + } + return id; + }); + + const addons = await this._fetchPaged( + PREF_GETADDONS_BYIDS, + { IDS: aIDs.join(",") }, + results => + results + .map(entry => this._parseAddon(entry)) + // Only return the add-ons corresponding the IDs passed to this method. + .filter(addon => idCheck.includes(addon.id)) + ); + + return addons; + }, + + /** + * Fetch the Firefox add-ons mapped to the list of extension IDs for the + * browser ID passed to this method. + * + * See: https://addons-server.readthedocs.io/en/latest/topics/api/addons.html#browser-mappings + * + * @param browserID + * The browser ID used to retrieve the mapping of IDs. + * @param extensionIDs + * The array of browser (non-Firefox) extension IDs to retrieve + * metadata for. + * @returns {object} result + * The result of the mapping. + * @returns {array} result.addons + * The AddonSearchResults for the addons that were successfully mapped. + * @returns {array} result.matchedIDs + * The IDs of the extensions that were successfully matched to + * equivalents that can be installed in this browser. These are + * the IDs before matching to equivalents. + * @returns {array} result.unmatchedIDs + * The IDs of the extensions that were not matched to equivalents. + */ + async getMappedAddons(browserID, extensionIDs) { + let matchedExtensionIDs = new Set(); + let unmatchedExtensionIDs = new Set(extensionIDs); + + const addonIds = await this._fetchPaged( + PREF_GET_BROWSER_MAPPINGS, + { BROWSER: browserID }, + results => + results + // Filter out all the entries with an extension ID not in the list + // passed to the method. + .filter(entry => { + if (unmatchedExtensionIDs.has(entry.extension_id)) { + unmatchedExtensionIDs.delete(entry.extension_id); + matchedExtensionIDs.add(entry.extension_id); + return true; + } + return false; + }) + // Return the add-on ID (stored as `guid` on AMO). + .map(entry => entry.addon_guid) + ); + + if (!addonIds.length) { + return { + addons: [], + matchedIDs: [], + unmatchedIDs: [...unmatchedExtensionIDs], + }; + } + + return { + addons: await this.getAddonsByIDs(addonIds), + matchedIDs: [...matchedExtensionIDs], + unmatchedIDs: [...unmatchedExtensionIDs], + }; + }, + + /** + * Asynchronously add add-ons to the cache corresponding to the specified + * ids. If caching is disabled, the cache is unchanged. + * + * @param aIds + * The array of add-on ids to add to the cache + * @returns {array} Add-ons to add to the cache. + */ + async cacheAddons(aIds) { + logger.debug( + "cacheAddons: enabled " + this.cacheEnabled + " IDs " + aIds.toSource() + ); + if (!this.cacheEnabled) { + return []; + } + + let ids = await getAddonsToCache(aIds); + + // If there are no add-ons to cache, act as if caching is disabled + if (!ids.length) { + return []; + } + + let addons = []; + try { + addons = await this.getAddonsByIDs(ids); + } catch (err) { + logger.error(`Error in addon metadata check: ${err.message}`); + } + if (addons.length) { + await AddonDatabase.update(addons); + } + return addons; + }, + + /** + * Get all installed addons from the AddonManager singleton. + * + * @return Promise{array} Resolves to an array of AddonWrapper instances. + */ + _getAllInstalledAddons() { + return lazy.AddonManager.getAllAddons(); + }, + + /** + * Performs the periodic background update check. + * + * In Firefox Desktop builds, the background update check is triggered on a + * daily basis as part of the AOM background update check and registered + * from: `toolkit/mozapps/extensions/extensions.manifest` + * + * In GeckoView builds, add-ons are checked for updates individually. The + * `AddonRepository.backgroundUpdateCheck()` method is called by the + * `updateWebExtension()` method defined in `GeckoViewWebExtensions.sys.mjs` + * but only when `AddonRepository.isMetadataStale()` returns true. + * + * @return Promise{null} Resolves when the metadata update is complete. + */ + async backgroundUpdateCheck() { + let shutter = (async () => { + if (this.appIsShuttingDown) { + logger.debug( + "Returning earlier from backgroundUpdateCheck, shutdown already in progress" + ); + return; + } + + let allAddons = await this._getAllInstalledAddons(); + + // Completely remove cache if caching is not enabled + if (!this.cacheEnabled) { + logger.debug("Clearing cache because it is disabled"); + await this._clearCache(); + return; + } + + let ids = allAddons.map(a => a.id); + logger.debug("Repopulate add-on cache with " + ids.toSource()); + + let addonsToCache = await getAddonsToCache(ids); + + // Completely remove cache if there are no add-ons to cache + if (!addonsToCache.length) { + logger.debug("Clearing cache because 0 add-ons were requested"); + await this._clearCache(); + return; + } + + let addons; + try { + addons = await this.getAddonsByIDs(addonsToCache); + } catch (err) { + // This is likely to happen if the server is unreachable, e.g. when + // there is no network connectivity. + logger.error(`Error in addon metadata lookup: ${err.message}`); + // Return now to avoid calling repopulate with an empty array; + // doing so would clear the cache. + return; + } + + AddonDatabase.repopulate(addons); + + // Always call AddonManager updateAddonRepositoryData after we refill the cache + await lazy.AddonManagerPrivate.updateAddonRepositoryData(); + })(); + lazy.AddonManager.beforeShutdown.addBlocker( + "AddonRepository Background Updater", + shutter + ); + await shutter; + lazy.AddonManager.beforeShutdown.removeBlocker(shutter); + }, + + /* + * Creates an AddonSearchResult by parsing an entry from the AMO API. + * + * @param aEntry + * An entry from the AMO search API to parse. + * @return Result object containing the parsed AddonSearchResult + */ + _parseAddon(aEntry) { + let addon = new AddonSearchResult(aEntry.guid); + + addon.name = aEntry.name; + if (typeof aEntry.current_version == "object") { + addon.version = String(aEntry.current_version.version); + if (Array.isArray(aEntry.current_version.files)) { + for (let file of aEntry.current_version.files) { + if (file.platform == "all" || file.platform == lazy.PLATFORM) { + if (file.url) { + addon.sourceURI = lazy.NetUtil.newURI(file.url); + } + break; + } + } + } + } + addon.homepageURL = aEntry.homepage; + addon.supportURL = aEntry.support_url; + addon.amoListingURL = aEntry.url; + + addon.description = convertHTMLToPlainText(aEntry.summary); + addon.fullDescription = convertHTMLToPlainText(aEntry.description); + + addon.weeklyDownloads = aEntry.weekly_downloads; + + switch (aEntry.type) { + case "persona": + case "statictheme": + addon.type = "theme"; + break; + + case "language": + addon.type = "locale"; + break; + + default: + addon.type = aEntry.type; + break; + } + + if (Array.isArray(aEntry.authors)) { + let authors = aEntry.authors.map( + author => + new lazy.AddonManagerPrivate.AddonAuthor(author.name, author.url) + ); + if (authors.length) { + addon.creator = authors[0]; + addon.developers = authors.slice(1); + } + } + + if (typeof aEntry.previews == "object") { + addon.screenshots = aEntry.previews.map(shot => { + let safeSize = orig => + Array.isArray(orig) && orig.length >= 2 ? orig : [null, null]; + let imageSize = safeSize(shot.image_size); + let thumbSize = safeSize(shot.thumbnail_size); + return new lazy.AddonManagerPrivate.AddonScreenshot( + shot.image_url, + imageSize[0], + imageSize[1], + shot.thumbnail_url, + thumbSize[0], + thumbSize[1], + shot.caption + ); + }); + } + + addon.contributionURL = aEntry.contributions_url; + + if (typeof aEntry.ratings == "object") { + addon.averageRating = Math.min(5, aEntry.ratings.average); + addon.reviewCount = aEntry.ratings.text_count; + } + + addon.reviewURL = aEntry.ratings_url; + if (aEntry.last_updated) { + addon.updateDate = new Date(aEntry.last_updated); + } + + addon.icons = aEntry.icons || {}; + + return addon; + }, + + // Create url from preference, returning null if preference does not exist + _formatURLPref(aPreference, aSubstitutions = {}) { + let url = Services.prefs.getCharPref(aPreference, ""); + if (!url) { + logger.warn("_formatURLPref: Couldn't get pref: " + aPreference); + return null; + } + + url = url.replace(/%([A-Z_]+)%/g, function (aMatch, aKey) { + return aKey in aSubstitutions + ? encodeURIComponent(aSubstitutions[aKey]) + : aMatch; + }); + + return Services.urlFormatter.formatURL(url); + }, + + flush() { + return AddonDatabase.flush(); + }, + + async getAvailableLangpacks() { + // This should be the API endpoint documented at: + // http://addons-server.readthedocs.io/en/latest/topics/api/addons.html#language-tools + let url = this._formatURLPref(PREF_GET_LANGPACKS); + + let response = await fetch(url, { credentials: "omit" }); + if (!response.ok) { + throw new Error("fetching available language packs failed"); + } + + let data = await response.json(); + + let result = []; + for (let entry of data.results) { + if ( + !entry.current_compatible_version || + !entry.current_compatible_version.files + ) { + continue; + } + + for (let file of entry.current_compatible_version.files) { + if ( + file.platform == "all" || + file.platform == Services.appinfo.OS.toLowerCase() + ) { + result.push({ + target_locale: entry.target_locale, + url: file.url, + hash: file.hash, + }); + } + } + } + + return result; + }, +}; + +var AddonDatabase = { + connectionPromise: null, + _loaded: false, + _saveTask: null, + _blockerAdded: false, + + // the in-memory database + DB: BLANK_DB(), + + /** + * A getter to retrieve the path to the DB + */ + get jsonFile() { + return PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + FILE_DATABASE + ); + }, + + /** + * Asynchronously opens a new connection to the database file. + * + * @return {Promise} a promise that resolves to the database. + */ + openConnection() { + if (!this.connectionPromise) { + this.connectionPromise = (async () => { + let inputDB, schema; + + try { + let data = await IOUtils.readUTF8(this.jsonFile); + inputDB = JSON.parse(data); + + if ( + !inputDB.hasOwnProperty("addons") || + !Array.isArray(inputDB.addons) + ) { + throw new Error("No addons array."); + } + + if (!inputDB.hasOwnProperty("schema")) { + throw new Error("No schema specified."); + } + + schema = parseInt(inputDB.schema, 10); + + if (!Number.isInteger(schema) || schema < DB_MIN_JSON_SCHEMA) { + throw new Error("Invalid schema value."); + } + } catch (e) { + if (e.name == "NotFoundError") { + logger.debug("No " + FILE_DATABASE + " found."); + } else { + logger.error( + `Malformed ${FILE_DATABASE}: ${e} - resetting to empty` + ); + } + + // Create a blank addons.json file + this.save(); + + Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA); + this._loaded = true; + return this.DB; + } + + Services.prefs.setIntPref(PREF_GETADDONS_DB_SCHEMA, DB_SCHEMA); + + // Convert the addon objects as necessary + // and store them in our in-memory copy of the database. + for (let addon of inputDB.addons) { + let id = addon.id; + + let entry = this._parseAddon(addon); + this.DB.addons.set(id, entry); + } + + this._loaded = true; + return this.DB; + })(); + } + + return this.connectionPromise; + }, + + /** + * Asynchronously shuts down the database connection and releases all + * cached objects + * + * @param aCallback + * An optional callback to call once complete + * @param aSkipFlush + * An optional boolean to skip flushing data to disk. Useful + * when the database is going to be deleted afterwards. + */ + shutdown(aSkipFlush) { + if (!this.connectionPromise) { + return Promise.resolve(); + } + + this.connectionPromise = null; + this._loaded = false; + + if (aSkipFlush) { + return Promise.resolve(); + } + + return this.flush(); + }, + + /** + * Asynchronously deletes the database, shutting down the connection + * first if initialized + * + * @param aCallback + * An optional callback to call once complete + * @return Promise{null} resolves when the database has been deleted + */ + delete(aCallback) { + this.DB = BLANK_DB(); + + if (this._saveTask) { + this._saveTask.disarm(); + this._saveTask = null; + } + + // shutdown(true) never rejects + this._deleting = this.shutdown(true) + .then(() => IOUtils.remove(this.jsonFile)) + .catch(error => + logger.error( + "Unable to delete Addon Repository file " + this.jsonFile, + error + ) + ) + .then(() => (this._deleting = null)) + .then(aCallback); + + return this._deleting; + }, + + async _saveNow() { + let json = { + schema: this.DB.schema, + addons: Array.from(this.DB.addons.values()), + }; + + await IOUtils.writeUTF8(this.jsonFile, JSON.stringify(json), { + tmpPath: `${this.jsonFile}.tmp`, + }); + }, + + save() { + if (!this._saveTask) { + this._saveTask = new lazy.DeferredTask( + () => this._saveNow(), + DB_BATCH_TIMEOUT_MS + ); + + if (!this._blockerAdded) { + lazy.AsyncShutdown.profileBeforeChange.addBlocker( + "Flush AddonRepository", + () => this.flush() + ); + this._blockerAdded = true; + } + } + this._saveTask.arm(); + }, + + /** + * Flush any pending I/O on the addons.json file + * @return: Promise{null} + * Resolves when the pending I/O (writing out or deleting + * addons.json) completes + */ + flush() { + if (this._deleting) { + return this._deleting; + } + + if (this._saveTask) { + let promise = this._saveTask.finalize(); + this._saveTask = null; + return promise; + } + + return Promise.resolve(); + }, + + /** + * Get an individual addon entry from the in-memory cache. + * Note: calling this function before the database is read will + * return undefined. + * + * @param {string} aId The id of the addon to retrieve. + */ + getAddon(aId) { + return this.DB.addons.get(aId); + }, + + /** + * Asynchronously repopulates the database so it only contains the + * specified add-ons + * + * @param {array} aAddons + * Add-ons to repopulate the database with. + */ + repopulate(aAddons) { + this.DB = BLANK_DB(); + this._update(aAddons); + + let now = Math.round(Date.now() / 1000); + logger.debug( + "Cache repopulated, setting " + PREF_METADATA_LASTUPDATE + " to " + now + ); + Services.prefs.setIntPref(PREF_METADATA_LASTUPDATE, now); + }, + + /** + * Asynchronously insert new addons into the database. + * + * @param {array} aAddons + * Add-ons to insert/update in the database + */ + async update(aAddons) { + await this.openConnection(); + + this._update(aAddons); + }, + + /** + * Merge the given addons into the database. + * + * @param {array} aAddons + * Add-ons to insert/update in the database + */ + _update(aAddons) { + for (let addon of aAddons) { + this.DB.addons.set(addon.id, this._parseAddon(addon)); + } + + this.save(); + }, + + /* + * Creates an AddonSearchResult by parsing an object structure + * retrieved from the DB JSON representation. + * + * @param aObj + * The object to parse + * @return Returns an AddonSearchResult object. + */ + _parseAddon(aObj) { + if (aObj instanceof AddonSearchResult) { + return aObj; + } + + let id = aObj.id; + if (!aObj.id) { + return null; + } + + let addon = new AddonSearchResult(id); + + for (let expectedProperty of Object.keys(AddonSearchResult.prototype)) { + if ( + !(expectedProperty in aObj) || + typeof aObj[expectedProperty] === "function" + ) { + continue; + } + + let value = aObj[expectedProperty]; + + try { + switch (expectedProperty) { + case "sourceURI": + addon.sourceURI = value ? lazy.NetUtil.newURI(value) : null; + break; + + case "creator": + addon.creator = value ? this._makeDeveloper(value) : null; + break; + + case "updateDate": + addon.updateDate = value ? new Date(value) : null; + break; + + case "developers": + if (!addon.developers) { + addon.developers = []; + } + for (let developer of value) { + addon.developers.push(this._makeDeveloper(developer)); + } + break; + + case "screenshots": + if (!addon.screenshots) { + addon.screenshots = []; + } + for (let screenshot of value) { + addon.screenshots.push(this._makeScreenshot(screenshot)); + } + break; + + case "icons": + if (!addon.icons) { + addon.icons = {}; + } + for (let size of Object.keys(aObj.icons)) { + addon.icons[size] = aObj.icons[size]; + } + break; + + case "iconURL": + break; + + default: + addon[expectedProperty] = value; + } + } catch (ex) { + logger.warn( + "Error in parsing property value for " + expectedProperty + " | " + ex + ); + } + + // delete property from obj to indicate we've already + // handled it. The remaining public properties will + // be stored separately and just passed through to + // be written back to the DB. + delete aObj[expectedProperty]; + } + + // Copy remaining properties to a separate object + // to prevent accidental access on downgraded versions. + // The properties will be merged in the same object + // prior to being written back through toJSON. + for (let remainingProperty of Object.keys(aObj)) { + switch (typeof aObj[remainingProperty]) { + case "boolean": + case "number": + case "string": + case "object": + // these types are accepted + break; + default: + continue; + } + + if (!remainingProperty.startsWith("_")) { + addon._unsupportedProperties[remainingProperty] = + aObj[remainingProperty]; + } + } + + return addon; + }, + + /** + * Make a developer object from a vanilla + * JS object from the JSON database + * + * @param aObj + * The JS object to use + * @return The created developer + */ + _makeDeveloper(aObj) { + let name = aObj.name; + let url = aObj.url; + return new lazy.AddonManagerPrivate.AddonAuthor(name, url); + }, + + /** + * Make a screenshot object from a vanilla + * JS object from the JSON database + * + * @param aObj + * The JS object to use + * @return The created screenshot + */ + _makeScreenshot(aObj) { + let url = aObj.url; + let width = aObj.width; + let height = aObj.height; + let thumbnailURL = aObj.thumbnailURL; + let thumbnailWidth = aObj.thumbnailWidth; + let thumbnailHeight = aObj.thumbnailHeight; + let caption = aObj.caption; + return new lazy.AddonManagerPrivate.AddonScreenshot( + url, + width, + height, + thumbnailURL, + thumbnailWidth, + thumbnailHeight, + caption + ); + }, +}; diff --git a/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs new file mode 100644 index 0000000000..09bb0adc97 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/AddonSettings.sys.mjs @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { AddonManager } from "resource://gre/modules/AddonManager.sys.mjs"; + +const PREF_SIGNATURES_REQUIRED = "xpinstall.signatures.required"; +const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required"; +const PREF_ALLOW_EXPERIMENTS = "extensions.experiments.enabled"; +const PREF_EM_SIDELOAD_SCOPES = "extensions.sideloadScopes"; +const PREF_IS_EMBEDDED = "extensions.isembedded"; +const PREF_UPDATE_REQUIREBUILTINCERTS = "extensions.update.requireBuiltInCerts"; +const PREF_INSTALL_REQUIREBUILTINCERTS = + "extensions.install.requireBuiltInCerts"; + +export var AddonSettings = {}; + +// Make a non-changable property that can't be manipulated from other +// code in the app. +function makeConstant(name, value) { + Object.defineProperty(AddonSettings, name, { + configurable: false, + enumerable: false, + writable: false, + value, + }); +} + +if (AppConstants.MOZ_REQUIRE_SIGNING && !Cu.isInAutomation) { + makeConstant("REQUIRE_SIGNING", true); + makeConstant("LANGPACKS_REQUIRE_SIGNING", true); +} else { + XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "REQUIRE_SIGNING", + PREF_SIGNATURES_REQUIRED, + false + ); + XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "LANGPACKS_REQUIRE_SIGNING", + PREF_LANGPACK_SIGNATURES, + false + ); +} + +/** + * Require the use of certs shipped with Firefox for + * addon install and update, if the distribution does + * not require addon signing and is not ESR. + */ +XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "INSTALL_REQUIREBUILTINCERTS", + PREF_INSTALL_REQUIREBUILTINCERTS, + !AppConstants.MOZ_REQUIRE_SIGNING && + !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") +); + +XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "UPDATE_REQUIREBUILTINCERTS", + PREF_UPDATE_REQUIREBUILTINCERTS, + !AppConstants.MOZ_REQUIRE_SIGNING && + !AppConstants.MOZ_APP_VERSION_DISPLAY.endsWith("esr") +); + +// Whether or not we're running in GeckoView embedded in an Android app +if (Cu.isInAutomation) { + XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "IS_EMBEDDED", + PREF_IS_EMBEDDED, + false + ); +} else { + makeConstant("IS_EMBEDDED", AppConstants.platform === "android"); +} + +/** + * AddonSettings.EXPERIMENTS_ENABLED + * + * Experimental APIs are always available to privileged signed addons. + * This constant makes an optional preference available to enable experimental + * APIs for developement purposes. + * + * Two features are toggled with this preference: + * + * 1. The ability to load an extension that contains an experimental + * API but is not privileged. + * 2. The ability to load an unsigned extension that gains privilege + * if it is temporarily loaded (e.g. via about:debugging). + * + * MOZ_REQUIRE_SIGNING is set to zero in unbranded builds, we also + * ensure nightly, dev-ed and our test infrastructure have access to + * the preference. + * + * Official releases ignore this preference. + */ +if ( + !AppConstants.MOZ_REQUIRE_SIGNING || + AppConstants.NIGHTLY_BUILD || + AppConstants.MOZ_DEV_EDITION || + Cu.isInAutomation +) { + XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "EXPERIMENTS_ENABLED", + PREF_ALLOW_EXPERIMENTS, + true + ); +} else { + makeConstant("EXPERIMENTS_ENABLED", false); +} + +if (AppConstants.MOZ_DEV_EDITION) { + makeConstant("DEFAULT_THEME_ID", "firefox-compact-dark@mozilla.org"); +} else { + makeConstant("DEFAULT_THEME_ID", "default-theme@mozilla.org"); +} + +// SCOPES_SIDELOAD is a bitflag for what scopes we will load new extensions from when we scan the directories. +// If a build allows sideloading, or we're in automation, we'll also allow use of the preference. +if (AppConstants.MOZ_ALLOW_ADDON_SIDELOAD || Cu.isInAutomation) { + XPCOMUtils.defineLazyPreferenceGetter( + AddonSettings, + "SCOPES_SIDELOAD", + PREF_EM_SIDELOAD_SCOPES, + AppConstants.MOZ_ALLOW_ADDON_SIDELOAD + ? AddonManager.SCOPE_ALL + : AddonManager.SCOPE_PROFILE + ); +} else { + makeConstant("SCOPES_SIDELOAD", AddonManager.SCOPE_PROFILE); +} diff --git a/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs new file mode 100644 index 0000000000..7b30daa0e2 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.sys.mjs @@ -0,0 +1,1876 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ + +/* eslint "mozilla/no-aArgs": 1 */ +/* eslint "no-unused-vars": [2, {"args": "none", "varsIgnorePattern": "^(Cc|Ci|Cr|Cu|EXPORTED_SYMBOLS)$"}] */ +/* eslint "semi": [2, "always"] */ +/* eslint "valid-jsdoc": [2, {requireReturn: false}] */ + +const CERTDB_CONTRACTID = "@mozilla.org/security/x509certdb;1"; + +import { + AddonManager, + AddonManagerPrivate, +} from "resource://gre/modules/AddonManager.sys.mjs"; +import { AsyncShutdown } from "resource://gre/modules/AsyncShutdown.sys.mjs"; +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; +import { NetUtil } from "resource://gre/modules/NetUtil.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { EventEmitter } from "resource://gre/modules/EventEmitter.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ExtensionAddonObserver: "resource://gre/modules/Extension.sys.mjs", + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.sys.mjs", + FileTestUtils: "resource://testing-common/FileTestUtils.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", + MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs", + + XPCShellContentUtils: + "resource://testing-common/XPCShellContentUtils.sys.mjs", + + getAppInfo: "resource://testing-common/AppInfo.sys.mjs", + updateAppInfo: "resource://testing-common/AppInfo.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], +}); + +const ArrayBufferInputStream = Components.Constructor( + "@mozilla.org/io/arraybuffer-input-stream;1", + "nsIArrayBufferInputStream", + "setData" +); + +const nsFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" +); + +const ZipReader = Components.Constructor( + "@mozilla.org/libjar/zip-reader;1", + "nsIZipReader", + "open" +); + +const ZipWriter = Components.Constructor( + "@mozilla.org/zipwriter;1", + "nsIZipWriter", + "open" +); + +function isRegExp(val) { + return val && typeof val === "object" && typeof val.test === "function"; +} + +class MockBarrier { + constructor(name) { + this.name = name; + this.blockers = []; + } + + addBlocker(name, blocker, options) { + this.blockers.push({ name, blocker, options }); + } + + async trigger() { + await Promise.all( + this.blockers.map(async ({ blocker, name }) => { + try { + if (typeof blocker == "function") { + await blocker(); + } else { + await blocker; + } + } catch (e) { + Cu.reportError(e); + dump( + `Shutdown blocker '${name}' for ${this.name} threw error: ${e} :: ${e.stack}\n` + ); + } + }) + ); + + this.blockers = []; + } +} + +// Mock out AddonManager's reference to the AsyncShutdown module so we can shut +// down AddonManager from the test +export var MockAsyncShutdown = { + profileBeforeChange: new MockBarrier("profileBeforeChange"), + profileChangeTeardown: new MockBarrier("profileChangeTeardown"), + quitApplicationGranted: new MockBarrier("quitApplicationGranted"), + // We can use the real Barrier + Barrier: AsyncShutdown.Barrier, +}; + +AddonManagerPrivate.overrideAsyncShutdown(MockAsyncShutdown); + +class AddonsList { + constructor(file) { + this.extensions = []; + this.themes = []; + this.xpis = []; + + if (!file.exists()) { + return; + } + + let data = lazy.aomStartup.readStartupData(); + + for (let loc of Object.values(data)) { + let dir = loc.path && new nsFile(loc.path); + + for (let addon of Object.values(loc.addons)) { + let file; + if (dir) { + file = dir.clone(); + try { + file.appendRelativePath(addon.path); + } catch (e) { + file = new nsFile(addon.path); + } + } else if (addon.path) { + file = new nsFile(addon.path); + } + + if (!file) { + continue; + } + + this.xpis.push(file); + + if (addon.enabled) { + addon.type = addon.type || "extension"; + + if (addon.type == "theme") { + this.themes.push(file); + } else { + this.extensions.push(file); + } + } + } + } + } + + hasItem(type, dir, id) { + var path = dir.clone(); + path.append(id); + + var xpiPath = dir.clone(); + xpiPath.append(`${id}.xpi`); + + return this[type].some(file => { + if (!file.exists()) { + throw new Error( + `Non-existent path found in addonStartup.json: ${file.path}` + ); + } + + if (file.isDirectory()) { + return file.equals(path); + } + if (file.isFile()) { + return file.equals(xpiPath); + } + return false; + }); + } + + hasTheme(dir, id) { + return this.hasItem("themes", dir, id); + } + + hasExtension(dir, id) { + return this.hasItem("extensions", dir, id); + } +} + +// The number of resetXPIExports calls. +// +// This is added to the URL of the modules once resetXPIExports is called, +// so that they become different module instances for each reset, and also the +// suffix is not used outside of tests. +let resetXPIExportsCount = 0; + +// Reset all properties of XPIExports to lazy getters, with new module URIs, +// in order to simulate the shutdown+restart situation. +function resetXPIExports(XPIExports) { + resetXPIExportsCount++; + + const suffix = "?" + resetXPIExportsCount; + + // The list of lazy getters should be in sync with XPIExports.sys.mjs. + // + // eslint-disable-next-line mozilla/lazy-getter-object-name + ChromeUtils.defineESModuleGetters(XPIExports, { + // XPIDatabase.sys.mjs + AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix, + BuiltInThemesHelpers: + "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix, + XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix, + XPIDatabaseReconcile: + "resource://gre/modules/addons/XPIDatabase.sys.mjs" + suffix, + + // XPIInstall.sys.mjs + UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix, + XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix, + verifyBundleSignedState: + "resource://gre/modules/addons/XPIInstall.sys.mjs" + suffix, + + // XPIProvider.sys.mjs + XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix, + XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs" + suffix, + }); +} + +export var AddonTestUtils = { + addonIntegrationService: null, + addonsList: null, + appInfo: null, + addonStartup: null, + collectedTelemetryEvents: [], + testScope: null, + testUnpacked: false, + useRealCertChecks: false, + usePrivilegedSignatures: true, + certSignatureDate: null, + overrideEntry: null, + + maybeInit(testScope) { + if (this.testScope != testScope) { + this.init(testScope); + } + }, + + init(testScope, enableLogging = true) { + if (this.testScope === testScope) { + return; + } + this.testScope = testScope; + + // Get the profile directory for tests to use. + this.profileDir = testScope.do_get_profile(); + + this.profileExtensions = this.profileDir.clone(); + this.profileExtensions.append("extensions"); + + this.addonStartup = this.profileDir.clone(); + this.addonStartup.append("addonStartup.json.lz4"); + + // Register a temporary directory for the tests. + this.tempDir = this.profileDir.clone(); + this.tempDir.append("temp"); + this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + this.registerDirectory("TmpD", this.tempDir); + + // Create a replacement app directory for the tests. + const appDirForAddons = this.profileDir.clone(); + appDirForAddons.append("appdir-addons"); + appDirForAddons.create( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + this.registerDirectory("XREAddonAppDir", appDirForAddons); + + // Enable more extensive EM logging. + if (enableLogging) { + Services.prefs.setBoolPref("extensions.logging.enabled", true); + } + + // By default only load extensions from the profile install location + Services.prefs.setIntPref( + "extensions.enabledScopes", + AddonManager.SCOPE_PROFILE + ); + + // By default don't disable add-ons from any scope + Services.prefs.setIntPref("extensions.autoDisableScopes", 0); + + // And scan for changes at startup + Services.prefs.setIntPref("extensions.startupScanScopes", 15); + + // By default, don't cache add-ons in AddonRepository.jsm + Services.prefs.setBoolPref("extensions.getAddons.cache.enabled", false); + + // Point update checks to the local machine for fast failures + Services.prefs.setCharPref( + "extensions.update.url", + "http://127.0.0.1/updateURL" + ); + Services.prefs.setCharPref( + "extensions.update.background.url", + "http://127.0.0.1/updateBackgroundURL" + ); + Services.prefs.setCharPref( + "services.settings.server", + "data:,#remote-settings-dummy/v1" + ); + + // By default ignore bundled add-ons + Services.prefs.setBoolPref("extensions.installDistroAddons", false); + + // Ensure signature checks are enabled by default + Services.prefs.setBoolPref("xpinstall.signatures.required", true); + + // Make sure that a given path does not exist + function pathShouldntExist(file) { + if (file.exists()) { + throw new Error( + `Test cleanup: path ${file.path} exists when it should not` + ); + } + } + + testScope.registerCleanupFunction(() => { + // Force a GC to ensure that anything holding a ref to temp file releases it. + // XXX This shouldn't be needed here, since cleanupTempXPIs() does a GC if + // something fails; see bug 1761255 + this.info(`Force a GC`); + Cu.forceGC(); + + this.cleanupTempXPIs(); + + let ignoreEntries = new Set(); + { + // FileTestUtils lazily creates a directory to hold the temporary files + // it creates. If that directory exists, ignore it. + let { value } = Object.getOwnPropertyDescriptor( + lazy.FileTestUtils, + "_globalTemporaryDirectory" + ); + if (value) { + ignoreEntries.add(value.leafName); + } + } + + // Check that the temporary directory is empty + var entries = []; + for (let { leafName } of this.iterDirectory(this.tempDir)) { + if (!ignoreEntries.has(leafName)) { + entries.push(leafName); + } + } + if (entries.length) { + throw new Error( + `Found unexpected files in temporary directory: ${entries.join(", ")}` + ); + } + + try { + appDirForAddons.remove(true); + } catch (ex) { + testScope.info(`Got exception removing addon app dir: ${ex}`); + } + + // ensure no leftover files in the system addon upgrade location + let featuresDir = this.profileDir.clone(); + featuresDir.append("features"); + // upgrade directories will be in UUID folders under features/ + for (let dir of this.iterDirectory(featuresDir)) { + dir.append("stage"); + pathShouldntExist(dir); + } + + // ensure no leftover files in the user addon location + let testDir = this.profileDir.clone(); + testDir.append("extensions"); + testDir.append("trash"); + pathShouldntExist(testDir); + + testDir.leafName = "staged"; + pathShouldntExist(testDir); + + return this.promiseShutdownManager(); + }); + }, + + initMochitest(testScope) { + if (this.testScope === testScope) { + return; + } + this.testScope = testScope; + + this.profileDir = FileUtils.getDir("ProfD", []); + + this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]); + + this.tempDir = FileUtils.getDir("TmpD", []); + this.tempDir.append("addons-mochitest"); + this.tempDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + + testScope.registerCleanupFunction(() => { + // Defer testScope cleanup until the last cleanup function has run. + testScope.registerCleanupFunction(() => { + this.testScope = null; + }); + this.cleanupTempXPIs(); + try { + this.tempDir.remove(true); + } catch (e) { + Cu.reportError(e); + } + }); + }, + + /** + * Iterates over the entries in a given directory. + * + * Fails silently if the given directory does not exist. + * + * @param {nsIFile} dir + * Directory to iterate. + */ + *iterDirectory(dir) { + let dirEnum; + try { + dirEnum = dir.directoryEntries; + let file; + while ((file = dirEnum.nextFile)) { + yield file; + } + } catch (e) { + if (dir.exists()) { + Cu.reportError(e); + } + } finally { + if (dirEnum) { + dirEnum.close(); + } + } + }, + + /** + * Creates a new HttpServer for testing, and begins listening on the + * specified port. Automatically shuts down the server when the test + * unit ends. + * + * @param {object} [options = {}] + * The options object. + * @param {integer} [options.port = -1] + * The port to listen on. If omitted, listen on a random + * port. The latter is the preferred behavior. + * @param {sequence?} [options.hosts = null] + * A set of hosts to accept connections to. Support for this is + * implemented using a proxy filter. + * + * @returns {HttpServer} + * The HTTP server instance. + */ + createHttpServer(...args) { + lazy.XPCShellContentUtils.ensureInitialized(this.testScope); + return lazy.XPCShellContentUtils.createHttpServer(...args); + }, + + registerJSON(...args) { + return lazy.XPCShellContentUtils.registerJSON(...args); + }, + + info(msg) { + // info() for mochitests, do_print for xpcshell. + let print = this.testScope.info || this.testScope.do_print; + print(msg); + }, + + cleanupTempXPIs() { + let didGC = false; + + for (let file of this.tempXPIs.splice(0)) { + if (file.exists()) { + try { + Services.obs.notifyObservers(file, "flush-cache-entry"); + file.remove(false); + } catch (e) { + if (didGC) { + Cu.reportError(`Failed to remove ${file.path}: ${e}`); + } else { + // Bug 1606684 - Sometimes XPI files are still in use by a process + // after the test has been finished. Force a GC once and try again. + this.info(`Force a GC`); + Cu.forceGC(); + didGC = true; + + try { + file.remove(false); + } catch (e) { + Cu.reportError(`Failed to remove ${file.path} after GC: ${e}`); + } + } + } + } + } + }, + + createAppInfo(ID, name, version, platformVersion = "1.0") { + lazy.updateAppInfo({ + ID, + name, + version, + platformVersion, + crashReporter: true, + }); + this.appInfo = lazy.getAppInfo(); + }, + + getManifestURI(file) { + if (file.isDirectory()) { + file.leafName = "manifest.json"; + if (file.exists()) { + return NetUtil.newURI(file); + } + + throw new Error("No manifest file present"); + } + + let zip = ZipReader(file); + try { + let uri = NetUtil.newURI(file); + + if (zip.hasEntry("manifest.json")) { + return NetUtil.newURI(`jar:${uri.spec}!/manifest.json`); + } + + throw new Error("No manifest file present"); + } finally { + zip.close(); + } + }, + + getIDFromExtension(file) { + return this.getIDFromManifest(this.getManifestURI(file)); + }, + + async getIDFromManifest(manifestURI) { + let body = await fetch(manifestURI.spec); + let manifest = await body.json(); + try { + if (manifest.browser_specific_settings?.gecko?.id) { + return manifest.browser_specific_settings.gecko.id; + } + return manifest.applications.gecko.id; + } catch (e) { + // IDs for WebExtensions are extracted from the certificate when + // not present in the manifest, so just generate a random one. + return Services.uuid.generateUUID().number; + } + }, + + overrideCertDB() { + let verifyCert = async (file, result, cert, callback) => { + if ( + result == Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED && + !this.useRealCertChecks && + callback.wrappedJSObject + ) { + // Bypassing XPConnect allows us to create a fake x509 certificate from JS + callback = callback.wrappedJSObject; + + try { + let id; + try { + let manifestURI = this.getManifestURI(file); + id = await this.getIDFromManifest(manifestURI); + } catch (err) { + if (file.leafName.endsWith(".xpi")) { + id = file.leafName.slice(0, -4); + } + } + + let fakeCert = { commonName: id }; + if (this.usePrivilegedSignatures) { + let privileged = + typeof this.usePrivilegedSignatures == "function" + ? this.usePrivilegedSignatures(id) + : this.usePrivilegedSignatures; + if (privileged === "system") { + fakeCert.organizationalUnit = "Mozilla Components"; + } else if (privileged) { + fakeCert.organizationalUnit = "Mozilla Extensions"; + } + } + if (this.certSignatureDate) { + // addon.signedDate is derived from this, used by the blocklist. + fakeCert.validity = { + notBefore: this.certSignatureDate * 1000, + }; + } + + return [callback, Cr.NS_OK, fakeCert]; + } catch (e) { + // If there is any error then just pass along the original results + } finally { + // Make sure to close the open zip file or it will be locked. + if (file.isFile()) { + Services.obs.notifyObservers( + file, + "flush-cache-entry", + "cert-override" + ); + } + } + } + + return [callback, result, cert]; + }; + + let FakeCertDB = { + init() { + for (let property of Object.keys( + this._genuine.QueryInterface(Ci.nsIX509CertDB) + )) { + if (property in this) { + continue; + } + + if (typeof this._genuine[property] == "function") { + this[property] = this._genuine[property].bind(this._genuine); + } + } + }, + + openSignedAppFileAsync(root, file, callback) { + // First try calling the real cert DB + this._genuine.openSignedAppFileAsync( + root, + file, + (result, zipReader, cert) => { + verifyCert(file.clone(), result, cert, callback).then( + ([callback, result, cert]) => { + callback.openSignedAppFileFinished(result, zipReader, cert); + } + ); + } + ); + }, + + QueryInterface: ChromeUtils.generateQI(["nsIX509CertDB"]), + }; + + // Unregister the real database. This only works because the add-ons manager + // hasn't started up and grabbed the certificate database yet. + lazy.MockRegistrar.register(CERTDB_CONTRACTID, FakeCertDB); + + // Initialize the mock service. + Cc[CERTDB_CONTRACTID].getService(); + FakeCertDB.init(); + }, + + /** + * Load the data from the specified files into the *real* blocklist providers. + * Loads using loadBlocklistRawData, which will treat this as an update. + * + * @param {nsIFile} dir + * The directory in which the files live. + * @param {string} prefix + * a prefix for the files which ought to be loaded. + * This method will suffix -extensions.json + * to the prefix it is given, and attempt to load it. + * If it exists, its data will be dumped into + * the respective store, and the update handler + * will be called. + */ + async loadBlocklistData(dir, prefix) { + let loadedData = {}; + let fileSuffix = "extensions"; + const fileName = `${prefix}-${fileSuffix}.json`; + + try { + loadedData[fileSuffix] = await IOUtils.readJSON( + PathUtils.join(dir.path, fileName) + ); + this.info(`Loaded ${fileName}`); + } catch (e) {} + + return this.loadBlocklistRawData(loadedData); + }, + + /** + * Load the following data into the *real* blocklist providers. + * Fires update methods as would happen if this data came from + * an actual blocklist update, etc. + * + * @param {object} data + * The data to load. + */ + async loadBlocklistRawData(data) { + const { BlocklistPrivate } = ChromeUtils.importESModule( + "resource://gre/modules/Blocklist.sys.mjs" + ); + const blocklistMapping = { + extensions: BlocklistPrivate.ExtensionBlocklistRS, + extensionsMLBF: BlocklistPrivate.ExtensionBlocklistMLBF, + }; + + for (const [dataProp, blocklistObj] of Object.entries(blocklistMapping)) { + let newData = data[dataProp]; + if (!newData) { + continue; + } + if (!Array.isArray(newData)) { + throw new Error( + "Expected an array of new items to put in the " + + dataProp + + " blocklist!" + ); + } + for (let item of newData) { + if (!item.id) { + item.id = Services.uuid.generateUUID().number.slice(1, -1); + } + if (!item.last_modified) { + item.last_modified = Date.now(); + } + } + blocklistObj.ensureInitialized(); + let db = await blocklistObj._client.db; + const collectionTimestamp = Math.max( + ...newData.map(r => r.last_modified) + ); + await db.importChanges({}, collectionTimestamp, newData, { + clear: true, + }); + // We manually call _onUpdate... which is evil, but at the moment kinto doesn't have + // a better abstraction unless you want to mock your own http server to do the update. + await blocklistObj._onUpdate(); + } + }, + + /** + * Starts up the add-on manager as if it was started by the application. + * + * @param {Object} params + * The new params are in an object and new code should use that. + * @param {boolean} params.earlyStartup + * Notifies early startup phase. default is true + * @param {boolean} params.lateStartup + * Notifies late startup phase which ensures addons are started or + * listeners are primed. default is true + * @param {boolean} params.newVersion + * If provided, the application version is changed to this string + * before the AddonManager is started. + */ + async promiseStartupManager(params) { + if (this.addonIntegrationService) { + throw new Error( + "Attempting to startup manager that was already started." + ); + } + // Support old arguments + if (typeof params != "object") { + params = { + newVersion: arguments[0], + }; + } + let { earlyStartup = true, lateStartup = true, newVersion } = params; + + lateStartup = earlyStartup && lateStartup; + + if (newVersion) { + this.appInfo.version = newVersion; + this.appInfo.platformVersion = newVersion; + } + + // AddonListeners are removed when the addonManager is shutdown, + // ensure the Extension observer is added. We call uninit in + // promiseShutdown to allow re-initialization. + lazy.ExtensionAddonObserver.init(); + + const { XPIExports } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ); + XPIExports.XPIInternal.overrideAsyncShutdown(MockAsyncShutdown); + + XPIExports.XPIInternal.BootstrapScope.prototype._beforeCallBootstrapMethod = + (method, params, reason) => { + try { + this.emit("bootstrap-method", { method, params, reason }); + } catch (e) { + try { + this.testScope.do_throw(e); + } catch (e) { + // Le sigh. + } + } + }; + + this.addonIntegrationService = Cc[ + "@mozilla.org/addons/integration;1" + ].getService(Ci.nsIObserver); + + this.addonIntegrationService.observe(null, "addons-startup", null); + + this.emit("addon-manager-started"); + + await Promise.all(XPIExports.XPIProvider.startupPromises); + + // Load the add-ons list as it was after extension registration + await this.loadAddonsList(true); + + // Wait for all add-ons to finish starting up before resolving. + await Promise.all( + Array.from( + XPIExports.XPIProvider.activeAddons.values(), + addon => addon.startupPromise + ) + ); + if (earlyStartup) { + lazy.ExtensionTestCommon.notifyEarlyStartup(); + } + if (lateStartup) { + lazy.ExtensionTestCommon.notifyLateStartup(); + } + }, + + async promiseShutdownManager({ + clearOverrides = true, + clearL10nRegistry = true, + } = {}) { + if (!this.addonIntegrationService) { + return false; + } + + if (this.overrideEntry && clearOverrides) { + this.overrideEntry.destruct(); + this.overrideEntry = null; + } + + const { XPIExports } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ); + + // Ensure some startup observers in XPIProvider are released. + Services.obs.notifyObservers(null, "test-load-xpi-database"); + + // Note: the code here used to trigger observer notifications such as + // "quit-application-granted". That was removed because of unwanted side + // effects in other components. The MockAsyncShutdown triggers here are very + // specific and only affect the AddonManager/XPIProvider internals. + await MockAsyncShutdown.quitApplicationGranted.trigger(); + + // If XPIDatabase.asyncLoadDB() has been called before, then _dbPromise is + // a promise, potentially still pending. Wait for it to settle before + // triggering profileBeforeChange, because the latter can trigger errors in + // the pending asyncLoadDB() by an indirect call to XPIDatabase.shutdown(). + await XPIExports.XPIDatabase._dbPromise; + + await MockAsyncShutdown.profileBeforeChange.trigger(); + await MockAsyncShutdown.profileChangeTeardown.trigger(); + + this.emit("addon-manager-shutdown"); + + this.addonIntegrationService = null; + + // Load the add-ons list as it was after application shutdown + await this.loadAddonsList(); + + // Flush the jar cache entries for each bootstrapped XPI so that + // we don't run into file locking issues on Windows. + for (let file of this.addonsList.xpis) { + Services.obs.notifyObservers(file, "flush-cache-entry"); + } + + // Clear L10nRegistry entries so restaring the AOM will work correctly with locales. + if (clearL10nRegistry) { + L10nRegistry.getInstance().clearSources(); + } + + // Clear any crash report annotations + this.appInfo.annotations = {}; + + // Force the XPIProvider provider to reload to better + // simulate real-world usage. + + // This would be cleaner if I could get it as the rejection reason from + // the AddonManagerInternal.shutdown() promise + let shutdownError = XPIExports.XPIDatabase._saveError; + + AddonManagerPrivate.unregisterProvider(XPIExports.XPIProvider); + + resetXPIExports(XPIExports); + + lazy.ExtensionAddonObserver.uninit(); + + lazy.ExtensionTestCommon.resetStartupPromises(); + + if (shutdownError) { + throw shutdownError; + } + + return true; + }, + + /** + * Asynchronously restart the AddonManager. If newVersion is provided, + * simulate an application upgrade (or downgrade) where the version + * is changed to newVersion when re-started. + * + * @param {Object} params + * The new params are in an object and new code should use that. + * See promiseStartupManager for param details. + */ + async promiseRestartManager(params) { + await this.promiseShutdownManager({ clearOverrides: false }); + await this.promiseStartupManager(params); + }, + + /** + * If promiseStartupManager is called with earlyStartup: false, then + * use this to notify early startup. + * + * @returns {Promise} resolves when notification is complete + */ + notifyEarlyStartup() { + return lazy.ExtensionTestCommon.notifyEarlyStartup(); + }, + + /** + * If promiseStartupManager is called with lateStartup: false, then + * use this to notify late startup. You should also call early startup + * if necessary. + * + * @returns {Promise} resolves when notification is complete + */ + notifyLateStartup() { + return lazy.ExtensionTestCommon.notifyLateStartup(); + }, + + async loadAddonsList(flush = false) { + if (flush) { + const { XPIExports } = ChromeUtils.importESModule( + "resource://gre/modules/addons/XPIExports.sys.mjs" + ); + XPIExports.XPIInternal.XPIStates.save(); + await XPIExports.XPIInternal.XPIStates._jsonFile._save(); + } + + this.addonsList = new AddonsList(this.addonStartup); + }, + + /** + * Writes the given data to a file in the given zip file. + * + * @param {string|nsIFile} zipFile + * The zip file to write to. + * @param {Object} files + * An object containing filenames and the data to write to the + * corresponding paths in the zip file. + * @param {integer} [flags = 0] + * Additional flags to open the file with. + */ + writeFilesToZip(zipFile, files, flags = 0) { + if (typeof zipFile == "string") { + zipFile = nsFile(zipFile); + } + + var zipW = ZipWriter( + zipFile, + FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE | flags + ); + + for (let [path, data] of Object.entries(files)) { + if ( + typeof data === "object" && + ChromeUtils.getClassName(data) === "Object" + ) { + data = JSON.stringify(data); + } + if (!(data instanceof ArrayBuffer)) { + data = new TextEncoder().encode(data).buffer; + } + + let stream = ArrayBufferInputStream(data, 0, data.byteLength); + + // Note these files are being created in the XPI archive with date + // 1 << 49, which is a valid time for ZipWriter. + zipW.addEntryStream( + path, + Math.pow(2, 49), + Ci.nsIZipWriter.COMPRESSION_NONE, + stream, + false + ); + } + + zipW.close(); + }, + + async promiseWriteFilesToZip(zip, files, flags) { + await IOUtils.makeDirectory(PathUtils.parent(zip)); + + this.writeFilesToZip(zip, files, flags); + + return Promise.resolve(nsFile(zip)); + }, + + async promiseWriteFilesToDir(dir, files) { + await IOUtils.makeDirectory(dir); + + for (let [path, data] of Object.entries(files)) { + path = path.split("/"); + let leafName = path.pop(); + + // Create parent directories, if necessary. + let dirPath = dir; + for (let subDir of path) { + dirPath = PathUtils.join(dirPath, subDir); + await PathUtils.makeDirectory(dirPath); + } + + const leafPath = PathUtils.join(dirPath, leafName); + if ( + typeof data == "object" && + ChromeUtils.getClassName(data) == "Object" + ) { + await IOUtils.writeJSON(leafPath, data); + } else if (typeof data == "string") { + await IOUtils.writeUTF8(leafPath, data); + } + } + + return nsFile(dir); + }, + + promiseWriteFilesToExtension(dir, id, files, unpacked = this.testUnpacked) { + if (unpacked) { + let path = PathUtils.join(dir, id); + + return this.promiseWriteFilesToDir(path, files); + } + + let xpi = PathUtils.join(dir, `${id}.xpi`); + + return this.promiseWriteFilesToZip(xpi, files); + }, + + tempXPIs: [], + + allocTempXPIFile() { + let file = this.tempDir.clone(); + let uuid = Services.uuid.generateUUID().number.slice(1, -1); + file.append(`${uuid}.xpi`); + + this.tempXPIs.push(file); + + return file; + }, + + /** + * Creates an XPI file for some manifest data in the temporary directory and + * returns the nsIFile for it. The file will be deleted when the test completes. + * + * @param {object} files + * The object holding data about the add-on + * @return {nsIFile} A file pointing to the created XPI file + */ + createTempXPIFile(files) { + let file = this.allocTempXPIFile(); + this.writeFilesToZip(file.path, files); + return file; + }, + + /** + * Creates an XPI file for some WebExtension data in the temporary directory and + * returns the nsIFile for it. The file will be deleted when the test completes. + * + * @param {Object} data + * The object holding data about the add-on, as expected by + * |ExtensionTestCommon.generateXPI|. + * @return {nsIFile} A file pointing to the created XPI file + */ + createTempWebExtensionFile(data) { + let file = lazy.ExtensionTestCommon.generateXPI(data); + this.tempXPIs.push(file); + return file; + }, + + /** + * Creates an XPI with the given files and installs it. + * + * @param {object} files + * A files object as would be passed to {@see #createTempXPI}. + * @returns {Promise} + * A promise which resolves when the add-on is installed. + */ + promiseInstallXPI(files) { + return this.promiseInstallFile(this.createTempXPIFile(files)); + }, + + /** + * Creates an extension proxy file. + * See: https://developer.mozilla.org/en-US/Add-ons/Setting_up_extension_development_environment#Firefox_extension_proxy_file + * + * @param {nsIFile} dir + * The directory to add the proxy file to. + * @param {nsIFile} addon + * An nsIFile for the add-on file that this is a proxy file for. + * @param {string} id + * A string to use for the add-on ID. + * @returns {Promise} Resolves when the file has been created. + */ + promiseWriteProxyFileToDir(dir, addon, id) { + let files = { + [id]: addon.path, + }; + + return this.promiseWriteFilesToDir(dir.path, files); + }, + + /** + * Manually installs an XPI file into an install location by either copying the + * XPI there or extracting it depending on whether unpacking is being tested + * or not. + * + * @param {nsIFile} xpiFile + * The XPI file to install. + * @param {nsIFile} [installLocation = this.profileExtensions] + * The install location (an nsIFile) to install into. + * @param {string} [id] + * The ID to install as. + * @param {boolean} [unpacked = this.testUnpacked] + * If true, install as an unpacked directory, rather than a + * packed XPI. + * @returns {nsIFile} + * A file pointing to the installed location of the XPI file or + * unpacked directory. + */ + async manuallyInstall( + xpiFile, + installLocation = this.profileExtensions, + id = null, + unpacked = this.testUnpacked + ) { + if (id == null) { + id = await this.getIDFromExtension(xpiFile); + } + + if (unpacked) { + let dir = installLocation.clone(); + dir.append(id); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let zip = ZipReader(xpiFile); + for (let entry of zip.findEntries(null)) { + let target = dir.clone(); + for (let part of entry.split("/")) { + target.append(part); + } + if (!target.parent.exists()) { + target.parent.create( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + } + try { + zip.extract(entry, target); + } catch (e) { + if ( + e.result != Cr.NS_ERROR_FILE_DIR_NOT_EMPTY && + !(target.exists() && target.isDirectory()) + ) { + throw e; + } + } + target.permissions |= FileUtils.PERMS_FILE; + } + zip.close(); + + return dir; + } + + let target = installLocation.clone(); + target.append(`${id}.xpi`); + xpiFile.copyTo(target.parent, target.leafName); + return target; + }, + + /** + * Manually uninstalls an add-on by removing its files from the install + * location. + * + * @param {nsIFile} installLocation + * The nsIFile of the install location to remove from. + * @param {string} id + * The ID of the add-on to remove. + * @param {boolean} [unpacked = this.testUnpacked] + * If true, uninstall an unpacked directory, rather than a + * packed XPI. + */ + manuallyUninstall(installLocation, id, unpacked = this.testUnpacked) { + let file = this.getFileForAddon(installLocation, id, unpacked); + + // In reality because the app is restarted a flush isn't necessary for XPIs + // removed outside the app, but for testing we must flush manually. + if (file.isFile()) { + Services.obs.notifyObservers(file, "flush-cache-entry"); + } + + file.remove(true); + }, + + /** + * Gets the nsIFile for where an add-on is installed. It may point to a file or + * a directory depending on whether add-ons are being installed unpacked or not. + * + * @param {nsIFile} dir + * The nsIFile for the install location + * @param {string} id + * The ID of the add-on + * @param {boolean} [unpacked = this.testUnpacked] + * If true, return the path to an unpacked directory, rather than a + * packed XPI. + * @returns {nsIFile} + * A file pointing to the XPI file or unpacked directory where + * the add-on should be installed. + */ + getFileForAddon(dir, id, unpacked = this.testUnpacked) { + dir = dir.clone(); + if (unpacked) { + dir.append(id); + } else { + dir.append(`${id}.xpi`); + } + return dir; + }, + + /** + * Sets the last modified time of the extension, usually to trigger an update + * of its metadata. + * + * @param {nsIFile} ext A file pointing to either the packed extension or its unpacked directory. + * @param {number} time The time to which we set the lastModifiedTime of the extension + * + * @deprecated Please use promiseSetExtensionModifiedTime instead + */ + setExtensionModifiedTime(ext, time) { + ext.lastModifiedTime = time; + if (ext.isDirectory()) { + for (let file of this.iterDirectory(ext)) { + this.setExtensionModifiedTime(file, time); + } + } + }, + + async promiseSetExtensionModifiedTime(path, time) { + await IOUtils.setModificationTime(path, time); + + const stat = await IOUtils.stat(path); + if (stat.type !== "directory") { + return; + } + + const children = await IOUtils.getChildren(path); + + try { + await Promise.all( + children.map(entry => this.promiseSetExtensionModifiedTime(entry, time)) + ); + } catch (ex) { + if (DOMException.isInstance(ex)) { + return; + } + throw ex; + } + }, + + registerDirectory(key, dir) { + var dirProvider = { + getFile(prop, persistent) { + persistent.value = false; + if (prop == key) { + return dir.clone(); + } + return null; + }, + + QueryInterface: ChromeUtils.generateQI(["nsIDirectoryServiceProvider"]), + }; + Services.dirsvc.registerProvider(dirProvider); + + try { + Services.dirsvc.undefine(key); + } catch (e) { + // This throws if the key is not already registered, but that + // doesn't matter. + if (e.result != Cr.NS_ERROR_FAILURE) { + throw e; + } + } + }, + + /** + * Returns a promise that resolves when the given add-on event is fired. The + * resolved value is an array of arguments passed for the event. + * + * @param {string} event + * The name of the AddonListener event handler method for which + * an event is expected. + * @param {function} checkFn [optional] + * A function to check if this is the right event. Should return true + * for the event that it wants, false otherwise. Will be passed + * all the relevant arguments. + * If not passed, any event will do to resolve the promise. + * @returns {Promise} + * Resolves to an array containing the event handler's + * arguments the first time it is called. + */ + promiseAddonEvent(event, checkFn) { + return new Promise(resolve => { + let listener = { + [event](...args) { + if (typeof checkFn == "function" && !checkFn(...args)) { + return; + } + AddonManager.removeAddonListener(listener); + resolve(args); + }, + }; + + AddonManager.addAddonListener(listener); + }); + }, + + promiseInstallEvent(event, checkFn) { + return new Promise(resolve => { + let listener = { + [event](...args) { + if (typeof checkFn == "function" && !checkFn(...args)) { + return; + } + AddonManager.removeInstallListener(listener); + resolve(args); + }, + }; + + AddonManager.addInstallListener(listener); + }); + }, + + /** + * A helper method to install AddonInstall and wait for completion. + * + * @param {AddonInstall} install + * The add-on to install. + * @returns {Promise} + * Resolves when the install completes, either successfully or + * in failure. + */ + promiseCompleteInstall(install) { + let listener; + return new Promise(resolve => { + let installPromise; + listener = { + onDownloadFailed: resolve, + onDownloadCancelled: resolve, + onInstallFailed: resolve, + onInstallCancelled: resolve, + onInstallEnded() { + // onInstallEnded is called right when an add-on has been installed. + // install() may still be pending, e.g. for updates, and be awaiting + // the completion of the update, part of which is the removal of the + // temporary XPI file of the downloaded update. To avoid intermittent + // test failures due to lingering temporary files, await install(). + resolve(installPromise); + }, + onInstallPostponed: resolve, + }; + + install.addListener(listener); + installPromise = install.install(); + }).then(() => { + install.removeListener(listener); + return install; + }); + }, + + /** + * A helper method to install a file. + * + * @param {nsIFile} file + * The file to install + * @param {boolean} [ignoreIncompatible = false] + * Optional parameter to ignore add-ons that are incompatible + * with the application + * @param {Object} [installTelemetryInfo = undefined] + * Optional parameter to set the install telemetry info for the + * installed addon + * @returns {Promise} + * Resolves when the install has completed. + */ + async promiseInstallFile( + file, + ignoreIncompatible = false, + installTelemetryInfo + ) { + let install = await AddonManager.getInstallForFile( + file, + null, + installTelemetryInfo + ); + if (!install) { + throw new Error(`No AddonInstall created for ${file.path}`); + } + + if (install.state != AddonManager.STATE_DOWNLOADED) { + throw new Error( + `Expected file to be downloaded for install of ${file.path}` + ); + } + + if (ignoreIncompatible && install.addon.appDisabled) { + return null; + } + + await install.install(); + return install; + }, + + /** + * A helper method to install an array of files. + * + * @param {Iterable} files + * The files to install + * @param {boolean} [ignoreIncompatible = false] + * Optional parameter to ignore add-ons that are incompatible + * with the application + * @returns {Promise} + * Resolves when the installs have completed. + */ + promiseInstallAllFiles(files, ignoreIncompatible = false) { + return Promise.all( + Array.from(files, file => + this.promiseInstallFile(file, ignoreIncompatible) + ) + ); + }, + + promiseCompleteAllInstalls(installs) { + return Promise.all(Array.from(installs, this.promiseCompleteInstall)); + }, + + /** + * @property {number} updateReason + * The default update reason for {@see promiseFindAddonUpdates} + * calls. May be overwritten by tests which primarily check for + * updates with a particular reason. + */ + updateReason: AddonManager.UPDATE_WHEN_PERIODIC_UPDATE, + + /** + * Returns a promise that will be resolved when an add-on update check is + * complete. The value resolved will be an AddonInstall if a new version was + * found. + * + * @param {object} addon The add-on to find updates for. + * @param {integer} reason The type of update to find. + * @param {Array} args Additional args to pass to `checkUpdates` after + * the update reason. + * @return {Promise} an object containing information about the update. + */ + promiseFindAddonUpdates( + addon, + reason = AddonTestUtils.updateReason, + ...args + ) { + // Retrieve the test assertion helper from the testScope + // (which is `equal` in xpcshell-test and `is` in mochitest) + let equal = this.testScope.equal || this.testScope.is; + return new Promise((resolve, reject) => { + let result = {}; + addon.findUpdates( + { + onNoCompatibilityUpdateAvailable(addon2) { + if ("compatibilityUpdate" in result) { + throw new Error("Saw multiple compatibility update events"); + } + equal(addon, addon2, "onNoCompatibilityUpdateAvailable"); + result.compatibilityUpdate = false; + }, + + onCompatibilityUpdateAvailable(addon2) { + if ("compatibilityUpdate" in result) { + throw new Error("Saw multiple compatibility update events"); + } + equal(addon, addon2, "onCompatibilityUpdateAvailable"); + result.compatibilityUpdate = true; + }, + + onNoUpdateAvailable(addon2) { + if ("updateAvailable" in result) { + throw new Error("Saw multiple update available events"); + } + equal(addon, addon2, "onNoUpdateAvailable"); + result.updateAvailable = false; + }, + + onUpdateAvailable(addon2, install) { + if ("updateAvailable" in result) { + throw new Error("Saw multiple update available events"); + } + equal(addon, addon2, "onUpdateAvailable"); + result.updateAvailable = install; + }, + + onUpdateFinished(addon2, error) { + equal(addon, addon2, "onUpdateFinished"); + if (error == AddonManager.UPDATE_STATUS_NO_ERROR) { + resolve(result); + } else { + result.error = error; + reject(result); + } + }, + }, + reason, + ...args + ); + }); + }, + + /** + * Monitors console output for the duration of a task, and returns a promise + * which resolves to a tuple containing a list of all console messages + * generated during the task's execution, and the result of the task itself. + * + * @param {function} task + * The task to run while monitoring console output. May be + * an async function, or an ordinary function which returns a promose. + * @return {Promise<[Array, *]>} + * Resolves to an object containing a `messages` property, with + * the array of console messages emitted during the execution + * of the task, and a `result` property, containing the task's + * return value. + */ + async promiseConsoleOutput(task) { + const DONE = "=== xpcshell test console listener done ==="; + + let listener, + messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + msg instanceof Ci.nsIScriptError; + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } + }, + + /** + * An object describing an expected or forbidden console message. Each + * property in the object corresponds to a property with the same name + * in a console message. If the value in the pattern object is a + * regular expression, it must match the value of the corresponding + * console message property. If it is any other value, it must be + * strictly equal to the correspondng console message property. + * + * @typedef {object} ConsoleMessagePattern + */ + + /** + * Checks the list of messages returned from `promiseConsoleOutput` + * against the given set of expected messages. + * + * This is roughly equivalent to the expected and forbidden message + * matching functionality of SimpleTest.monitorConsole. + * + * @param {Array} messages + * The array of console messages to match. + * @param {object} options + * Options describing how to perform the match. + * @param {Array} [options.expected = []] + * An array of messages which must appear in `messages`. The + * matching messages in the `messages` array must appear in the + * same order as the patterns in the `expected` array. + * @param {Array} [options.forbidden = []] + * An array of messages which must not appear in the `messages` + * array. + * @param {bool} [options.forbidUnexpected = false] + * If true, the `messages` array must not contain any messages + * which are not matched by the given `expected` patterns. + */ + checkMessages( + messages, + { expected = [], forbidden = [], forbidUnexpected = false } + ) { + function msgMatches(msg, expectedMsg) { + for (let [prop, pattern] of Object.entries(expectedMsg)) { + if (isRegExp(pattern) && typeof msg[prop] === "string") { + if (!pattern.test(msg[prop])) { + return false; + } + } else if (msg[prop] !== pattern) { + return false; + } + } + return true; + } + + function validateOptionFormat(optionName, optionValue) { + for (let item of optionValue) { + if (!item || typeof item !== "object" || isRegExp(item)) { + throw new Error( + `Unexpected format in AddonTestUtils.checkMessages "${optionName}" parameter` + ); + } + } + } + + validateOptionFormat("expected", expected); + validateOptionFormat("forbidden", forbidden); + + let i = 0; + for (let msg of messages) { + if (forbidden.some(pat => msgMatches(msg, pat))) { + this.testScope.ok(false, `Got forbidden console message: ${msg}`); + continue; + } + + if (i < expected.length && msgMatches(msg, expected[i])) { + this.info(`Matched expected console message: ${msg}`); + i++; + } else if (forbidUnexpected) { + this.testScope.ok(false, `Got unexpected console message: ${msg}`); + } + } + for (let pat of expected.slice(i)) { + this.testScope.ok( + false, + `Did not get expected console message: ${uneval(pat)}` + ); + } + }, + + /** + * Asserts that the expected installTelemetryInfo properties are available + * on the AddonWrapper or AddonInstall objects. + * + * @param {AddonWrapper|AddonInstall} addonOrInstall + * The addon or addonInstall object to check. + * @param {Object} expectedInstallInfo + * The expected installTelemetryInfo properties + * (every property can be a primitive value or a regular expression). + * @param {string} [msg] + * Optional assertion message suffix. + */ + checkInstallInfo(addonOrInstall, expectedInstallInfo, msg = undefined) { + const installInfo = addonOrInstall.installTelemetryInfo; + const { Assert } = this.testScope; + + msg = msg ? ` ${msg}` : ""; + + for (const key of Object.keys(expectedInstallInfo)) { + const actual = installInfo[key]; + let expected = expectedInstallInfo[key]; + + // Assert the property value using a regular expression. + if (expected && typeof expected.test == "function") { + Assert.ok( + expected.test(actual), + `${key} value "${actual}" has the value expected "${expected}"${msg}` + ); + } else { + Assert.deepEqual( + actual, + expected, + `Got the expected value for ${key}${msg}` + ); + } + } + }, + + /** + * Helper to wait for a webextension to completely start + * + * @param {string} [id] + * An optional extension id to look for. + * + * @returns {Promise} + * A promise that resolves with the extension, once it is started. + */ + promiseWebExtensionStartup(id) { + return new Promise(resolve => { + lazy.Management.on("ready", function listener(event, extension) { + if (!id || extension.id == id) { + lazy.Management.off("ready", listener); + resolve(extension); + } + }); + }); + }, + + /** + * Wait until an extension with a search provider has been loaded. + * This should be called after the extension has started, but before shutdown. + * + * @param {object} extension + * The return value of ExtensionTestUtils.loadExtension. + * For browser tests, see mochitest/tests/SimpleTest/ExtensionTestUtils.js + * For xpcshell tests, see toolkit/components/extensions/ExtensionXPCShellUtils.jsm + * @param {object} [options] + * Optional options. + * @param {boolean} [options.expectPending = false] + * Whether to expect the search provider to still be starting up. + */ + async waitForSearchProviderStartup( + extension, + { expectPending = false } = {} + ) { + // In xpcshell tests, equal/ok are defined in the global scope. + let { equal, ok } = this.testScope; + if (!equal || !ok) { + // In mochitests, these are available via Assert.sys.mjs. + let { Assert } = this.testScope; + equal = Assert.equal.bind(Assert); + ok = Assert.ok.bind(Assert); + } + + equal( + extension.state, + "running", + "Search provider extension should be running" + ); + ok(extension.id, "Extension ID of search provider should be set"); + + // The map of promises from browser/components/extensions/parent/ext-chrome-settings-overrides.js + let { pendingSearchSetupTasks } = lazy.Management.global; + let searchStartupPromise = pendingSearchSetupTasks.get(extension.id); + if (expectPending) { + ok( + searchStartupPromise, + "Search provider registration should be in progress" + ); + } + return searchStartupPromise; + }, + + /** + * Initializes the URLPreloader, which is required in order to load + * built_in_addons.json. + */ + initializeURLPreloader() { + lazy.aomStartup.initializeURLPreloader(); + }, + + /** + * Override chrome URL for specifying allowed built-in add-ons. + * + * @param {object} data - An object specifying which add-on IDs are permitted + * to load, for instance: { "system": ["id1", "..."] } + */ + async overrideBuiltIns(data) { + this.initializeURLPreloader(); + + let file = this.tempDir.clone(); + file.append("override.txt"); + this.tempXPIs.push(file); + + let manifest = Services.io.newFileURI(file); + await IOUtils.writeJSON(file.path, data); + this.overrideEntry = lazy.aomStartup.registerChrome(manifest, [ + [ + "override", + "chrome://browser/content/built_in_addons.json", + Services.io.newFileURI(file).spec, + ], + ]); + }, + + // AMTelemetry events helpers. + + /** + * Formerly this function re-routed telemetry events. Now it just ensures + * that there are no unexamined events after the test file is exiting. + */ + hookAMTelemetryEvents() { + this.testScope.registerCleanupFunction(() => { + this.testScope.Assert.deepEqual( + [], + this.getAMTelemetryEvents(), + "No unexamined telemetry events after test is finished" + ); + }); + }, + + /** + * Retrive any AMTelemetry event collected and clears _all_ telemetry events. + * + * @returns {Array} + * The array of the collected telemetry data. + */ + getAMTelemetryEvents() { + // This duplicates some logic from TelemetryTestUtils. + let snapshots = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + /* clear = */ true + ); + let events = (snapshots.parent ?? []) + .filter(entry => entry[1] == "addonsManager") + .map(entry => ({ + // The callers don't expect the timestamp or the category. + method: entry[2], + object: entry[3], + value: entry[4], + extra: entry[5], + })); + + return events; + }, + + /** + * @param {string|string[]} events - The event(s) to retrieve. + * @param {object} [filter] - key/value pairs to filter events. + * @returns {object[]} Collected extra objects from events. + */ + getAMGleanEvents(events, filter = {}) { + let result = []; + for (let event of [].concat(events)) { + result = result.concat(Glean.addonsManager[event].testGetValue() ?? []); + } + + // When combining multiple events, we want them in chronological order. + result.sort((a, b) => a.timestamp - b.timestamp); + + result = result.filter(e => + Object.keys(filter).every(key => e.extra[key] === filter[key]) + ); + + // We (usually) don't care about install_id, so drop it to ease comparison. + result.forEach(e => delete e.extra.install_id); + + // For Glean events, all data is in the extra object. + return result.map(e => e.extra); + }, +}; + +for (let [key, val] of Object.entries(AddonTestUtils)) { + if (typeof val == "function") { + AddonTestUtils[key] = val.bind(AddonTestUtils); + } +} + +EventEmitter.decorate(AddonTestUtils); diff --git a/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs new file mode 100644 index 0000000000..a3935a26f9 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/AddonUpdateChecker.sys.mjs @@ -0,0 +1,643 @@ +/* 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/. */ + +/** + * The AddonUpdateChecker is responsible for retrieving the update information + * from an add-on's remote update manifest. + */ + +const TIMEOUT = 60 * 1000; +const TOOLKIT_ID = "toolkit@mozilla.org"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + Blocklist: "resource://gre/modules/Blocklist.sys.mjs", + CertUtils: "resource://gre/modules/CertUtils.sys.mjs", + ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs", +}); + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const LOGGER_ID = "addons.update-checker"; + +// Create a new logger for use by the Addons Update Checker +// (Requires AddonManager.jsm) +var logger = Log.repository.getLogger(LOGGER_ID); + +/** + * Sanitizes the update URL in an update item, as returned by + * parseRDFManifest and parseJSONManifest. Ensures that: + * + * - The URL is secure, or secured by a strong enough hash. + * - The security principal of the update manifest has permission to + * load the URL. + * + * @param aUpdate + * The update item to sanitize. + * @param aRequest + * The XMLHttpRequest used to load the manifest. + * @param aHashPattern + * The regular expression used to validate the update hash. + * @param aHashString + * The human-readable string specifying which hash functions + * are accepted. + */ +function sanitizeUpdateURL(aUpdate, aRequest, aHashPattern, aHashString) { + if (aUpdate.updateURL) { + let scriptSecurity = Services.scriptSecurityManager; + let principal = scriptSecurity.getChannelURIPrincipal(aRequest.channel); + try { + // This logs an error on failure, so no need to log it a second time + scriptSecurity.checkLoadURIStrWithPrincipal( + principal, + aUpdate.updateURL, + scriptSecurity.DISALLOW_SCRIPT + ); + } catch (e) { + delete aUpdate.updateURL; + return; + } + + if ( + lazy.AddonManager.checkUpdateSecurity && + !aUpdate.updateURL.startsWith("https:") && + !aHashPattern.test(aUpdate.updateHash) + ) { + logger.warn( + `Update link ${aUpdate.updateURL} is not secure and is not verified ` + + `by a strong enough hash (needs to be ${aHashString}).` + ); + delete aUpdate.updateURL; + delete aUpdate.updateHash; + } + } +} + +/** + * Parses an JSON update manifest into an array of update objects. + * + * @param aId + * The ID of the add-on being checked for updates + * @param aRequest + * The XMLHttpRequest that has retrieved the update manifest + * @param aManifestData + * The pre-parsed manifest, as a JSON object tree + * @return an array of update objects + * @throws if the update manifest is invalid in any way + */ +function parseJSONManifest(aId, aRequest, aManifestData) { + let TYPE_CHECK = { + array: val => Array.isArray(val), + object: val => val && typeof val == "object" && !Array.isArray(val), + }; + + function getProperty(aObj, aProperty, aType, aDefault = undefined) { + if (!(aProperty in aObj)) { + return aDefault; + } + + let value = aObj[aProperty]; + + let matchesType = + aType in TYPE_CHECK ? TYPE_CHECK[aType](value) : typeof value == aType; + if (!matchesType) { + throw Components.Exception( + `Update manifest property '${aProperty}' has incorrect type (expected ${aType})` + ); + } + + return value; + } + + function getRequiredProperty(aObj, aProperty, aType) { + let value = getProperty(aObj, aProperty, aType); + if (value === undefined) { + throw Components.Exception( + `Update manifest is missing a required ${aProperty} property.` + ); + } + return value; + } + + let manifest = aManifestData; + + if (!TYPE_CHECK.object(manifest)) { + throw Components.Exception( + "Root element of update manifest must be a JSON object literal" + ); + } + + // The set of add-ons this manifest has updates for + let addons = getProperty(manifest, "addons", "object"); + + if (!addons) { + let keys = Object.keys(manifest); + if (keys.length) { + // "addons" property is optional. The presence of other properties may be + // a sign of a mistake, so print a warning to help with debugging. + logger.warn( + `Update manifest for ${aId} is missing the "addons" property, found ${keys} instead.` + ); + } else { + // If the add-on isn't listed, the update server may return an empty + // response. + logger.warn(`Received empty update manifest for ${aId}.`); + } + return []; + } + + // The entry for this particular add-on + let addon = getProperty(addons, aId, "object"); + + // A missing entry doesn't count as a failure, just as no avialable update + // information + if (!addon) { + logger.warn("Update manifest did not contain an entry for " + aId); + return []; + } + + // The list of available updates + let updates = getProperty(addon, "updates", "array", []); + + let results = []; + + for (let update of updates) { + let version = getRequiredProperty(update, "version", "string"); + + logger.debug(`Found an update entry for ${aId} version ${version}`); + + let applications = getProperty(update, "applications", "object", { + gecko: {}, + }); + + // "gecko" is currently the only supported application entry. If + // it's missing, skip this update. + if (!("gecko" in applications)) { + logger.debug( + "gecko not in application entry, skipping update of ${addon}" + ); + continue; + } + + let app = getProperty(applications, "gecko", "object"); + + let appEntry = { + id: TOOLKIT_ID, + minVersion: getProperty( + app, + "strict_min_version", + "string", + lazy.AddonManagerPrivate.webExtensionsMinPlatformVersion + ), + maxVersion: "*", + }; + + let result = { + id: aId, + version, + updateURL: getProperty(update, "update_link", "string"), + updateHash: getProperty(update, "update_hash", "string"), + updateInfoURL: getProperty(update, "update_info_url", "string"), + strictCompatibility: false, + targetApplications: [appEntry], + }; + + if ("strict_max_version" in app) { + if ("advisory_max_version" in app) { + logger.warn( + "Ignoring 'advisory_max_version' update manifest property for " + + aId + + " property since 'strict_max_version' also present" + ); + } + + appEntry.maxVersion = getProperty(app, "strict_max_version", "string"); + result.strictCompatibility = appEntry.maxVersion != "*"; + } else if ("advisory_max_version" in app) { + appEntry.maxVersion = getProperty(app, "advisory_max_version", "string"); + } + + // Add an app entry for the current API ID, too, so that it overrides any + // existing app-specific entries, which would take priority over the toolkit + // entry. + // + // Note: This currently only has any effect on legacy extensions (mainly + // those used in tests), since WebExtensions cannot yet specify app-specific + // compatibility ranges. + result.targetApplications.push( + Object.assign({}, appEntry, { id: Services.appinfo.ID }) + ); + + // The JSON update protocol requires an SHA-2 hash. RDF still + // supports SHA-1, for compatibility reasons. + sanitizeUpdateURL(result, aRequest, /^sha(256|512):/, "sha256 or sha512"); + + results.push(result); + } + return results; +} + +/** + * Starts downloading an update manifest and then passes it to an appropriate + * parser to convert to an array of update objects + * + * @param aId + * The ID of the add-on being checked for updates + * @param aUrl + * The URL of the update manifest + * @param aObserver + * An observer to pass results to + */ +function UpdateParser(aId, aUrl, aObserver) { + this.id = aId; + this.observer = aObserver; + this.url = aUrl; + + logger.debug("Requesting " + aUrl); + try { + this.request = new lazy.ServiceRequest({ mozAnon: true }); + this.request.open("GET", this.url, true); + this.request.channel.notificationCallbacks = + new lazy.CertUtils.BadCertHandler( + !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS + ); + this.request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to cache. + this.request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + this.request.overrideMimeType("text/plain"); + this.request.timeout = TIMEOUT; + this.request.addEventListener("load", () => this.onLoad()); + this.request.addEventListener("error", () => this.onError()); + this.request.addEventListener("timeout", () => this.onTimeout()); + this.request.send(null); + } catch (e) { + logger.error(`Failed to request update manifest for ${this.id}`, e); + } +} + +UpdateParser.prototype = { + id: null, + observer: null, + request: null, + url: null, + + /** + * Called when the manifest has been successfully loaded. + */ + onLoad() { + let request = this.request; + this.request = null; + this._doneAt = new Error("place holder"); + + try { + lazy.CertUtils.checkCert( + request.channel, + !lazy.AddonSettings.UPDATE_REQUIREBUILTINCERTS + ); + } catch (e) { + logger.warn("Request failed: " + this.url + " - " + e); + this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR); + return; + } + + if (!Components.isSuccessCode(request.status)) { + logger.warn("Request failed: " + this.url + " - " + request.status); + this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR); + return; + } + + let channel = request.channel; + if (channel instanceof Ci.nsIHttpChannel && !channel.requestSucceeded) { + logger.warn( + "Request failed: " + + this.url + + " - " + + channel.responseStatus + + ": " + + channel.responseStatusText + ); + this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR); + return; + } + + let results; + try { + let json = JSON.parse(request.responseText); + results = parseJSONManifest(this.id, request, json); + } catch (e) { + logger.warn( + `onUpdateCheckComplete failed to parse update manifest for ${this.id}`, + e + ); + this.notifyError(lazy.AddonManager.ERROR_PARSE_ERROR); + return; + } + + if ("onUpdateCheckComplete" in this.observer) { + try { + this.observer.onUpdateCheckComplete(results); + } catch (e) { + logger.warn( + `onUpdateCheckComplete notification failed for ${this.id}`, + e + ); + } + } else { + logger.warn( + "onUpdateCheckComplete may not properly cancel", + new Error("stack marker") + ); + } + }, + + /** + * Called when the request times out + */ + onTimeout() { + this.request = null; + this._doneAt = new Error("Timed out"); + logger.warn("Request for " + this.url + " timed out"); + this.notifyError(lazy.AddonManager.ERROR_TIMEOUT); + }, + + /** + * Called when the manifest failed to load. + */ + onError() { + if (!Components.isSuccessCode(this.request.status)) { + logger.warn("Request failed: " + this.url + " - " + this.request.status); + } else if (this.request.channel instanceof Ci.nsIHttpChannel) { + try { + if (this.request.channel.requestSucceeded) { + logger.warn( + "Request failed: " + + this.url + + " - " + + this.request.channel.responseStatus + + ": " + + this.request.channel.responseStatusText + ); + } + } catch (e) { + logger.warn("HTTP Request failed for an unknown reason"); + } + } else { + logger.warn("Request failed for an unknown reason"); + } + + this.request = null; + this._doneAt = new Error("UP_onError"); + + this.notifyError(lazy.AddonManager.ERROR_DOWNLOAD_ERROR); + }, + + /** + * Helper method to notify the observer that an error occurred. + */ + notifyError(aStatus) { + if ("onUpdateCheckError" in this.observer) { + try { + this.observer.onUpdateCheckError(aStatus); + } catch (e) { + logger.warn("onUpdateCheckError notification failed", e); + } + } + }, + + /** + * Called to cancel an in-progress update check. + */ + cancel() { + if (!this.request) { + logger.error("Trying to cancel already-complete request", this._doneAt); + return; + } + this.request.abort(); + this.request = null; + this._doneAt = new Error("UP_cancel"); + this.notifyError(lazy.AddonManager.ERROR_CANCELLED); + }, +}; + +/** + * Tests if an update matches a version of the application or platform + * + * @param aUpdate + * The available update + * @param aAppVersion + * The application version to use + * @param aPlatformVersion + * The platform version to use + * @param aIgnoreMaxVersion + * Ignore maxVersion when testing if an update matches. Optional. + * @param aIgnoreStrictCompat + * Ignore strictCompatibility when testing if an update matches. Optional. + * @return true if the update is compatible with the application/platform + */ +function matchesVersions( + aUpdate, + aAppVersion, + aPlatformVersion, + aIgnoreMaxVersion, + aIgnoreStrictCompat +) { + if (aUpdate.strictCompatibility && !aIgnoreStrictCompat) { + aIgnoreMaxVersion = false; + } + + let result = false; + for (let app of aUpdate.targetApplications) { + if (app.id == Services.appinfo.ID) { + return ( + Services.vc.compare(aAppVersion, app.minVersion) >= 0 && + (aIgnoreMaxVersion || + Services.vc.compare(aAppVersion, app.maxVersion) <= 0) + ); + } + if (app.id == TOOLKIT_ID) { + result = + Services.vc.compare(aPlatformVersion, app.minVersion) >= 0 && + (aIgnoreMaxVersion || + Services.vc.compare(aPlatformVersion, app.maxVersion) <= 0); + } + } + return result; +} + +export var AddonUpdateChecker = { + /** + * Retrieves the best matching compatibility update for the application from + * a list of available update objects. + * + * @param aUpdates + * An array of update objects + * @param aVersion + * The version of the add-on to get new compatibility information for + * @param aIgnoreCompatibility + * An optional parameter to get the first compatibility update that + * is compatible with any version of the application or toolkit + * @param aAppVersion + * The version of the application or null to use the current version + * @param aPlatformVersion + * The version of the platform or null to use the current version + * @param aIgnoreMaxVersion + * Ignore maxVersion when testing if an update matches. Optional. + * @param aIgnoreStrictCompat + * Ignore strictCompatibility when testing if an update matches. Optional. + * @return an update object if one matches or null if not + */ + getCompatibilityUpdate( + aUpdates, + aVersion, + aIgnoreCompatibility, + aAppVersion, + aPlatformVersion, + aIgnoreMaxVersion, + aIgnoreStrictCompat + ) { + if (!aAppVersion) { + aAppVersion = Services.appinfo.version; + } + if (!aPlatformVersion) { + aPlatformVersion = Services.appinfo.platformVersion; + } + + for (let update of aUpdates) { + if (Services.vc.compare(update.version, aVersion) == 0) { + if (aIgnoreCompatibility) { + for (let targetApp of update.targetApplications) { + let id = targetApp.id; + if (id == Services.appinfo.ID || id == TOOLKIT_ID) { + return update; + } + } + } else if ( + matchesVersions( + update, + aAppVersion, + aPlatformVersion, + aIgnoreMaxVersion, + aIgnoreStrictCompat + ) + ) { + return update; + } + } + } + return null; + }, + + /** + * Asynchronously returns the newest available update from a list of update objects. + * + * @param aUpdates + * An array of update objects + * @param aAddon + * The add-on that is being updated. + * @param aAppVersion + * The version of the application or null to use the current version + * @param aPlatformVersion + * The version of the platform or null to use the current version + * @param aIgnoreMaxVersion + * When determining compatible updates, ignore maxVersion. Optional. + * @param aIgnoreStrictCompat + * When determining compatible updates, ignore strictCompatibility. Optional. + * @return an update object if one matches or null if not + */ + async getNewestCompatibleUpdate( + aUpdates, + aAddon, + aAppVersion, + aPlatformVersion, + aIgnoreMaxVersion, + aIgnoreStrictCompat + ) { + if (!aAppVersion) { + aAppVersion = Services.appinfo.version; + } + if (!aPlatformVersion) { + aPlatformVersion = Services.appinfo.platformVersion; + } + + let newestVersion = aAddon.version; + let newest = null; + let blocked = null; + let blockedState; + for (let update of aUpdates) { + if (!update.updateURL) { + continue; + } + if (Services.vc.compare(newestVersion, update.version) >= 0) { + // Update older than add-on version or older than previous result. + continue; + } + if ( + !matchesVersions( + update, + aAppVersion, + aPlatformVersion, + aIgnoreMaxVersion, + aIgnoreStrictCompat + ) + ) { + continue; + } + let state = await lazy.Blocklist.getAddonBlocklistState( + update, + aAppVersion, + aPlatformVersion + ); + if (state != Ci.nsIBlocklistService.STATE_NOT_BLOCKED) { + if ( + !blocked || + Services.vc.compare(blocked.version, update.version) < 0 + ) { + blocked = update; + blockedState = state; + } + continue; + } + newest = update; + newestVersion = update.version; + } + if ( + blocked && + (!newest || Services.vc.compare(blocked.version, newestVersion) >= 0) + ) { + // If |newest| has a higher version than |blocked|, then the add-on would + // not be considered for installation. But if |blocked| would otherwise + // be eligible for installation, then report to telemetry that installation + // has been blocked because of the blocklist. + lazy.Blocklist.recordAddonBlockChangeTelemetry( + { + id: aAddon.id, + version: blocked.version, + blocklistState: blockedState, + }, + "addon_update_check" + ); + } + return newest; + }, + + /** + * Starts an update check. + * + * @param aId + * The ID of the add-on being checked for updates + * @param aUrl + * The URL of the add-on's update manifest + * @param aObserver + * An observer to notify of results + * @return UpdateParser so that the caller can use UpdateParser.cancel() to shut + * down in-progress update requests + */ + checkForUpdates(aId, aUrl, aObserver) { + return new UpdateParser(aId, aUrl, aObserver); + }, +}; diff --git a/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs new file mode 100644 index 0000000000..aaac109fc0 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/GMPProvider.sys.mjs @@ -0,0 +1,934 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", + GMPInstallManager: "resource://gre/modules/GMPInstallManager.sys.mjs", + Log: "resource://gre/modules/Log.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +import { + GMPPrefs, + GMPUtils, + OPEN_H264_ID, + WIDEVINE_L1_ID, + WIDEVINE_L3_ID, +} from "resource://gre/modules/GMPUtils.sys.mjs"; + +const SEC_IN_A_DAY = 24 * 60 * 60; +// How long to wait after a user enabled EME before attempting to download CDMs. +const GMP_CHECK_DELAY = 10 * 1000; // milliseconds + +const XHTML = "http://www.w3.org/1999/xhtml"; + +const NS_GRE_DIR = "GreD"; +const CLEARKEY_PLUGIN_ID = "gmp-clearkey"; +const CLEARKEY_VERSION = "0.1"; + +const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created"; + +const GMP_LICENSE_INFO = "plugins-gmp-license-info"; +const GMP_PRIVACY_INFO = "plugins-gmp-privacy-info"; +const GMP_LEARN_MORE = "learn_more_label"; + +const GMP_PLUGINS = [ + { + id: OPEN_H264_ID, + name: "plugins-openh264-name", + description: "plugins-openh264-description", + level: "", + libName: "gmpopenh264", + // The following licenseURL is part of an awful hack to include the OpenH264 + // license without having bug 624602 fixed yet, and intentionally ignores + // localisation. + licenseURL: "chrome://mozapps/content/extensions/OpenH264-license.txt", + homepageURL: "https://www.openh264.org/", + }, + { + id: WIDEVINE_L1_ID, + name: "plugins-widevine-name", + description: "plugins-widevine-description", + level: "L1", + libName: "Google.Widevine.CDM", + licenseURL: "https://www.google.com/policies/privacy/", + homepageURL: "https://www.widevine.com/", + isEME: true, + }, + { + id: WIDEVINE_L3_ID, + name: "plugins-widevine-name", + description: "plugins-widevine-description", + level: "L3", + libName: "widevinecdm", + licenseURL: "https://www.google.com/policies/privacy/", + homepageURL: "https://www.widevine.com/", + isEME: true, + }, +]; + +ChromeUtils.defineLazyGetter( + lazy, + "addonsBundle", + () => new Localization(["toolkit/about/aboutAddons.ftl"], true) +); +ChromeUtils.defineLazyGetter(lazy, "gmpService", () => + Cc["@mozilla.org/gecko-media-plugin-service;1"].getService( + Ci.mozIGeckoMediaPluginChromeService + ) +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "gmpProviderEnabled", + GMPPrefs.KEY_PROVIDER_ENABLED +); + +var gLogger; +var gLogAppenderDump = null; + +function configureLogging() { + if (!gLogger) { + gLogger = lazy.Log.repository.getLogger("Toolkit.GMP"); + gLogger.addAppender( + new lazy.Log.ConsoleAppender(new lazy.Log.BasicFormatter()) + ); + } + gLogger.level = GMPPrefs.getInt( + GMPPrefs.KEY_LOGGING_LEVEL, + lazy.Log.Level.Warn + ); + + let logDumping = GMPPrefs.getBool(GMPPrefs.KEY_LOGGING_DUMP, false); + if (logDumping != !!gLogAppenderDump) { + if (logDumping) { + gLogAppenderDump = new lazy.Log.DumpAppender( + new lazy.Log.BasicFormatter() + ); + gLogger.addAppender(gLogAppenderDump); + } else { + gLogger.removeAppender(gLogAppenderDump); + gLogAppenderDump = null; + } + } +} + +/** + * The GMPWrapper provides the info for the various GMP plugins to public + * callers through the API. + */ +function GMPWrapper(aPluginInfo, aRawPluginInfo) { + this._plugin = aPluginInfo; + this._rawPlugin = aRawPluginInfo; + this._log = lazy.Log.repository.getLoggerWithMessagePrefix( + "Toolkit.GMP", + "GMPWrapper(" + this._plugin.id + ") " + ); + Services.prefs.addObserver( + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id), + this, + true + ); + Services.prefs.addObserver( + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id), + this, + true + ); + if (this._plugin.isEME) { + Services.prefs.addObserver(GMPPrefs.KEY_EME_ENABLED, this, true); + Services.obs.addObserver(this, "EMEVideo:CDMMissing"); + } +} + +GMPWrapper.prototype = { + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + // An active task that checks for plugin updates and installs them. + _updateTask: null, + _gmpPath: null, + _isUpdateCheckPending: false, + + set gmpPath(aPath) { + this._gmpPath = aPath; + }, + get gmpPath() { + if (!this._gmpPath && this.isInstalled) { + this._gmpPath = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + this._plugin.id, + GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id) + ); + } + return this._gmpPath; + }, + + get id() { + return this._plugin.id; + }, + get libName() { + return this._plugin.libName; + }, + get type() { + return "plugin"; + }, + get isGMPlugin() { + return true; + }, + get name() { + return this._plugin.name; + }, + get creator() { + return null; + }, + get homepageURL() { + return this._plugin.homepageURL; + }, + + get description() { + return this._plugin.description; + }, + get fullDescription() { + return null; + }, + + getFullDescription(doc) { + let plugin = this._rawPlugin; + + let frag = doc.createDocumentFragment(); + for (let [urlProp, labelId] of [ + ["learnMoreURL", GMP_LEARN_MORE], + [ + "licenseURL", + this.id == WIDEVINE_L1_ID || this.id == WIDEVINE_L3_ID + ? GMP_PRIVACY_INFO + : GMP_LICENSE_INFO, + ], + ]) { + if (plugin[urlProp]) { + let a = doc.createElementNS(XHTML, "a"); + a.href = plugin[urlProp]; + a.target = "_blank"; + a.textContent = lazy.addonsBundle.formatValueSync(labelId); + + if (frag.childElementCount) { + frag.append( + doc.createElementNS(XHTML, "br"), + doc.createElementNS(XHTML, "br") + ); + } + frag.append(a); + } + } + + return frag; + }, + + get version() { + return GMPPrefs.getString( + GMPPrefs.KEY_PLUGIN_VERSION, + null, + this._plugin.id + ); + }, + + get isActive() { + return ( + !this.appDisabled && + !this.userDisabled && + !GMPUtils.isPluginHidden(this._plugin) + ); + }, + get appDisabled() { + if ( + this._plugin.isEME && + !GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true) + ) { + // If "media.eme.enabled" is false, all EME plugins are disabled. + return true; + } + return false; + }, + + get userDisabled() { + return !GMPPrefs.getBool( + GMPPrefs.KEY_PLUGIN_ENABLED, + true, + this._plugin.id + ); + }, + set userDisabled(aVal) { + GMPPrefs.setBool( + GMPPrefs.KEY_PLUGIN_ENABLED, + aVal === false, + this._plugin.id + ); + }, + + async enable() { + this.userDisabled = false; + }, + async disable() { + this.userDisabled = true; + }, + + get blocklistState() { + return Ci.nsIBlocklistService.STATE_NOT_BLOCKED; + }, + get size() { + return 0; + }, + get scope() { + return lazy.AddonManager.SCOPE_APPLICATION; + }, + get pendingOperations() { + return lazy.AddonManager.PENDING_NONE; + }, + + get operationsRequiringRestart() { + return lazy.AddonManager.OP_NEEDS_RESTART_NONE; + }, + + get permissions() { + let permissions = 0; + if (!this.appDisabled) { + permissions |= lazy.AddonManager.PERM_CAN_UPGRADE; + permissions |= this.userDisabled + ? lazy.AddonManager.PERM_CAN_ENABLE + : lazy.AddonManager.PERM_CAN_DISABLE; + } + return permissions; + }, + + get updateDate() { + let time = Number( + GMPPrefs.getInt(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, 0, this._plugin.id) + ); + if (this.isInstalled) { + return new Date(time * 1000); + } + return null; + }, + + get isCompatible() { + return true; + }, + + get isPlatformCompatible() { + return true; + }, + + get providesUpdatesSecurely() { + return true; + }, + + get foreignInstall() { + return false; + }, + + get installTelemetryInfo() { + return { source: "gmp-plugin" }; + }, + + isCompatibleWith(aAppVersion, aPlatformVersion) { + return true; + }, + + get applyBackgroundUpdates() { + if (!GMPPrefs.isSet(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id)) { + return lazy.AddonManager.AUTOUPDATE_DEFAULT; + } + + return GMPPrefs.getBool( + GMPPrefs.KEY_PLUGIN_AUTOUPDATE, + true, + this._plugin.id + ) + ? lazy.AddonManager.AUTOUPDATE_ENABLE + : lazy.AddonManager.AUTOUPDATE_DISABLE; + }, + + set applyBackgroundUpdates(aVal) { + if (aVal == lazy.AddonManager.AUTOUPDATE_DEFAULT) { + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, this._plugin.id); + } else if (aVal == lazy.AddonManager.AUTOUPDATE_ENABLE) { + GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, true, this._plugin.id); + } else if (aVal == lazy.AddonManager.AUTOUPDATE_DISABLE) { + GMPPrefs.setBool(GMPPrefs.KEY_PLUGIN_AUTOUPDATE, false, this._plugin.id); + } + }, + + /** + * Called by the addon manager to update GMP addons. For example this will be + * used if a user manually checks for GMP plugin updates by using the + * menu in about:addons. + * + * This function is not used if MediaKeySystemAccess is requested and + * Widevine is not yet installed, or if the user toggles prefs to enable EME. + * For the function used in those cases see `checkForUpdates`. + */ + findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) { + this._log.trace( + "findUpdates() - " + this._plugin.id + " - reason=" + aReason + ); + + // In the case of GMP addons we do not wish to implement AddonInstall, as + // we don't want to display information as in a normal addon install such + // as a download progress bar. As such, we short circuit our + // listeners by indicating that no updates exist (though some may). + lazy.AddonManagerPrivate.callNoUpdateListeners(this, aListener); + + if (aReason === lazy.AddonManager.UPDATE_WHEN_PERIODIC_UPDATE) { + if (!lazy.AddonManager.shouldAutoUpdate(this)) { + this._log.trace( + "findUpdates() - " + this._plugin.id + " - no autoupdate" + ); + return Promise.resolve(false); + } + + let secSinceLastCheck = + Date.now() / 1000 - + Services.prefs.getIntPref(GMPPrefs.KEY_UPDATE_LAST_CHECK, 0); + if (secSinceLastCheck <= SEC_IN_A_DAY) { + this._log.trace( + "findUpdates() - " + + this._plugin.id + + " - last check was less then a day ago" + ); + return Promise.resolve(false); + } + } else if (aReason !== lazy.AddonManager.UPDATE_WHEN_USER_REQUESTED) { + this._log.trace( + "findUpdates() - " + + this._plugin.id + + " - the given reason to update is not supported" + ); + return Promise.resolve(false); + } + + if (this._updateTask !== null) { + this._log.trace( + "findUpdates() - " + this._plugin.id + " - update task already running" + ); + return this._updateTask; + } + + this._updateTask = (async () => { + this._log.trace("findUpdates() - updateTask"); + try { + let installManager = new lazy.GMPInstallManager(); + let res = await installManager.checkForAddons(); + let update = res.addons.find(addon => addon.id === this._plugin.id); + if (update && update.isValid && !update.isInstalled) { + this._log.trace( + "findUpdates() - found update for " + + this._plugin.id + + ", installing" + ); + await installManager.installAddon(update); + } else { + this._log.trace("findUpdates() - no updates for " + this._plugin.id); + } + this._log.info( + "findUpdates() - updateTask succeeded for " + this._plugin.id + ); + } catch (e) { + this._log.error( + "findUpdates() - updateTask for " + this._plugin.id + " threw", + e + ); + throw e; + } finally { + this._updateTask = null; + } + return true; + })(); + + return this._updateTask; + }, + + get pluginLibraries() { + if (this.isInstalled) { + let path = this.version; + return [path]; + } + return []; + }, + get pluginFullpath() { + if (this.isInstalled) { + let path = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + this._plugin.id, + this.version + ); + return [path]; + } + return []; + }, + + get isInstalled() { + return this.version && !!this.version.length; + }, + + _handleEnabledChanged() { + this._log.info( + "_handleEnabledChanged() id=" + + this._plugin.id + + " isActive=" + + this.isActive + ); + + lazy.AddonManagerPrivate.callAddonListeners( + this.isActive ? "onEnabling" : "onDisabling", + this, + false + ); + if (this._gmpPath) { + if (this.isActive) { + this._log.info( + "onPrefEnabledChanged() - adding gmp directory " + this._gmpPath + ); + lazy.gmpService.addPluginDirectory(this._gmpPath); + } else { + this._log.info( + "onPrefEnabledChanged() - removing gmp directory " + this._gmpPath + ); + lazy.gmpService.removePluginDirectory(this._gmpPath); + } + } + lazy.AddonManagerPrivate.callAddonListeners( + this.isActive ? "onEnabled" : "onDisabled", + this + ); + }, + + onPrefEMEGlobalEnabledChanged() { + this._log.info( + "onPrefEMEGlobalEnabledChanged() id=" + + this._plugin.id + + " appDisabled=" + + this.appDisabled + + " isActive=" + + this.isActive + + " hidden=" + + GMPUtils.isPluginHidden(this._plugin) + ); + + lazy.AddonManagerPrivate.callAddonListeners("onPropertyChanged", this, [ + "appDisabled", + ]); + // If EME or the GMP itself are disabled, uninstall the GMP. + // Otherwise, check for updates, so we download and install the GMP. + if (this.appDisabled) { + this.uninstallPlugin(); + } else if (!GMPUtils.isPluginHidden(this._plugin)) { + lazy.AddonManagerPrivate.callInstallListeners( + "onExternalInstall", + null, + this, + null, + false + ); + lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false); + lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this); + this.checkForUpdates(GMP_CHECK_DELAY); + } + if (!this.userDisabled) { + this._handleEnabledChanged(); + } + }, + + /** + * This is called if prefs are changed to enable EME, or if Widevine + * MediaKeySystemAccess is requested but the Widevine CDM is not installed. + * + * For the function used by the addon manager see `findUpdates`. + */ + checkForUpdates(delay) { + if (this._isUpdateCheckPending) { + return; + } + this._isUpdateCheckPending = true; + GMPPrefs.reset(GMPPrefs.KEY_UPDATE_LAST_CHECK, null); + // Delay this in case the user changes his mind and doesn't want to + // enable EME after all. + lazy.setTimeout(() => { + if (!this.appDisabled) { + let gmpInstallManager = new lazy.GMPInstallManager(); + // We don't really care about the results, if someone is interested + // they can check the log. + gmpInstallManager.simpleCheckAndInstall().catch(() => {}); + } + this._isUpdateCheckPending = false; + }, delay); + }, + + onPrefEnabledChanged() { + if (!this._plugin.isEME || !this.appDisabled) { + this._handleEnabledChanged(); + } + }, + + onPrefVersionChanged() { + lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false); + if (this._gmpPath) { + this._log.info( + "onPrefVersionChanged() - unregistering gmp directory " + this._gmpPath + ); + lazy.gmpService.removeAndDeletePluginDirectory( + this._gmpPath, + true /* can defer */ + ); + } + lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this); + + lazy.AddonManagerPrivate.callInstallListeners( + "onExternalInstall", + null, + this, + null, + false + ); + lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this, false); + this._gmpPath = null; + if (this.isInstalled) { + this._gmpPath = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + this._plugin.id, + GMPPrefs.getString(GMPPrefs.KEY_PLUGIN_VERSION, null, this._plugin.id) + ); + } + if (this._gmpPath && this.isActive) { + this._log.info( + "onPrefVersionChanged() - registering gmp directory " + this._gmpPath + ); + lazy.gmpService.addPluginDirectory(this._gmpPath); + } + lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this); + }, + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + let pref = data; + if ( + pref == + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id) + ) { + this.onPrefEnabledChanged(); + } else if ( + pref == + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id) + ) { + this.onPrefVersionChanged(); + } else if (pref == GMPPrefs.KEY_EME_ENABLED) { + this.onPrefEMEGlobalEnabledChanged(); + } + } else if (topic == "EMEVideo:CDMMissing") { + this.checkForUpdates(0); + } + }, + + uninstallPlugin() { + lazy.AddonManagerPrivate.callAddonListeners("onUninstalling", this, false); + if (this.gmpPath) { + this._log.info( + "uninstallPlugin() - unregistering gmp directory " + this.gmpPath + ); + lazy.gmpService.removeAndDeletePluginDirectory(this.gmpPath); + } + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_VERSION, this.id); + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_HASHVALUE, this.id); + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_ABI, this.id); + GMPPrefs.reset(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, this.id); + lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this); + }, + + shutdown() { + Services.prefs.removeObserver( + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_ENABLED, this._plugin.id), + this + ); + Services.prefs.removeObserver( + GMPPrefs.getPrefKey(GMPPrefs.KEY_PLUGIN_VERSION, this._plugin.id), + this + ); + if (this._plugin.isEME) { + Services.prefs.removeObserver(GMPPrefs.KEY_EME_ENABLED, this); + Services.obs.removeObserver(this, "EMEVideo:CDMMissing"); + } + return this._updateTask; + }, + + _arePluginFilesOnDisk() { + let fileExists = function (aGmpPath, aFileName) { + let f = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + let path = PathUtils.join(aGmpPath, aFileName); + f.initWithPath(path); + return f.exists(); + }; + + let libName = + AppConstants.DLL_PREFIX + this._plugin.libName + AppConstants.DLL_SUFFIX; + let infoName; + if ( + this._plugin.id == WIDEVINE_L1_ID || + this._plugin.id == WIDEVINE_L3_ID + ) { + infoName = "manifest.json"; + } else { + infoName = this._plugin.id.substring(4) + ".info"; + } + + return ( + fileExists(this.gmpPath, libName) && fileExists(this.gmpPath, infoName) + ); + }, + + validate() { + if (!this.isInstalled) { + // Not installed -> Valid. + return { + installed: false, + valid: true, + }; + } + + let expectedABI = GMPUtils._expectedABI(this._plugin); + let abi = GMPPrefs.getString( + GMPPrefs.KEY_PLUGIN_ABI, + expectedABI, + this._plugin.id + ); + if (abi != expectedABI) { + // ABI doesn't match. Possibly this is a profile migrated across platforms + // or from 32 -> 64 bit. + return { + installed: true, + mismatchedABI: true, + valid: false, + }; + } + + // Installed -> Check if files are missing. + let filesOnDisk = this._arePluginFilesOnDisk(); + return { + installed: true, + valid: filesOnDisk, + }; + }, +}; + +var GMPProvider = { + get name() { + return "GMPProvider"; + }, + + _plugins: null, + + startup() { + configureLogging(); + this._log = lazy.Log.repository.getLoggerWithMessagePrefix( + "Toolkit.GMP", + "GMPProvider." + ); + this.buildPluginList(); + this.ensureProperCDMInstallState(); + + Services.prefs.addObserver(GMPPrefs.KEY_LOG_BASE, configureLogging); + + for (let plugin of this._plugins.values()) { + let wrapper = plugin.wrapper; + let gmpPath = wrapper.gmpPath; + let isEnabled = wrapper.isActive; + this._log.trace( + "startup - enabled=" + isEnabled + ", gmpPath=" + gmpPath + ); + + if (gmpPath && isEnabled) { + let validation = wrapper.validate(); + if (validation.mismatchedABI) { + this._log.info( + "startup - gmp " + plugin.id + " mismatched ABI, uninstalling" + ); + wrapper.uninstallPlugin(); + continue; + } + if (!validation.valid) { + this._log.info( + "startup - gmp " + plugin.id + " invalid, uninstalling" + ); + wrapper.uninstallPlugin(); + continue; + } + this._log.info("startup - adding gmp directory " + gmpPath); + try { + lazy.gmpService.addPluginDirectory(gmpPath); + } catch (e) { + if (e.name != "NS_ERROR_NOT_AVAILABLE") { + throw e; + } + this._log.warn( + "startup - adding gmp directory failed with " + + e.name + + " - sandboxing not available?", + e + ); + } + } + } + + try { + let greDir = Services.dirsvc.get(NS_GRE_DIR, Ci.nsIFile); + let path = greDir.path; + if ( + GMPUtils._isWindowsOnARM64() && + GMPPrefs.getBool( + GMPPrefs.KEY_PLUGIN_ALLOW_X64_ON_ARM64, + true, + CLEARKEY_PLUGIN_ID + ) + ) { + path = PathUtils.join(path, "i686"); + } + let clearkeyPath = PathUtils.join( + path, + CLEARKEY_PLUGIN_ID, + CLEARKEY_VERSION + ); + this._log.info("startup - adding clearkey CDM directory " + clearkeyPath); + lazy.gmpService.addPluginDirectory(clearkeyPath); + } catch (e) { + this._log.warn("startup - adding clearkey CDM failed", e); + } + }, + + shutdown() { + this._log.trace("shutdown"); + Services.prefs.removeObserver(GMPPrefs.KEY_LOG_BASE, configureLogging); + + let shutdownTask = (async () => { + this._log.trace("shutdown - shutdownTask"); + let shutdownSucceeded = true; + + for (let plugin of this._plugins.values()) { + try { + await plugin.wrapper.shutdown(); + } catch (e) { + shutdownSucceeded = false; + } + } + + this._plugins = null; + + if (!shutdownSucceeded) { + throw new Error("Shutdown failed"); + } + })(); + + return shutdownTask; + }, + + async getAddonByID(aId) { + if (!this.isEnabled) { + return null; + } + + let plugin = this._plugins.get(aId); + if (plugin && !GMPUtils.isPluginHidden(plugin)) { + return plugin.wrapper; + } + return null; + }, + + async getAddonsByTypes(aTypes) { + if (!this.isEnabled || (aTypes && !aTypes.includes("plugin"))) { + return []; + } + + let results = Array.from(this._plugins.values()) + .filter(p => !GMPUtils.isPluginHidden(p)) + .map(p => p.wrapper); + + return results; + }, + + get isEnabled() { + return lazy.gmpProviderEnabled; + }, + + buildPluginList() { + this._plugins = new Map(); + for (let aPlugin of GMP_PLUGINS) { + let plugin = { + id: aPlugin.id, + name: lazy.addonsBundle.formatValueSync(aPlugin.name), + description: lazy.addonsBundle.formatValueSync(aPlugin.description), + libName: aPlugin.libName, + homepageURL: aPlugin.homepageURL, + optionsURL: aPlugin.optionsURL, + wrapper: null, + isEME: aPlugin.isEME, + }; + plugin.wrapper = new GMPWrapper(plugin, aPlugin); + this._plugins.set(plugin.id, plugin); + } + }, + + ensureProperCDMInstallState() { + if (!GMPPrefs.getBool(GMPPrefs.KEY_EME_ENABLED, true)) { + for (let plugin of this._plugins.values()) { + if (plugin.isEME && plugin.wrapper.isInstalled) { + lazy.gmpService.addPluginDirectory(plugin.wrapper.gmpPath); + plugin.wrapper.uninstallPlugin(); + } + } + } + }, + + observe(subject, topic, data) { + if (topic == FIRST_CONTENT_PROCESS_TOPIC) { + lazy.AddonManagerPrivate.registerProvider(GMPProvider, ["plugin"]); + Services.obs.notifyObservers(null, "gmp-provider-registered"); + + Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC); + } + }, + + addObserver() { + Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC); + }, +}; + +GMPProvider.addObserver(); + +// For test use only. +export const GMPTestUtils = { + /** + * Used to override the GMP service with a mock. + * + * @param {object} mockService + * The mocked gmpService object. + * @param {function} callback + * Method called with the overridden gmpService. The override + * is undone after the callback returns. + */ + async overrideGmpService(mockService, callback) { + let originalGmpService = lazy.gmpService; + lazy.gmpService = mockService; + try { + return await callback(); + } finally { + lazy.gmpService = originalGmpService; + } + }, +}; diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs new file mode 100644 index 0000000000..1615a551c8 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.sys.mjs @@ -0,0 +1,601 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Log } from "resource://gre/modules/Log.sys.mjs"; +import { CertUtils } from "resource://gre/modules/CertUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ServiceRequest: "resource://gre/modules/ServiceRequest.sys.mjs", +}); + +// This will inherit settings from the "addons" logger. +var logger = Log.repository.getLogger("addons.productaddons"); +// We want to set the level of this logger independent from its parent to help +// debug things like GMP updates. Link this to its own level pref. +logger.manageLevelFromPref("extensions.logging.productaddons.level"); + +/** + * Number of milliseconds after which we need to cancel `downloadXMLWithRequest` + * and `conservativeFetch`. + * + * Bug 1087674 suggests that the XHR/ServiceRequest we use in + * `downloadXMLWithRequest` may never terminate in presence of network nuisances + * (e.g. strange antivirus behavior). This timeout is a defensive measure to + * ensure that we fail cleanly in such case. + */ +const TIMEOUT_DELAY_MS = 20000; + +/** + * Gets the status of an XMLHttpRequest either directly or from its underlying + * channel. + * + * @param request + * The XMLHttpRequest. + * @returns {Object} result - An object containing the results. + * @returns {integer} result.status - Request status code, if available, else the channel nsresult. + * @returns {integer} result.channelStatus - Channel nsresult. + * @returns {integer} result.errorCode - Request error code. + */ +function getRequestStatus(request) { + let status = null; + let errorCode = null; + let channelStatus = null; + + try { + status = request.status; + } catch (e) {} + try { + errorCode = request.errorCode; + } catch (e) {} + try { + channelStatus = request.channel.QueryInterface(Ci.nsIRequest).status; + } catch (e) {} + + if (status == null) { + status = channelStatus; + } + + return { status, channelStatus, errorCode }; +} + +/** + * A wrapper around `ServiceRequest` that behaves like a limited `fetch()`. + * This doesn't handle headers like fetch, but can be expanded as callers need. + * + * Use this in order to leverage the `beConservative` flag, for + * example to avoid using HTTP3 to fetch critical data. + * + * @param input a resource + * @returns a Response object + */ +async function conservativeFetch(input) { + return new Promise(function (resolve, reject) { + const request = new lazy.ServiceRequest({ mozAnon: true }); + request.timeout = TIMEOUT_DELAY_MS; + + request.onerror = () => { + let err = new TypeError("NetworkError: Network request failed"); + err.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR; + reject(err); + }; + request.ontimeout = () => { + let err = new TypeError("Timeout: Network request failed"); + err.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR; + reject(err); + }; + request.onabort = () => { + let err = new DOMException("Aborted", "AbortError"); + err.addonCheckerErr = ProductAddonChecker.ABORT_ERR; + reject(err); + }; + request.onload = () => { + const responseAttributes = { + status: request.status, + statusText: request.statusText, + url: request.responseURL, + }; + resolve(new Response(request.response, responseAttributes)); + }; + + const method = "GET"; + + request.open(method, input, true); + + request.send(); + }); +} + +/** + * Verifies the content signature on GMP's update.xml. When we fetch update.xml + * balrog should send back content signature headers, which this function + * is used to verify. + * + * @param data + * The data received from balrog. I.e. the xml contents of update.xml. + * @param contentSignatureHeader + * The contents of the 'content-signature' header received along with + * `data`. + * @return A promise that will resolve to nothing if the signature verification + * succeeds, or rejects on failure, with an Error that sets its + * addonCheckerErr property disambiguate failure cases and a message + * explaining the error. + */ +async function verifyGmpContentSignature(data, contentSignatureHeader) { + if (!contentSignatureHeader) { + logger.warn( + "Unexpected missing content signature header during content signature validation" + ); + let err = new Error( + "Content signature validation failed: missing content signature header" + ); + err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR; + throw err; + } + // Split out the header. It should contain a the following fields, separated by a semicolon + // - x5u - a URI to the cert chain. See also https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.5 + // - p384ecdsa - the signature to verify. See also https://github.com/mozilla-services/autograph/blob/main/signer/contentsignaturepki/README.md + const headerFields = contentSignatureHeader + .split(";") // Split fields... + .map(s => s.trim()) // Remove whitespace... + .map(s => [ + // Break each field into it's name and value. This more verbose version is + // used instead of `split()` to handle values that contain = characters. This + // shouldn't happen for the signature because it's base64_url (no = padding), + // but it's not clear if it's possible for the x5u URL (as part of a query). + // Guard anyway, better safe than sorry. + s.substring(0, s.indexOf("=")), // Get field name... + s.substring(s.indexOf("=") + 1), // and field value. + ]); + + let x5u; + let signature; + for (const [fieldName, fieldValue] of headerFields) { + if (fieldName == "x5u") { + x5u = fieldValue; + } else if (fieldName == "p384ecdsa") { + // The signature needs to contain 'p384ecdsa', so stich it back together. + signature = `p384ecdsa=${fieldValue}`; + } + } + + if (!x5u) { + logger.warn("Unexpected missing x5u during content signature validation"); + let err = Error("Content signature validation failed: missing x5u"); + err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR; + throw err; + } + + if (!signature) { + logger.warn( + "Unexpected missing signature during content signature validation" + ); + let err = Error("Content signature validation failed: missing signature"); + err.addonCheckerErr = ProductAddonChecker.VERIFICATION_MISSING_DATA_ERR; + throw err; + } + + // The x5u field should contain the location of the cert chain, fetch it. + // Use `conservativeFetch` so we get conservative behaviour and ensure (more) + // reliable fetching. + const certChain = await (await conservativeFetch(x5u)).text(); + + const verifier = Cc[ + "@mozilla.org/security/contentsignatureverifier;1" + ].createInstance(Ci.nsIContentSignatureVerifier); + + // See bug 1771992. In the future, this may need to handle staging and dev + // environments in addition to just production and testing. + let root = Ci.nsIContentSignatureVerifier.ContentSignatureProdRoot; + if (Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + root = Ci.nsIX509CertDB.AppXPCShellRoot; + } + + let valid; + try { + valid = await verifier.asyncVerifyContentSignature( + data, + signature, + certChain, + "aus.content-signature.mozilla.org", + root + ); + } catch (err) { + logger.warn(`Unexpected error while validating content signature: ${err}`); + let newErr = new Error(`Content signature validation failed: ${err}`); + newErr.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR; + throw newErr; + } + + if (!valid) { + logger.warn("Unexpected invalid content signature found during validation"); + let err = new Error("Content signature is not valid"); + err.addonCheckerErr = ProductAddonChecker.VERIFICATION_INVALID_ERR; + throw err; + } +} + +/** + * Downloads an XML document from a URL optionally testing the SSL certificate + * for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to the ServiceRequest request on success or + * rejects with a JS exception in case of error. + */ +function downloadXMLWithRequest( + url, + allowNonBuiltIn = false, + allowedCerts = null +) { + return new Promise((resolve, reject) => { + let request = new lazy.ServiceRequest(); + // This is here to let unit test code override the ServiceRequest. + if (request.wrappedJSObject) { + request = request.wrappedJSObject; + } + request.open("GET", url, true); + request.channel.notificationCallbacks = new CertUtils.BadCertHandler( + allowNonBuiltIn + ); + // Prevent the request from reading from the cache. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + // Don't send any cookies + request.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS; + request.timeout = TIMEOUT_DELAY_MS; + + request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + request.setRequestHeader("Pragma", "no-cache"); + + let fail = event => { + let request = event.target; + let status = getRequestStatus(request); + let message = + "Failed downloading XML, status: " + + status.status + + ", channelStatus: " + + status.channelStatus + + ", errorCode: " + + status.errorCode + + ", reason: " + + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status.status; + if (event.type == "error") { + ex.addonCheckerErr = ProductAddonChecker.NETWORK_REQUEST_ERR; + } else if (event.type == "abort") { + ex.addonCheckerErr = ProductAddonChecker.ABORT_ERR; + } else if (event.type == "timeout") { + ex.addonCheckerErr = ProductAddonChecker.NETWORK_TIMEOUT_ERR; + } + reject(ex); + }; + + let success = event => { + logger.info("Completed downloading document"); + let request = event.target; + + try { + CertUtils.checkCert(request.channel, allowNonBuiltIn, allowedCerts); + } catch (ex) { + logger.error("Request failed certificate checks: " + ex); + ex.status = getRequestStatus(request).requestStatus; + ex.addonCheckerErr = ProductAddonChecker.VERIFICATION_FAILED_ERR; + reject(ex); + return; + } + + resolve(request); + }; + + request.addEventListener("error", fail); + request.addEventListener("abort", fail); + request.addEventListener("timeout", fail); + request.addEventListener("load", success); + + logger.info("sending request to: " + url); + request.send(null); + }); +} + +/** + * Downloads an XML document from a URL optionally testing the SSL certificate + * for certain attributes, and/or testing the content signature. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @param verifyContentSignature + * When true, will verify the content signature information from the + * response header. Failure to verify will result in an error. + * @return a promise that resolves to the DOM document downloaded or rejects + * with a JS exception in case of error. + */ +async function downloadXML( + url, + allowNonBuiltIn = false, + allowedCerts = null, + verifyContentSignature = false +) { + let request = await downloadXMLWithRequest( + url, + allowNonBuiltIn, + allowedCerts + ); + if (verifyContentSignature) { + await verifyGmpContentSignature( + request.response, + request.getResponseHeader("content-signature") + ); + } + return request.responseXML; +} + +/** + * Parses a list of add-ons from a DOM document. + * + * @param document + * The DOM document to parse. + * @return null if there is no element otherwise an object containing + * an array of the addons listed and a field notifying whether the + * fallback was used. + */ +function parseXML(document) { + // Check that the root element is correct + if (document.documentElement.localName != "updates") { + let err = new Error( + "got node name: " + + document.documentElement.localName + + ", expected: updates" + ); + err.addonCheckerErr = ProductAddonChecker.XML_PARSE_ERR; + throw err; + } + + // Check if there are any addons elements in the updates element + let addons = document.querySelector("updates:root > addons"); + if (!addons) { + return null; + } + + let results = []; + let addonList = document.querySelectorAll("updates:root > addons > addon"); + for (let addonElement of addonList) { + let addon = {}; + + for (let name of [ + "id", + "URL", + "hashFunction", + "hashValue", + "version", + "size", + ]) { + if (addonElement.hasAttribute(name)) { + addon[name] = addonElement.getAttribute(name); + } + } + addon.size = Number(addon.size) || undefined; + + results.push(addon); + } + + return { + usedFallback: false, + addons: results, + }; +} + +/** + * Downloads file from a URL using ServiceRequest. + * + * @param url + * The url to download from. + * @param options (optional) + * @param options.httpsOnlyNoUpgrade + * Prevents upgrade to https:// when HTTPS-Only Mode is enabled. + * @return a promise that resolves to the path of a temporary file or rejects + * with a JS exception in case of error. + */ +function downloadFile(url, options = { httpsOnlyNoUpgrade: false }) { + return new Promise((resolve, reject) => { + let sr = new lazy.ServiceRequest(); + + sr.onload = function (response) { + logger.info("downloadFile File download. status=" + sr.status); + if (sr.status != 200 && sr.status != 206) { + reject(Components.Exception("File download failed", sr.status)); + return; + } + (async function () { + const path = await IOUtils.createUniqueFile( + PathUtils.tempDir, + "tmpaddon" + ); + logger.info(`Downloaded file will be saved to ${path}`); + await IOUtils.write(path, new Uint8Array(sr.response)); + + return path; + })().then(resolve, reject); + }; + + let fail = event => { + let request = event.target; + let status = getRequestStatus(request); + let message = + "Failed downloading via ServiceRequest, status: " + + status.status + + ", channelStatus: " + + status.channelStatus + + ", errorCode: " + + status.errorCode + + ", reason: " + + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status.status; + reject(ex); + }; + sr.addEventListener("error", fail); + sr.addEventListener("abort", fail); + + sr.responseType = "arraybuffer"; + try { + sr.open("GET", url); + if (options.httpsOnlyNoUpgrade) { + sr.channel.loadInfo.httpsOnlyStatus |= Ci.nsILoadInfo.HTTPS_ONLY_EXEMPT; + } + // Allow deprecated HTTP request from SystemPrincipal + sr.channel.loadInfo.allowDeprecatedSystemRequests = true; + sr.send(null); + } catch (ex) { + reject(ex); + } + }); +} + +/** + * Verifies that a downloaded file matches what was expected. + * + * @param properties + * The properties to check, `hashFunction` with `hashValue` + * are supported. Any properties missing won't be checked. + * @param path + * The path of the file to check. + * @return a promise that resolves if the file matched or rejects with a JS + * exception in case of error. + */ +var verifyFile = async function (properties, path) { + if (properties.size !== undefined) { + let stat = await IOUtils.stat(path); + if (stat.size != properties.size) { + throw new Error( + "Downloaded file was " + + stat.size + + " bytes but expected " + + properties.size + + " bytes." + ); + } + } + + if (properties.hashFunction !== undefined) { + let expectedDigest = properties.hashValue.toLowerCase(); + let digest = await IOUtils.computeHexDigest(path, properties.hashFunction); + if (digest != expectedDigest) { + throw new Error( + "Hash was `" + digest + "` but expected `" + expectedDigest + "`." + ); + } + } +}; + +export const ProductAddonChecker = { + // More specific error names to help debug and report failures. + NETWORK_REQUEST_ERR: "NetworkRequestError", + NETWORK_TIMEOUT_ERR: "NetworkTimeoutError", + ABORT_ERR: "AbortError", // Doesn't have network prefix to work with existing convention. + VERIFICATION_MISSING_DATA_ERR: "VerificationMissingDataError", + VERIFICATION_FAILED_ERR: "VerificationFailedError", + VERIFICATION_INVALID_ERR: "VerificationInvalidError", + XML_PARSE_ERR: "XMLParseError", + + /** + * Downloads a list of add-ons from a URL optionally testing the SSL + * certificate for certain attributes, and/or testing the content signature. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @param verifyContentSignature + * When true, will verify the content signature information from the + * response header. Failure to verify will result in an error. + * @return a promise that resolves to an object containing the list of add-ons + * and whether the local fallback was used, or rejects with a JS + * exception in case of error. In the case of an error, a best effort + * is made to set the error addonCheckerErr property to one of the + * more specific names used by the product addon checker. + */ + getProductAddonList( + url, + allowNonBuiltIn = false, + allowedCerts = null, + verifyContentSignature = false + ) { + return downloadXML( + url, + allowNonBuiltIn, + allowedCerts, + verifyContentSignature + ).then(parseXML); + }, + + /** + * Downloads an add-on to a local file and checks that it matches the expected + * file. The caller is responsible for deleting the temporary file returned. + * + * @param addon + * The addon to download. + * @param options (optional) + * @param options.httpsOnlyNoUpgrade + * Prevents upgrade to https:// when HTTPS-Only Mode is enabled. + * @return a promise that resolves to the temporary file downloaded or rejects + * with a JS exception in case of error. + */ + async downloadAddon(addon, options = { httpsOnlyNoUpgrade: false }) { + let path = await downloadFile(addon.URL, options); + try { + await verifyFile(addon, path); + return path; + } catch (e) { + await IOUtils.remove(path); + throw e; + } + }, +}; + +// For test use only. +export const ProductAddonCheckerTestUtils = { + /** + * Used to override ServiceRequest calls with a mock request. + * @param mockRequest The mocked ServiceRequest object. + * @param callback Method called with the overridden ServiceRequest. The override + * is undone after the callback returns. + */ + async overrideServiceRequest(mockRequest, callback) { + let originalServiceRequest = lazy.ServiceRequest; + lazy.ServiceRequest = function () { + return mockRequest; + }; + try { + return await callback(); + } finally { + lazy.ServiceRequest = originalServiceRequest; + } + }, +}; diff --git a/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs new file mode 100644 index 0000000000..ccac484a1e --- /dev/null +++ b/toolkit/mozapps/extensions/internal/SitePermsAddonProvider.sys.mjs @@ -0,0 +1,661 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { computeSha256HashAsString } from "resource://gre/modules/addons/crypto-utils.sys.mjs"; +import { + GATED_PERMISSIONS, + SITEPERMS_ADDON_PROVIDER_PREF, + SITEPERMS_ADDON_TYPE, + isGatedPermissionType, + isKnownPublicSuffix, + isPrincipalInSitePermissionsBlocklist, +} from "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonManagerPrivate: "resource://gre/modules/AddonManager.sys.mjs", +}); +ChromeUtils.defineLazyGetter( + lazy, + "addonsBundle", + () => new Localization(["toolkit/about/aboutAddons.ftl"], true) +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SITEPERMS_ADDON_PROVIDER_ENABLED", + SITEPERMS_ADDON_PROVIDER_PREF, + false +); + +const FIRST_CONTENT_PROCESS_TOPIC = "ipc:first-content-process-created"; +const SITEPERMS_ADDON_ID_SUFFIX = "@siteperms.mozilla.org"; + +// Generate a per-session random salt, which is then used to generate +// per-siteOrigin hashed strings used as the addon id in SitePermsAddonWrapper constructor +// (expected to be matching new addon id generated for the same siteOrigin during +// the same browsing session and different ones in new browsing sessions). +// +// NOTE: `generateSalt` is exported for testing purpose, should not be +// used outside of tests. +let SALT; +export function generateSalt() { + // Throw if we're not in test and SALT is already defined + if ( + typeof SALT !== "undefined" && + !Services.env.exists("XPCSHELL_TEST_PROFILE_DIR") + ) { + throw new Error("This should only be called from XPCShell tests"); + } + SALT = crypto.getRandomValues(new Uint8Array(12)).join(""); +} + +function getSalt() { + if (!SALT) { + generateSalt(); + } + return SALT; +} + +class SitePermsAddonWrapper { + // An array of nsIPermission granted for the siteOrigin. + // We can't use a Set as handlePermissionChange might be called with different + // nsIPermission instance for the same permission (in the generic sense) + #permissions = []; + + // This will be set to true in the `uninstall` method to recognize when a perm-changed notification + // is actually triggered by the SitePermsAddonWrapper uninstall method itself. + isUninstalling = false; + + /** + * @param {string} siteOriginNoSuffix: The origin this addon is installed for + * WITHOUT the suffix generated from the + * origin attributes (see: + * nsIPrincipal.siteOriginNoSuffix). + * @param {Array} permissions: An array of the initial + * permissions the user granted + * for the addon origin. + */ + constructor(siteOriginNoSuffix, permissions = []) { + this.siteOrigin = siteOriginNoSuffix; + this.principal = + Services.scriptSecurityManager.createContentPrincipalFromOrigin( + this.siteOrigin + ); + // Use a template string for the concat in case `siteOrigin` isn't a string. + const saltedValue = `${this.siteOrigin}${getSalt()}`; + this.id = `${computeSha256HashAsString( + saltedValue + )}${SITEPERMS_ADDON_ID_SUFFIX}`; + + for (const perm of permissions) { + this.#permissions.push(perm); + } + } + + get isUninstalled() { + return this.#permissions.length === 0; + } + + /** + * Returns the list of gated permissions types granted for the instance's origin + * + * @return {Array} + */ + get sitePermissions() { + return Array.from(new Set(this.#permissions.map(perm => perm.type))); + } + + /** + * Update #permissions, and calls `uninstall` if there are no remaining gated permissions + * granted. This is called by SitePermsAddonProvider when it gets a "perm-changed" notification for a gated + * permission. + * + * @param {nsIPermission} permission: The permission being added/removed + * @param {String} action: The action perm-changed notifies us about + */ + handlePermissionChange(permission, action) { + if (action == "added") { + this.#permissions.push(permission); + } else if (action == "deleted") { + // We want to remove the registered permission for the right principal (looking into originSuffix so we + // can unregister revoked permission on a specific context, private window, ...). + this.#permissions = this.#permissions.filter( + perm => + !( + perm.type == permission.type && + perm.principal.originSuffix === permission.principal.originSuffix + ) + ); + + if (this.#permissions.length === 0) { + this.uninstall(); + } + } + } + + get type() { + return SITEPERMS_ADDON_TYPE; + } + + get name() { + return lazy.addonsBundle.formatValueSync("addon-sitepermission-host", { + host: this.principal.host, + }); + } + + get creator() {} + + get homepageURL() {} + + get description() {} + + get fullDescription() {} + + get version() { + // We consider the previous implementation attempt (signed addons) to be the initial version, + // hence the 2.0 for this approach. + return "2.0"; + } + get updateDate() {} + + get isActive() { + return true; + } + + get appDisabled() { + return false; + } + + get userDisabled() { + return false; + } + set userDisabled(aVal) {} + + get size() { + return 0; + } + + async updateBlocklistState(options = {}) {} + + get blocklistState() { + return Ci.nsIBlocklistService.STATE_NOT_BLOCKED; + } + + get scope() { + return lazy.AddonManager.SCOPE_APPLICATION; + } + + get pendingOperations() { + return lazy.AddonManager.PENDING_NONE; + } + + get operationsRequiringRestart() { + return lazy.AddonManager.OP_NEEDS_RESTART_NONE; + } + + get permissions() { + // The addon only supports PERM_CAN_UNINSTALL and no other AOM permission. + return lazy.AddonManager.PERM_CAN_UNINSTALL; + } + + get signedState() { + // Will make the permission prompt use the webextSitePerms.headerUnsignedWithPerms string + return lazy.AddonManager.SIGNEDSTATE_MISSING; + } + + async enable() {} + + async disable() {} + + /** + * Uninstall the addon, calling AddonManager hooks and removing all granted permissions. + * + * @throws Services.perms.removeFromPrincipal could throw, see PermissionManager::AddInternal. + */ + async uninstall() { + if (this.isUninstalling) { + return; + } + try { + this.isUninstalling = true; + lazy.AddonManagerPrivate.callAddonListeners( + "onUninstalling", + this, + false + ); + const permissions = [...this.#permissions]; + for (const permission of permissions) { + try { + Services.perms.removeFromPrincipal( + permission.principal, + permission.type + ); + // Only remove the permission from the array if it was successfully removed from the principal + this.#permissions.splice(this.#permissions.indexOf(permission), 1); + } catch (err) { + Cu.reportError(err); + } + } + lazy.AddonManagerPrivate.callAddonListeners("onUninstalled", this); + } finally { + this.isUninstalling = false; + } + } + + get isCompatible() { + return true; + } + + get isPlatformCompatible() { + return true; + } + + get providesUpdatesSecurely() { + return true; + } + + get foreignInstall() { + return false; + } + + get installTelemetryInfo() { + return { source: "siteperm-addon-provider", method: "synthetic-install" }; + } + + isCompatibleWith(aAppVersion, aPlatformVersion) { + return true; + } +} + +class SitePermsAddonInstalling extends SitePermsAddonWrapper { + #install = null; + + /** + * @param {string} siteOriginNoSuffix: The origin this addon is installed + * for, WITHOUT the suffix generated from + * the origin attributes (see: + * nsIPrincipal.siteOriginNoSuffix). + * @param {SitePermsAddonInstall} install: The SitePermsAddonInstall instance + * calling this constructor. + */ + constructor(siteOriginNoSuffix, install) { + // SitePermsAddonWrapper expect an array of nsIPermission as its second parameter. + // Since we don't have a proper permission here, we pass an object with the properties + // being used in the class. + const permission = { + principal: install.principal, + type: install.newSitePerm, + }; + + super(siteOriginNoSuffix, [permission]); + this.#install = install; + } + + get existingAddon() { + return SitePermsAddonProvider.wrappersMapByOrigin.get(this.siteOrigin); + } + + uninstall() { + // While about:addons tab is already open, new addon cards for newly installed + // addons are created from the `onInstalled` AOM events, for the `SitePermsAddonWrapper` + // the `onInstalling` and `onInstalled` events are emitted by `SitePermsAddonInstall` + // and the addon instance is going to be a `SitePermsAddonInstalling` instance if + // there wasn't an AddonCard for the same addon id yet. + // + // To make sure that all permissions will be uninstalled if a user uninstall the + // addon from an AddonCard created from a `SitePermsAddonInstalling` instance, + // we forward calls to the uninstall method of the existing `SitePermsAddonWrapper` + // instance being tracked by the `SitePermsAddonProvider`. + // If there isn't any then removing only the single permission added along with the + // `SitePremsAddonInstalling` is going to be enough. + if (this.existingAddon) { + return this.existingAddon.uninstall(); + } + + return super.uninstall(); + } + + validInstallOrigins() { + // Always return true from here, + // actual checks are done from AddonManagerInternal.getSitePermsAddonInstallForWebpage + return true; + } +} + +// Numeric id included in the install telemetry events to correlate multiple events related +// to the same install or update flow. +let nextInstallId = 0; + +class SitePermsAddonInstall { + #listeners = new Set(); + #installEvents = { + INSTALL_CANCELLED: "onInstallCancelled", + INSTALL_ENDED: "onInstallEnded", + INSTALL_FAILED: "onInstallFailed", + }; + + /** + * @param {nsIPrincipal} installingPrincipal + * @param {String} sitePerm + */ + constructor(installingPrincipal, sitePerm) { + this.principal = installingPrincipal; + this.newSitePerm = sitePerm; + this.state = lazy.AddonManager.STATE_DOWNLOADED; + this.addon = new SitePermsAddonInstalling( + this.principal.siteOriginNoSuffix, + this + ); + this.installId = ++nextInstallId; + } + + get installTelemetryInfo() { + return this.addon.installTelemetryInfo; + } + + async checkPrompt() { + // `promptHandler` can be set from `AddonManagerInternal.setupPromptHandler` + if (this.promptHandler) { + let info = { + // TODO: Investigate if we need to handle addon "update", i.e. granting new + // gated permission on an origin other permissions were already granted for (Bug 1790778). + existingAddon: null, + addon: this.addon, + icon: "chrome://mozapps/skin/extensions/category-sitepermission.svg", + // Used in AMTelemetry to detect the install flow related to this prompt. + install: this, + }; + + try { + await this.promptHandler(info); + } catch (err) { + if (this.error < 0) { + this.state = lazy.AddonManager.STATE_INSTALL_FAILED; + // In some cases onOperationCancelled is called during failures + // to install/uninstall/enable/disable addons. We may need to + // do that here in the future. + this.#callInstallListeners(this.#installEvents.INSTALL_FAILED); + } else if (this.state !== lazy.AddonManager.STATE_CANCELLED) { + this.cancel(); + } + return; + } + } + + this.state = lazy.AddonManager.STATE_PROMPTS_DONE; + this.install(); + } + + install() { + if (this.state === lazy.AddonManager.STATE_PROMPTS_DONE) { + lazy.AddonManagerPrivate.callAddonListeners("onInstalling", this.addon); + Services.perms.addFromPrincipal( + this.principal, + this.newSitePerm, + Services.perms.ALLOW_ACTION + ); + this.state = lazy.AddonManager.STATE_INSTALLED; + this.#callInstallListeners(this.#installEvents.INSTALL_ENDED); + lazy.AddonManagerPrivate.callAddonListeners("onInstalled", this.addon); + this.addon.install = null; + return; + } + + if (this.state !== lazy.AddonManager.STATE_DOWNLOADED) { + this.state = lazy.AddonManager.STATE_INSTALL_FAILED; + this.#callInstallListeners(this.#installEvents.INSTALL_FAILED); + return; + } + + this.checkPrompt(); + } + + cancel() { + // This method can be called if the install is already cancelled. + // We don't want to go further in such case as it would lead to duplicated Telemetry events. + if (this.state == lazy.AddonManager.STATE_CANCELLED) { + console.error("SitePermsAddonInstall#cancel called twice on ", this); + return; + } + + this.state = lazy.AddonManager.STATE_CANCELLED; + this.#callInstallListeners(this.#installEvents.INSTALL_CANCELLED); + } + + /** + * Add a listener for the install events + * + * @param {Object} listener + * @param {Function} [listener.onDownloadEnded] + * @param {Function} [listener.onInstallCancelled] + * @param {Function} [listener.onInstallEnded] + * @param {Function} [listener.onInstallFailed] + */ + addListener(listener) { + this.#listeners.add(listener); + } + + /** + * Remove a listener + * + * @param {Object} listener: The same object reference that was used for `addListener` + */ + removeListener(listener) { + this.#listeners.delete(listener); + } + + /** + * Call the listeners callbacks for a given event. + * + * @param {String} eventName: The event to fire. Should be one of `this.#installEvents` + */ + #callInstallListeners(eventName) { + if (!Object.values(this.#installEvents).includes(eventName)) { + console.warn(`Unknown "${eventName}" "event`); + return; + } + + lazy.AddonManagerPrivate.callInstallListeners( + eventName, + Array.from(this.#listeners), + this + ); + } +} + +const SitePermsAddonProvider = { + get name() { + return "SitePermsAddonProvider"; + }, + + wrappersMapByOrigin: new Map(), + + /** + * Update wrappersMapByOrigin on perm-changed + * + * @param {nsIPermission} permission: The permission being added/removed + * @param {String} action: The action perm-changed notifies us about + */ + handlePermissionChange(permission, action = "added") { + // Bail out if it it's not a gated perm + if (!isGatedPermissionType(permission.type)) { + return; + } + + // Gated APIs should probably not be available on non-secure origins, + // but let's double check here. + if (permission.principal.scheme !== "https") { + return; + } + + if (isPrincipalInSitePermissionsBlocklist(permission.principal)) { + return; + } + + const { siteOriginNoSuffix } = permission.principal; + + // Install origin cannot be on a known etld (e.g. github.io). + // We shouldn't get a permission change for those here, but let's + // be extra safe + if (isKnownPublicSuffix(siteOriginNoSuffix)) { + return; + } + + // Pipe the change to the existing addon if there is one. + if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) { + this.wrappersMapByOrigin + .get(siteOriginNoSuffix) + .handlePermissionChange(permission, action); + } + + if (action == "added") { + // We only have one SitePermsAddon per origin, handling multiple permissions. + if (this.wrappersMapByOrigin.has(siteOriginNoSuffix)) { + return; + } + + const addonWrapper = new SitePermsAddonWrapper(siteOriginNoSuffix, [ + permission, + ]); + this.wrappersMapByOrigin.set(siteOriginNoSuffix, addonWrapper); + return; + } + + if (action == "deleted") { + if (!this.wrappersMapByOrigin.has(siteOriginNoSuffix)) { + return; + } + // Only remove the addon if it doesn't have any permissions left. + if (!this.wrappersMapByOrigin.get(siteOriginNoSuffix).isUninstalled) { + return; + } + this.wrappersMapByOrigin.delete(siteOriginNoSuffix); + } + }, + + /** + * Returns a Promise that resolves when handled the list of gated permissions + * and setup ther observer for the "perm-changed" event. + * + * @returns Promise + */ + lazyInit() { + if (!this._initPromise) { + this._initPromise = new Promise(resolve => { + // Build the initial list of addons per origin + const perms = Services.perms.getAllByTypes(GATED_PERMISSIONS); + for (const perm of perms) { + this.handlePermissionChange(perm); + } + Services.obs.addObserver(this, "perm-changed"); + resolve(); + }); + } + return this._initPromise; + }, + + shutdown() { + if (this._initPromise) { + Services.obs.removeObserver(this, "perm-changed"); + } + this.wrappersMapByOrigin.clear(); + this._initPromise = null; + }, + + /** + * Get a SitePermsAddonWrapper from an extension id + * + * @param {String|null|undefined} id: The extension id, + * @returns {SitePermsAddonWrapper|undefined} + */ + async getAddonByID(id) { + await this.lazyInit(); + if (!id?.endsWith?.(SITEPERMS_ADDON_ID_SUFFIX)) { + return undefined; + } + + for (const addon of this.wrappersMapByOrigin.values()) { + if (addon.id === id) { + return addon; + } + } + return undefined; + }, + + /** + * Get a list of SitePermsAddonWrapper for a given list of extension types. + * + * @param {Array|null|undefined} types: If null or undefined is passed, + * the callsites expect to get all the addons from the provider, without + * any filtering. + * @returns {Array} + */ + async getAddonsByTypes(types) { + if ( + !this.isEnabled || + // `types` can be null/undefined, and in such case we _do_ want to return the addons. + (Array.isArray(types) && !types.includes(SITEPERMS_ADDON_TYPE)) + ) { + return []; + } + + await this.lazyInit(); + return Array.from(this.wrappersMapByOrigin.values()); + }, + + /** + * Create and return a SitePermsAddonInstall instance for a permission on a given principal + * + * @param {nsIPrincipal} installingPrincipal + * @param {String} sitePerm + * @returns {SitePermsAddonInstall} + */ + getSitePermsAddonInstallForWebpage(installingPrincipal, sitePerm) { + return new SitePermsAddonInstall(installingPrincipal, sitePerm); + }, + + get isEnabled() { + return lazy.SITEPERMS_ADDON_PROVIDER_ENABLED; + }, + + observe(subject, topic, data) { + if (!this.isEnabled) { + return; + } + + if (topic == FIRST_CONTENT_PROCESS_TOPIC) { + Services.obs.removeObserver(this, FIRST_CONTENT_PROCESS_TOPIC); + + lazy.AddonManagerPrivate.registerProvider(SitePermsAddonProvider, [ + SITEPERMS_ADDON_TYPE, + ]); + Services.obs.notifyObservers(null, "sitepermsaddon-provider-registered"); + } else if (topic === "perm-changed") { + if (data === "cleared") { + // In such case, `subject` is null, but we can simply uninstall all existing addons. + for (const addon of this.wrappersMapByOrigin.values()) { + addon.uninstall(); + } + this.wrappersMapByOrigin.clear(); + return; + } + + const perm = subject.QueryInterface(Ci.nsIPermission); + this.handlePermissionChange(perm, data); + } + }, + + addFirstContentProcessObserver() { + Services.obs.addObserver(this, FIRST_CONTENT_PROCESS_TOPIC); + }, +}; + +// We want to register the SitePermsAddonProvider once the first content process gets created +// (and only if the feature is also enabled through the "dom.sitepermsaddon-provider.enabled" +// about:config pref). +SitePermsAddonProvider.addFirstContentProcessObserver(); diff --git a/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs new file mode 100644 index 0000000000..5d1d2c1970 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/XPIDatabase.sys.mjs @@ -0,0 +1,3832 @@ +/* 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", + 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.jsm) +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", + "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", + "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(a => true); + + let changes = { + enabled: [], + disabled: [], + }; + + for (let addon of addons) { + // The add-on might have vanished, we'll catch that on the next startup + if (!addon._sourceBundle || !addon._sourceBundle.exists()) { + continue; + } + + let signedState = await XPIExports.verifyBundleSignedState( + addon._sourceBundle, + addon + ); + + if (signedState != addon.signedState) { + addon.signedState = signedState; + lazy.AddonManagerPrivate.callAddonListeners( + "onPropertyChanged", + addon.wrapper, + ["signedState"] + ); + } + + let disabled = await this.updateAddonDisabledState(addon); + if (disabled !== undefined) { + changes[disabled ? "disabled" : "enabled"].push(addon.id); + } + } + + this.saveChanges(); + + Services.obs.notifyObservers( + null, + "xpi-signature-changed", + JSON.stringify(changes) + ); + } catch (err) { + logger.error("XPI_verifySignature: " + err); + } + }, + + /** + * Imports the xpinstall permissions from preferences into the permissions + * manager for the user to change later. + */ + importPermissions() { + 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, aAddon => true); + }, + + /** + * Called to get an Addon with a particular ID. + * + * @param {string} aId + * The ID of the add-on to retrieve + * @returns {Addon?} + */ + async getAddonByID(aId) { + let aAddon = await this.getVisibleAddonForID(aId); + return aAddon ? aAddon.wrapper : null; + }, + + /** + * Obtain an Addon having the specified Sync GUID. + * + * @param {string} aGUID + * String GUID of add-on to retrieve + * @returns {Addon?} + */ + async getAddonBySyncGUID(aGUID) { + let addon = await this.getAddon(aAddon => aAddon.syncGUID == aGUID); + return addon ? addon.wrapper : null; + }, + + /** + * Called to get Addons of a particular type. + * + * @param {Array?} 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 + * @param {string?} [aOldAppVersion] + * The version of the application last run with this profile or null + * if it is a new profile or the version is unknown + * @param {string?} [aOldPlatformVersion] + * The version of the platform last run with this profile or null + * if it is a new profile or the version is unknown + * @returns {boolean} + * A boolean indicating if flushing caches is required to complete + * changing this add-on + */ + addMetadata( + aLocation, + aId, + aAddonState, + aNewAddon, + aOldAppVersion, + aOldPlatformVersion + ) { + logger.debug(`New add-on ${aId} installed in ${aLocation.name}`); + + // We treat this is a new install if, + // + // a) It was explicitly registered as a staged install in the last + // session, or, + // b) We're not currently migrating or rebuilding a corrupt database. In + // that case, we can assume this add-on was found during a routine + // directory scan. + let isNewInstall = !!aNewAddon || !XPIDatabase.rebuildingDatabase; + + // If it's a new install and we haven't yet loaded the manifest then it + // must be something dropped directly into the install location + let isDetectedInstall = isNewInstall && !aNewAddon; + + // Load the manifest if necessary and sanity check the add-on ID + let unsigned; + try { + // Do not allow third party installs if xpinstall is disabled by policy + if ( + isDetectedInstall && + Services.policies && + !Services.policies.isAllowed("xpinstall") + ) { + throw new Error( + "Extension installs are disabled by enterprise policy." + ); + } + + if (!aNewAddon) { + // Load the manifest from the add-on. + aNewAddon = 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); + + // If maxVersion was inadvertently updated for a locale, force a reload + // from the manifest. See Bug 1646016 for details. + if ( + !aReloadMetadata && + aOldAddon.type === "locale" && + aOldAddon.matchingTargetApplication + ) { + aReloadMetadata = aOldAddon.matchingTargetApplication.maxVersion === "*"; + } + + let manifest = null; + if (checkSigning || aReloadMetadata || signedDateMissing) { + try { + manifest = 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; + } + + // 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; + }, +}; diff --git a/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs new file mode 100644 index 0000000000..3fdd03e660 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/XPIExports.sys.mjs @@ -0,0 +1,36 @@ +/* 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 wraps XPIDatabase, XPIInstall, and XPIProvider modules in order to + * allow testing the shutdown+restart situation in AddonTestUtils.sys.mjs. + */ + +// A shared `lazy` object for exports from XPIDatabase, XPIInternal, and +// XPIProvider modules. +// +// Consumers shouldn't store those property values to global variables, except +// for registering XPIProvider. +// +// The list of lazy getters should be in sync with resetXPIExports in +// AddonTestUtils.sys.mjs. +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + // XPIDatabase.sys.mjs + AddonInternal: "resource://gre/modules/addons/XPIDatabase.sys.mjs", + BuiltInThemesHelpers: "resource://gre/modules/addons/XPIDatabase.sys.mjs", + XPIDatabase: "resource://gre/modules/addons/XPIDatabase.sys.mjs", + XPIDatabaseReconcile: "resource://gre/modules/addons/XPIDatabase.sys.mjs", + + // XPIInstall.sys.mjs + UpdateChecker: "resource://gre/modules/addons/XPIInstall.sys.mjs", + XPIInstall: "resource://gre/modules/addons/XPIInstall.sys.mjs", + verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.sys.mjs", + + // XPIProvider.sys.mjs + XPIProvider: "resource://gre/modules/addons/XPIProvider.sys.mjs", + XPIInternal: "resource://gre/modules/addons/XPIProvider.sys.mjs", +}); + +export { lazy as XPIExports }; diff --git a/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs new file mode 100644 index 0000000000..1a80407ad2 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/XPIInstall.sys.mjs @@ -0,0 +1,4897 @@ +/* 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 install extensions. + * In general, we try to avoid loading it until extension installation + * or update is required. Please keep that in mind when deciding whether + * to add code here or elsewhere. + */ + +/** + * @typedef {number} integer + */ + +/* 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"; +import { + computeSha256HashAsString, + getHashStringForCrypto, +} from "resource://gre/modules/addons/crypto-utils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { + AddonManager, + AddonManagerPrivate, +} from "resource://gre/modules/AddonManager.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs", + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + CertUtils: "resource://gre/modules/CertUtils.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + ProductAddonChecker: + "resource://gre/modules/addons/ProductAddonChecker.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs", +}); + +ChromeUtils.defineLazyGetter(lazy, "IconDetails", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ).ExtensionParent.IconDetails; +}); + +const { nsIBlocklistService } = Ci; + +const nsIFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" +); + +const BinaryOutputStream = Components.Constructor( + "@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream" +); +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); +const FileInputStream = Components.Constructor( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); +const FileOutputStream = Components.Constructor( + "@mozilla.org/network/file-output-stream;1", + "nsIFileOutputStream", + "init" +); +const ZipReader = Components.Constructor( + "@mozilla.org/libjar/zip-reader;1", + "nsIZipReader", + "open" +); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"], +}); + +const PREF_INSTALL_REQUIRESECUREORIGIN = + "extensions.install.requireSecureOrigin"; +const PREF_PENDING_OPERATIONS = "extensions.pendingOperations"; +const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url"; +const PREF_XPI_ENABLED = "xpinstall.enabled"; +const PREF_XPI_DIRECT_WHITELISTED = "xpinstall.whitelist.directRequest"; +const PREF_XPI_FILE_WHITELISTED = "xpinstall.whitelist.fileRequest"; +const PREF_XPI_WHITELIST_REQUIRED = "xpinstall.whitelist.required"; + +const PREF_SELECTED_THEME = "extensions.activeThemeID"; + +const TOOLKIT_ID = "toolkit@mozilla.org"; + +/** + * Returns a nsIFile instance for the given path, relative to the given + * base file, if provided. + * + * @param {string} path + * The (possibly relative) path of the file. + * @param {nsIFile} [base] + * An optional file to use as a base path if `path` is relative. + * @returns {nsIFile} + */ +function getFile(path, base = null) { + // First try for an absolute path, as we get in the case of proxy + // files. Ideally we would try a relative path first, but on Windows, + // paths which begin with a drive letter are valid as relative paths, + // and treated as such. + try { + return new nsIFile(path); + } catch (e) { + // Ignore invalid relative paths. The only other error we should see + // here is EOM, and either way, any errors that we care about should + // be re-thrown below. + } + + // If the path isn't absolute, we must have a base path. + let file = base.clone(); + file.appendRelativePath(path); + return file; +} + +/** + * Sends local and remote notifications to flush a JAR file cache entry + * + * @param {nsIFile} aJarFile + * The ZIP/XPI/JAR file as a nsIFile + */ +function flushJarCache(aJarFile) { + Services.obs.notifyObservers(aJarFile, "flush-cache-entry"); + Services.ppmm.broadcastAsyncMessage(MSG_JAR_FLUSH, { + path: aJarFile.path, + }); +} + +const PREF_EM_UPDATE_BACKGROUND_URL = "extensions.update.background.url"; +const PREF_EM_UPDATE_URL = "extensions.update.url"; +const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root"; + +const KEY_TEMPDIR = "TmpD"; + +// This is a random number array that can be used as "salt" when generating +// an automatic ID based on the directory path of an add-on. It will prevent +// someone from creating an ID for a permanent add-on that could be replaced +// by a temporary add-on (because that would be confusing, I guess). +const TEMP_INSTALL_ID_GEN_SESSION = new Uint8Array( + Float64Array.of(Math.random()).buffer +); + +const MSG_JAR_FLUSH = "Extension:FlushJarCache"; + +/** + * Valid IDs fit this pattern. + */ +var gIDTest = + /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const LOGGER_ID = "addons.xpi"; + +// Create a new logger for use by all objects in this Addons XPI Provider module +// (Requires AddonManager.jsm) +var logger = Log.repository.getLogger(LOGGER_ID); + +// Stores the ID of the theme which was selected during the last session, +// if any. When installing a new built-in theme with this ID, it will be +// automatically enabled. +let lastSelectedTheme = null; + +function getJarURI(file, path = "") { + if (file instanceof Ci.nsIFile) { + file = Services.io.newFileURI(file); + } + if (file instanceof Ci.nsIURI) { + file = file.spec; + } + return Services.io.newURI(`jar:${file}!/${path}`); +} + +let DirPackage; +let XPIPackage; +class Package { + static get(file) { + if (file.isFile()) { + return new XPIPackage(file); + } + return new DirPackage(file); + } + + constructor(file, rootURI) { + this.file = file; + this.filePath = file.path; + this.rootURI = rootURI; + } + + close() {} + + async readString(...path) { + let buffer = await this.readBinary(...path); + return new TextDecoder().decode(buffer); + } + + async verifySignedState(addonId, addonType, addonLocation) { + if (!shouldVerifySignedState(addonType, addonLocation)) { + return { + signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED, + cert: null, + }; + } + + let root = Ci.nsIX509CertDB.AddonsPublicRoot; + if ( + !AppConstants.MOZ_REQUIRE_SIGNING && + Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false) + ) { + root = Ci.nsIX509CertDB.AddonsStageRoot; + } + + return this.verifySignedStateForRoot(addonId, root); + } + + flushCache() {} +} + +DirPackage = class DirPackage extends Package { + constructor(file) { + super(file, Services.io.newFileURI(file)); + } + + hasResource(...path) { + return IOUtils.exists(PathUtils.join(this.filePath, ...path)); + } + + async iterDirectory(path, callback) { + let fullPath = PathUtils.join(this.filePath, ...path); + + let children = await IOUtils.getChildren(fullPath); + for (let path of children) { + let { type } = await IOUtils.stat(path); + callback({ + isDir: type == "directory", + name: PathUtils.filename(path), + path, + }); + } + } + + iterFiles(callback, path = []) { + return this.iterDirectory(path, async entry => { + let entryPath = [...path, entry.name]; + if (entry.isDir) { + callback({ + path: entryPath.join("/"), + isDir: true, + }); + await this.iterFiles(callback, entryPath); + } else { + callback({ + path: entryPath.join("/"), + isDir: false, + }); + } + }); + } + + readBinary(...path) { + return IOUtils.read(PathUtils.join(this.filePath, ...path)); + } + + async verifySignedStateForRoot(addonId, root) { + return { signedState: AddonManager.SIGNEDSTATE_UNKNOWN, cert: null }; + } +}; + +XPIPackage = class XPIPackage extends Package { + constructor(file) { + super(file, getJarURI(file)); + + this.zipReader = new ZipReader(file); + } + + close() { + this.zipReader.close(); + this.zipReader = null; + this.flushCache(); + } + + async hasResource(...path) { + return this.zipReader.hasEntry(path.join("/")); + } + + async iterFiles(callback) { + for (let path of this.zipReader.findEntries("*")) { + let entry = this.zipReader.getEntry(path); + callback({ + path, + isDir: entry.isDirectory, + }); + } + } + + async readBinary(...path) { + let response = await fetch(this.rootURI.resolve(path.join("/"))); + return response.arrayBuffer(); + } + + verifySignedStateForRoot(addonId, root) { + return new Promise(resolve => { + let callback = { + openSignedAppFileFinished(aRv, aZipReader, aCert) { + if (aZipReader) { + aZipReader.close(); + } + resolve({ + signedState: getSignedStatus(aRv, aCert, addonId), + cert: aCert, + }); + }, + }; + // This allows the certificate DB to get the raw JS callback object so the + // test code can pass through objects that XPConnect would reject. + callback.wrappedJSObject = callback; + + lazy.gCertDB.openSignedAppFileAsync(root, this.file, callback); + }); + } + + flushCache() { + flushJarCache(this.file); + } +}; + +/** + * Return an object that implements enough of the Package interface + * to allow loadManifest() to work for a built-in addon (ie, one loaded + * from a resource: url) + * + * @param {nsIURL} baseURL The URL for the root of the add-on. + * @returns {object} + */ +function builtinPackage(baseURL) { + return { + rootURI: baseURL, + filePath: baseURL.spec, + file: null, + verifySignedState() { + return { + signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED, + cert: null, + }; + }, + async hasResource(path) { + try { + let response = await fetch(this.rootURI.resolve(path)); + return response.ok; + } catch (e) { + return false; + } + }, + }; +} + +/** + * Determine the reason to pass to an extension's bootstrap methods when + * switch between versions. + * + * @param {string} oldVersion The version of the existing extension instance. + * @param {string} newVersion The version of the extension being installed. + * + * @returns {integer} + * BOOSTRAP_REASONS.ADDON_UPGRADE or BOOSTRAP_REASONS.ADDON_DOWNGRADE + */ +function newVersionReason(oldVersion, newVersion) { + return Services.vc.compare(oldVersion, newVersion) <= 0 + ? XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UPGRADE + : XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_DOWNGRADE; +} + +// Behaves like Promise.all except waits for all promises to resolve/reject +// before resolving/rejecting itself +function waitForAllPromises(promises) { + return new Promise((resolve, reject) => { + let shouldReject = false; + let rejectValue = null; + + let newPromises = promises.map(p => + p.catch(value => { + shouldReject = true; + rejectValue = value; + }) + ); + Promise.all(newPromises).then(results => + shouldReject ? reject(rejectValue) : resolve(results) + ); + }); +} + +/** + * Reads an AddonInternal object from a webextension manifest.json + * + * @param {Package} aPackage + * The install package for the add-on + * @param {XPIStateLocation} aLocation + * The install location the add-on is installed in, or will be + * installed to. + * @returns {{ addon: AddonInternal, verifiedSignedState: object}} + * @throws if the install manifest in the stream is corrupt or could not + * be read + */ +async function loadManifestFromWebManifest(aPackage, aLocation) { + let verifiedSignedState; + const temporarilyInstalled = aLocation.isTemporary; + let extension = await lazy.ExtensionData.constructAsync({ + rootURI: XPIExports.XPIInternal.maybeResolveURI(aPackage.rootURI), + temporarilyInstalled, + async checkPrivileged(type, id) { + verifiedSignedState = await aPackage.verifySignedState( + id, + type, + aLocation + ); + return lazy.ExtensionData.getIsPrivileged({ + signedState: verifiedSignedState.signedState, + builtIn: aLocation.isBuiltin, + temporarilyInstalled, + }); + }, + }); + + let manifest = await extension.loadManifest(); + + // Read the list of available locales, and pre-load messages for + // all locales. + let locales = !extension.errors.length + ? await extension.initAllLocales() + : null; + + if (extension.errors.length) { + let error = new Error("Extension is invalid"); + // Add detailed errors on the error object so that the front end can display them + // if needed (eg in about:debugging). + error.additionalErrors = extension.errors; + throw error; + } + + // Internally, we use the `applications` key but it is because we assign the value + // of `browser_specific_settings` to `applications` in `ExtensionData.parseManifest()`. + // Yet, as of MV3, only `browser_specific_settings` is accepted in manifest.json files. + let bss = manifest.applications?.gecko || {}; + + // A * is illegal in strict_min_version + if (bss.strict_min_version?.split(".").some(part => part == "*")) { + throw new Error("The use of '*' in strict_min_version is invalid"); + } + + let addon = new XPIExports.AddonInternal(); + addon.id = bss.id; + addon.version = manifest.version; + addon.manifestVersion = manifest.manifest_version; + addon.type = extension.type; + addon.loader = null; + addon.strictCompatibility = true; + addon.internalName = null; + addon.updateURL = bss.update_url; + addon.installOrigins = manifest.install_origins; + addon.optionsBrowserStyle = true; + addon.optionsURL = null; + addon.optionsType = null; + addon.aboutURL = null; + addon.dependencies = Object.freeze(Array.from(extension.dependencies)); + addon.startupData = extension.startupData; + addon.hidden = extension.isPrivileged && manifest.hidden; + addon.incognito = manifest.incognito; + + if (addon.type === "theme" && (await aPackage.hasResource("preview.png"))) { + addon.previewImage = "preview.png"; + } + + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + if (addon.type == "sitepermission-deprecated") { + addon.sitePermissions = manifest.site_permissions; + addon.siteOrigin = manifest.install_origins[0]; + } + + if (manifest.options_ui) { + // Store just the relative path here, the AddonWrapper getURL + // wrapper maps this to a full URL. + addon.optionsURL = manifest.options_ui.page; + if (manifest.options_ui.open_in_tab) { + addon.optionsType = AddonManager.OPTIONS_TYPE_TAB; + } else { + addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER; + } + + addon.optionsBrowserStyle = manifest.options_ui.browser_style; + } + + // WebExtensions don't use iconURLs + addon.iconURL = null; + addon.icons = manifest.icons || {}; + addon.userPermissions = extension.manifestPermissions; + addon.optionalPermissions = extension.manifestOptionalPermissions; + addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT; + + function getLocale(aLocale) { + // Use the raw manifest, here, since we need values with their + // localization placeholders still in place. + let rawManifest = extension.rawManifest; + + // As a convenience, allow author to be set if its a string bug 1313567. + let creator = + typeof rawManifest.author === "string" ? rawManifest.author : null; + let homepageURL = rawManifest.homepage_url; + + // Allow developer to override creator and homepage_url. + if (rawManifest.developer) { + if (rawManifest.developer.name) { + creator = rawManifest.developer.name; + } + if (rawManifest.developer.url) { + homepageURL = rawManifest.developer.url; + } + } + + let result = { + name: extension.localize(rawManifest.name, aLocale), + description: extension.localize(rawManifest.description, aLocale), + creator: extension.localize(creator, aLocale), + homepageURL: extension.localize(homepageURL, aLocale), + + developers: null, + translators: null, + contributors: null, + locales: [aLocale], + }; + return result; + } + + addon.defaultLocale = getLocale(extension.defaultLocale); + addon.locales = Array.from(locales.keys(), getLocale); + + delete addon.defaultLocale.locales; + + addon.targetApplications = [ + { + id: TOOLKIT_ID, + minVersion: bss.strict_min_version, + maxVersion: bss.strict_max_version, + }, + ]; + + addon.targetPlatforms = []; + // Themes are disabled by default, except when they're installed from a web page. + addon.userDisabled = extension.type === "theme"; + addon.softDisabled = + addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED; + + return { addon, verifiedSignedState }; +} + +async function readRecommendationStates(aPackage, aAddonID) { + let recommendationData; + try { + recommendationData = await aPackage.readString( + "mozilla-recommendation.json" + ); + } catch (e) { + // Ignore I/O errors. + return null; + } + + try { + recommendationData = JSON.parse(recommendationData); + } catch (e) { + logger.warn("Failed to parse recommendation", e); + } + + if (recommendationData) { + let { addon_id, states, validity } = recommendationData; + + if (addon_id === aAddonID && Array.isArray(states) && validity) { + let validNotAfter = Date.parse(validity.not_after); + let validNotBefore = Date.parse(validity.not_before); + if (validNotAfter && validNotBefore) { + return { + validNotAfter, + validNotBefore, + states, + }; + } + } + logger.warn( + `Invalid recommendation for ${aAddonID}: ${JSON.stringify( + recommendationData + )}` + ); + } + + return null; +} + +function defineSyncGUID(aAddon) { + // Define .syncGUID as a lazy property which is also settable + Object.defineProperty(aAddon, "syncGUID", { + get: () => { + aAddon.syncGUID = Services.uuid.generateUUID().toString(); + return aAddon.syncGUID; + }, + set: val => { + delete aAddon.syncGUID; + aAddon.syncGUID = val; + }, + configurable: true, + enumerable: true, + }); +} + +// Generate a unique ID based on the path to this temporary add-on location. +function generateTemporaryInstallID(aFile) { + const hasher = CryptoHash("sha1"); + const data = new TextEncoder().encode(aFile.path); + // Make it so this ID cannot be guessed. + const sess = TEMP_INSTALL_ID_GEN_SESSION; + hasher.update(sess, sess.length); + hasher.update(data, data.length); + let id = `${getHashStringForCrypto(hasher)}${ + XPIExports.XPIInternal.TEMPORARY_ADDON_SUFFIX + }`; + logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`); + return id; +} + +var loadManifest = async function (aPackage, aLocation, aOldAddon) { + let addon; + let verifiedSignedState; + if (await aPackage.hasResource("manifest.json")) { + ({ addon, verifiedSignedState } = await loadManifestFromWebManifest( + aPackage, + aLocation + )); + } else { + // TODO bug 1674799: Remove this unused branch. + for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) { + if (await aPackage.hasResource(loader.manifestFile)) { + addon = await loader.loadManifest(aPackage); + addon.loader = loader.name; + verifiedSignedState = await aPackage.verifySignedState( + addon.id, + addon.type, + aLocation + ); + break; + } + } + } + + if (!addon) { + throw new Error( + `File ${aPackage.filePath} does not contain a valid manifest` + ); + } + + addon._sourceBundle = aPackage.file; + addon.rootURI = aPackage.rootURI.spec; + addon.location = aLocation; + + let { signedState, cert } = verifiedSignedState; + addon.signedState = signedState; + addon.signedDate = cert?.validity?.notBefore / 1000 || null; + + if (!addon.id) { + if (cert) { + addon.id = cert.commonName; + if (!gIDTest.test(addon.id)) { + throw new Error(`Extension is signed with an invalid id (${addon.id})`); + } + } + if (!addon.id && aLocation.isTemporary) { + addon.id = generateTemporaryInstallID(aPackage.file); + } + } + + addon.propagateDisabledState(aOldAddon); + if (!aLocation.isSystem && !aLocation.isBuiltin) { + if (addon.type === "extension" && !aLocation.isTemporary) { + addon.recommendationState = await readRecommendationStates( + aPackage, + addon.id + ); + } + + await addon.updateBlocklistState(); + addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(addon); + + // Always report when there is an attempt to install a blocked add-on. + // (transitions from STATE_BLOCKED to STATE_NOT_BLOCKED are checked + // in the individual AddonInstall subclasses). + if (addon.blocklistState == nsIBlocklistService.STATE_BLOCKED) { + addon.recordAddonBlockChangeTelemetry( + aOldAddon ? "addon_update" : "addon_install" + ); + } + } + + defineSyncGUID(addon); + + return addon; +}; + +/** + * Loads an add-on's manifest from the given file or directory. + * + * @param {nsIFile} aFile + * The file to load the manifest from. + * @param {XPIStateLocation} aLocation + * The install location the add-on is installed in, or will be + * installed to. + * @param {AddonInternal?} aOldAddon + * The currently-installed add-on with the same ID, if one exist. + * This is used to migrate user settings like the add-on's + * disabled state. + * @returns {AddonInternal} + * The parsed Addon object for the file's manifest. + */ +var loadManifestFromFile = async function (aFile, aLocation, aOldAddon) { + let pkg = Package.get(aFile); + try { + let addon = await loadManifest(pkg, aLocation, aOldAddon); + return addon; + } finally { + pkg.close(); + } +}; + +/* + * A synchronous method for loading an add-on's manifest. Do not use + * this. + */ +function syncLoadManifest(state, location, oldAddon) { + if (location.name == "app-builtin") { + let pkg = builtinPackage(Services.io.newURI(state.rootURI)); + return XPIExports.XPIInternal.awaitPromise( + loadManifest(pkg, location, oldAddon) + ); + } + + let file = new nsIFile(state.path); + let pkg = Package.get(file); + return XPIExports.XPIInternal.awaitPromise( + (async () => { + try { + let addon = await loadManifest(pkg, location, oldAddon); + addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile( + file, + "" + ).spec; + return addon; + } finally { + pkg.close(); + } + })() + ); +} + +/** + * Creates and returns a new unique temporary file. The caller should delete + * the file when it is no longer needed. + * + * @returns {nsIFile} + * An nsIFile that points to a randomly named, initially empty file in + * the OS temporary files directory + */ +function getTemporaryFile() { + let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []); + let random = Math.round(Math.random() * 36 ** 3).toString(36); + file.append(`tmp-${random}.xpi`); + file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE); + return file; +} + +function getHashForFile(file, algorithm) { + let crypto = CryptoHash(algorithm); + let fis = new FileInputStream(file, -1, -1, false); + try { + crypto.updateFromStream(fis, file.fileSize); + } finally { + fis.close(); + } + return getHashStringForCrypto(crypto); +} + +/** + * Returns the signedState for a given return code and certificate by verifying + * it against the expected ID. + * + * @param {nsresult} aRv + * The result code returned by the signature checker for the + * signature check operation. + * @param {nsIX509Cert?} aCert + * The certificate the add-on was signed with, if a valid + * certificate exists. + * @param {string?} aAddonID + * The expected ID of the add-on. If passed, this must match the + * ID in the certificate's CN field. + * @returns {number} + * A SIGNEDSTATE result code constant, as defined on the + * AddonManager class. + */ +function getSignedStatus(aRv, aCert, aAddonID) { + let expectedCommonName = aAddonID; + if (aAddonID && aAddonID.length > 64) { + expectedCommonName = computeSha256HashAsString(aAddonID); + } + + switch (aRv) { + case Cr.NS_OK: + if (expectedCommonName && expectedCommonName != aCert.commonName) { + return AddonManager.SIGNEDSTATE_BROKEN; + } + + if (aCert.organizationalUnit == "Mozilla Components") { + return AddonManager.SIGNEDSTATE_SYSTEM; + } + + if (aCert.organizationalUnit == "Mozilla Extensions") { + return AddonManager.SIGNEDSTATE_PRIVILEGED; + } + + return /preliminary/i.test(aCert.organizationalUnit) + ? AddonManager.SIGNEDSTATE_PRELIMINARY + : AddonManager.SIGNEDSTATE_SIGNED; + case Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED: + return AddonManager.SIGNEDSTATE_MISSING; + case Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID: + case Cr.NS_ERROR_SIGNED_JAR_ENTRY_INVALID: + case Cr.NS_ERROR_SIGNED_JAR_ENTRY_MISSING: + case Cr.NS_ERROR_SIGNED_JAR_ENTRY_TOO_LARGE: + case Cr.NS_ERROR_SIGNED_JAR_UNSIGNED_ENTRY: + case Cr.NS_ERROR_SIGNED_JAR_MODIFIED_ENTRY: + return AddonManager.SIGNEDSTATE_BROKEN; + default: + // Any other error indicates that either the add-on isn't signed or it + // is signed by a signature that doesn't chain to the trusted root. + return AddonManager.SIGNEDSTATE_UNKNOWN; + } +} + +function shouldVerifySignedState(aAddonType, aLocation) { + // TODO when KEY_APP_SYSTEM_DEFAULTS and KEY_APP_SYSTEM_ADDONS locations + // are removed, we need to reorganize the logic here. At that point we + // should: + // if builtin or MOZ_UNSIGNED_SCOPES return false + // if system return true + // return SIGNED_TYPES.has(type) + + // We don't care about signatures for default system add-ons + if (aLocation.name == XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS) { + return false; + } + + // Updated system add-ons should always have their signature checked + if (aLocation.isSystem) { + return true; + } + + if ( + aLocation.isBuiltin || + aLocation.scope & AppConstants.MOZ_UNSIGNED_SCOPES + ) { + return false; + } + + // Otherwise only check signatures if the add-on is one of the signed + // types. + return XPIExports.XPIDatabase.SIGNED_TYPES.has(aAddonType); +} + +/** + * Verifies that a bundle's contents are all correctly signed by an + * AMO-issued certificate + * + * @param {nsIFile} aBundle + * The nsIFile for the bundle to check, either a directory or zip file. + * @param {AddonInternal} aAddon + * The add-on object to verify. + * @returns {Promise} + * A Promise that resolves to an AddonManager.SIGNEDSTATE_* constant. + */ +export var verifyBundleSignedState = async function (aBundle, aAddon) { + let pkg = Package.get(aBundle); + try { + let { signedState } = await pkg.verifySignedState( + aAddon.id, + aAddon.type, + aAddon.location + ); + return signedState; + } finally { + pkg.close(); + } +}; + +/** + * Replaces %...% strings in an addon url (update and updateInfo) with + * appropriate values. + * + * @param {AddonInternal} aAddon + * The AddonInternal representing the add-on + * @param {string} aUri + * The URI to escape + * @param {integer?} aUpdateType + * An optional number representing the type of update, only applicable + * when creating a url for retrieving an update manifest + * @param {string?} aAppVersion + * The optional application version to use for %APP_VERSION% + * @returns {string} + * The appropriately escaped URI. + */ +function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion) { + let uri = AddonManager.escapeAddonURI(aAddon, aUri, aAppVersion); + + // If there is an updateType then replace the UPDATE_TYPE string + if (aUpdateType) { + uri = uri.replace(/%UPDATE_TYPE%/g, aUpdateType); + } + + // If this add-on has compatibility information for either the current + // application or toolkit then replace the ITEM_MAXAPPVERSION with the + // maxVersion + let app = aAddon.matchingTargetApplication; + if (app) { + var maxVersion = app.maxVersion; + } else { + maxVersion = ""; + } + uri = uri.replace(/%ITEM_MAXAPPVERSION%/g, maxVersion); + + let compatMode = "normal"; + if (!AddonManager.checkCompatibility) { + compatMode = "ignore"; + } else if (AddonManager.strictCompatibility) { + compatMode = "strict"; + } + uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode); + + return uri; +} + +/** + * Converts an iterable of addon objects into a map with the add-on's ID as key. + * + * @param {sequence} addons + * A sequence of AddonInternal objects. + * + * @returns {Map} + */ +function addonMap(addons) { + return new Map(addons.map(a => [a.id, a])); +} + +async function removeAsync(aFile) { + await IOUtils.remove(aFile.path, { ignoreAbsent: true, recursive: true }); +} + +/** + * Recursively removes a directory or file fixing permissions when necessary. + * + * @param {nsIFile} aFile + * The nsIFile to remove + */ +function recursiveRemove(aFile) { + let isDir = null; + + try { + isDir = aFile.isDirectory(); + } catch (e) { + // If the file has already gone away then don't worry about it, this can + // happen on OSX where the resource fork is automatically moved with the + // data fork for the file. See bug 733436. + if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) { + return; + } + + throw e; + } + + setFilePermissions( + aFile, + isDir ? lazy.FileUtils.PERMS_DIRECTORY : lazy.FileUtils.PERMS_FILE + ); + + try { + aFile.remove(true); + return; + } catch (e) { + if (!aFile.isDirectory() || aFile.isSymlink()) { + logger.error("Failed to remove file " + aFile.path, e); + throw e; + } + } + + // Use a snapshot of the directory contents to avoid possible issues with + // iterating over a directory while removing files from it (the YAFFS2 + // embedded filesystem has this issue, see bug 772238), and to remove + // normal files before their resource forks on OSX (see bug 733436). + let entries = Array.from(XPIExports.XPIInternal.iterDirectory(aFile)); + entries.forEach(recursiveRemove); + + try { + aFile.remove(true); + } catch (e) { + logger.error("Failed to remove empty directory " + aFile.path, e); + throw e; + } +} + +/** + * Sets permissions on a file + * + * @param {nsIFile} aFile + * The file or directory to operate on. + * @param {integer} aPermissions + * The permissions to set + */ +function setFilePermissions(aFile, aPermissions) { + try { + aFile.permissions = aPermissions; + } catch (e) { + logger.warn( + "Failed to set permissions " + + aPermissions.toString(8) + + " on " + + aFile.path, + e + ); + } +} + +/** + * Write a given string to a file + * + * @param {nsIFile} file + * The nsIFile instance to write into + * @param {string} string + * The string to write + */ +function writeStringToFile(file, string) { + let fileStream = new FileOutputStream( + file, + lazy.FileUtils.MODE_WRONLY | + lazy.FileUtils.MODE_CREATE | + lazy.FileUtils.MODE_TRUNCATE, + lazy.FileUtils.PERMS_FILE, + 0 + ); + + try { + let binStream = new BinaryOutputStream(fileStream); + + binStream.writeByteArray(new TextEncoder().encode(string)); + } finally { + fileStream.close(); + } +} + +/** + * A safe way to install a file or the contents of a directory to a new + * directory. The file or directory is moved or copied recursively and if + * anything fails an attempt is made to rollback the entire operation. The + * operation may also be rolled back to its original state after it has + * completed by calling the rollback method. + * + * Operations can be chained. Calling move or copy multiple times will remember + * the whole set and if one fails all of the operations will be rolled back. + */ +function SafeInstallOperation() { + this._installedFiles = []; + this._createdDirs = []; +} + +SafeInstallOperation.prototype = { + _installedFiles: null, + _createdDirs: null, + + _installFile(aFile, aTargetDirectory, aCopy) { + let oldFile = aCopy ? null : aFile.clone(); + let newFile = aFile.clone(); + try { + if (aCopy) { + newFile.copyTo(aTargetDirectory, null); + // copyTo does not update the nsIFile with the new. + newFile = getFile(aFile.leafName, aTargetDirectory); + // Windows roaming profiles won't properly sync directories if a new file + // has an older lastModifiedTime than a previous file, so update. + newFile.lastModifiedTime = Date.now(); + } else { + newFile.moveTo(aTargetDirectory, null); + } + } catch (e) { + logger.error( + "Failed to " + + (aCopy ? "copy" : "move") + + " file " + + aFile.path + + " to " + + aTargetDirectory.path, + e + ); + throw e; + } + this._installedFiles.push({ oldFile, newFile }); + }, + + /** + * Moves a file or directory into a new directory. If an error occurs then all + * files that have been moved will be moved back to their original location. + * + * @param {nsIFile} aFile + * The file or directory to be moved. + * @param {nsIFile} aTargetDirectory + * The directory to move into, this is expected to be an empty + * directory. + */ + moveUnder(aFile, aTargetDirectory) { + try { + this._installFile(aFile, aTargetDirectory, false); + } catch (e) { + this.rollback(); + throw e; + } + }, + + /** + * Renames a file to a new location. If an error occurs then all + * files that have been moved will be moved back to their original location. + * + * @param {nsIFile} aOldLocation + * The old location of the file. + * @param {nsIFile} aNewLocation + * The new location of the file. + */ + moveTo(aOldLocation, aNewLocation) { + try { + let oldFile = aOldLocation.clone(), + newFile = aNewLocation.clone(); + oldFile.moveTo(newFile.parent, newFile.leafName); + this._installedFiles.push({ oldFile, newFile, isMoveTo: true }); + } catch (e) { + this.rollback(); + throw e; + } + }, + + /** + * Copies a file or directory into a new directory. If an error occurs then + * all new files that have been created will be removed. + * + * @param {nsIFile} aFile + * The file or directory to be copied. + * @param {nsIFile} aTargetDirectory + * The directory to copy into, this is expected to be an empty + * directory. + */ + copy(aFile, aTargetDirectory) { + try { + this._installFile(aFile, aTargetDirectory, true); + } catch (e) { + this.rollback(); + throw e; + } + }, + + /** + * Rolls back all the moves that this operation performed. If an exception + * occurs here then both old and new directories are left in an indeterminate + * state + */ + rollback() { + while (this._installedFiles.length) { + let move = this._installedFiles.pop(); + if (move.isMoveTo) { + move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName); + } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) { + let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent); + oldDir.create( + Ci.nsIFile.DIRECTORY_TYPE, + lazy.FileUtils.PERMS_DIRECTORY + ); + } else if (!move.oldFile) { + // No old file means this was a copied file + move.newFile.remove(true); + } else { + move.newFile.moveTo(move.oldFile.parent, null); + } + } + + while (this._createdDirs.length) { + recursiveRemove(this._createdDirs.pop()); + } + }, +}; + +// A hash algorithm if the caller of AddonInstall did not specify one. +const DEFAULT_HASH_ALGO = "sha256"; + +/** + * Base class for objects that manage the installation of an addon. + * This class isn't instantiated directly, see the derived classes below. + */ +class AddonInstall { + /** + * Instantiates an AddonInstall. + * + * @param {XPIStateLocation} installLocation + * The install location the add-on will be installed into + * @param {nsIURL} url + * The nsIURL to get the add-on from. If this is an nsIFileURL then + * the add-on will not need to be downloaded + * @param {Object} [options = {}] + * Additional options for the install + * @param {string} [options.hash] + * An optional hash for the add-on + * @param {AddonInternal} [options.existingAddon] + * The add-on this install will update if known + * @param {string} [options.name] + * An optional name for the add-on + * @param {string} [options.type] + * An optional type for the add-on + * @param {object} [options.icons] + * Optional icons for the add-on + * @param {string} [options.version] + * The expected version for the add-on. + * Required for updates, i.e. when existingAddon is set. + * @param {Object?} [options.telemetryInfo] + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @param {boolean} [options.isUserRequestedUpdate] + * An optional boolean, true if the install object is related to a user triggered update. + * @param {nsIURL} [options.releaseNotesURI] + * An optional nsIURL that release notes where release notes can be retrieved. + * @param {function(string) : Promise} [options.promptHandler] + * A callback to prompt the user before installing. + */ + constructor(installLocation, url, options = {}) { + this.wrapper = new AddonInstallWrapper(this); + this.location = installLocation; + this.sourceURI = url; + + if (options.hash) { + let hashSplit = options.hash.toLowerCase().split(":"); + this.originalHash = { + algorithm: hashSplit[0], + data: hashSplit[1], + }; + } + this.hash = this.originalHash; + this.fileHash = null; + this.existingAddon = options.existingAddon || null; + this.promptHandler = options.promptHandler || (() => Promise.resolve()); + this.releaseNotesURI = options.releaseNotesURI || null; + + this._startupPromise = null; + + this._installPromise = new Promise(resolve => { + this._resolveInstallPromise = resolve; + }); + // Ignore uncaught rejections for this promise, since they're + // handled by install listeners. + this._installPromise.catch(() => {}); + + this.listeners = []; + this.icons = options.icons || {}; + this.error = 0; + + this.progress = 0; + this.maxProgress = -1; + + // Giving each instance of AddonInstall a reference to the logger. + this.logger = logger; + + this.name = options.name || null; + this.type = options.type || null; + this.version = options.version || null; + this.isUserRequestedUpdate = options.isUserRequestedUpdate; + this.installTelemetryInfo = null; + + if (options.telemetryInfo) { + this.installTelemetryInfo = options.telemetryInfo; + } else if (this.existingAddon) { + // Inherits the installTelemetryInfo on updates (so that the source of the original + // installation telemetry data is being preserved across the extension updates). + this.installTelemetryInfo = this.existingAddon.installTelemetryInfo; + this.existingAddon._updateInstall = this; + } + + this.file = null; + this.ownsTempFile = null; + + this.addon = null; + this.state = null; + + XPIInstall.installs.add(this); + } + + /** + * Called when we are finished with this install and are ready to remove + * any external references to it. + */ + _cleanup() { + XPIInstall.installs.delete(this); + if (this.addon && this.addon._install) { + if (this.addon._install === this) { + this.addon._install = null; + } else { + Cu.reportError(new Error("AddonInstall mismatch")); + } + } + if (this.existingAddon && this.existingAddon._updateInstall) { + if (this.existingAddon._updateInstall === this) { + this.existingAddon._updateInstall = null; + } else { + Cu.reportError(new Error("AddonInstall existingAddon mismatch")); + } + } + } + + /** + * Starts installation of this add-on from whatever state it is currently at + * if possible. + * + * Note this method is overridden to handle additional state in + * the subclassses below. + * + * @returns {Promise} + * @throws if installation cannot proceed from the current state + */ + install() { + switch (this.state) { + case AddonManager.STATE_DOWNLOADED: + this.checkPrompt(); + break; + case AddonManager.STATE_PROMPTS_DONE: + this.checkForBlockers(); + break; + case AddonManager.STATE_READY: + this.startInstall(); + break; + case AddonManager.STATE_POSTPONED: + logger.debug(`Postponing install of ${this.addon.id}`); + break; + case AddonManager.STATE_DOWNLOADING: + case AddonManager.STATE_CHECKING_UPDATE: + case AddonManager.STATE_INSTALLING: + // Installation is already running + break; + default: + throw new Error("Cannot start installing from this state"); + } + return this._installPromise; + } + + continuePostponedInstall() { + if (this.state !== AddonManager.STATE_POSTPONED) { + throw new Error("AddonInstall not in postponed state"); + } + + // Force the postponed install to continue. + logger.info(`${this.addon.id} has resumed a previously postponed upgrade`); + this.state = AddonManager.STATE_READY; + this.install(); + } + + /** + * Called during XPIProvider shutdown so that we can do any necessary + * pre-shutdown cleanup. + */ + onShutdown() { + switch (this.state) { + case AddonManager.STATE_POSTPONED: + this.removeTemporaryFile(); + break; + } + } + + /** + * Cancels installation of this add-on. + * + * Note this method is overridden to handle additional state in + * the subclass DownloadAddonInstall. + * + * @throws if installation cannot be cancelled from the current state + */ + cancel() { + switch (this.state) { + case AddonManager.STATE_AVAILABLE: + case AddonManager.STATE_DOWNLOADED: + logger.debug("Cancelling download of " + this.sourceURI.spec); + this.state = AddonManager.STATE_CANCELLED; + this._cleanup(); + this._callInstallListeners("onDownloadCancelled"); + this.removeTemporaryFile(); + break; + case AddonManager.STATE_POSTPONED: + logger.debug(`Cancelling postponed install of ${this.addon.id}`); + this.state = AddonManager.STATE_CANCELLED; + this._cleanup(); + this._callInstallListeners( + "onInstallCancelled", + /* aCancelledByUser */ false + ); + this.removeTemporaryFile(); + + let stagingDir = this.location.installer.getStagingDir(); + let stagedAddon = stagingDir.clone(); + + this.unstageInstall(stagedAddon); + break; + default: + throw new Error( + "Cannot cancel install of " + + this.sourceURI.spec + + " from this state (" + + this.state + + ")" + ); + } + } + + /** + * Adds an InstallListener for this instance if the listener is not already + * registered. + * + * @param {InstallListener} aListener + * The InstallListener to add + */ + addListener(aListener) { + if ( + !this.listeners.some(function (i) { + return i == aListener; + }) + ) { + this.listeners.push(aListener); + } + } + + /** + * Removes an InstallListener for this instance if it is registered. + * + * @param {InstallListener} aListener + * The InstallListener to remove + */ + removeListener(aListener) { + this.listeners = this.listeners.filter(function (i) { + return i != aListener; + }); + } + + /** + * Removes the temporary file owned by this AddonInstall if there is one. + */ + removeTemporaryFile() { + // Only proceed if this AddonInstall owns its XPI file + if (!this.ownsTempFile) { + this.logger.debug( + `removeTemporaryFile: ${this.sourceURI.spec} does not own temp file` + ); + return; + } + + try { + this.logger.debug( + `removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` + + this.file.path + ); + flushJarCache(this.file); + this.file.remove(true); + this.ownsTempFile = false; + } catch (e) { + this.logger.warn( + `Failed to remove temporary file ${this.file.path} for addon ` + + this.sourceURI.spec, + e + ); + } + } + + _setFileHash(calculatedHash) { + this.fileHash = { + algorithm: this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO, + data: calculatedHash, + }; + + if (this.hash && calculatedHash != this.hash.data) { + return false; + } + return true; + } + + /** + * Updates the addon metadata that has to be propagated across restarts. + */ + updatePersistedMetadata() { + this.addon.sourceURI = this.sourceURI.spec; + + if (this.releaseNotesURI) { + this.addon.releaseNotesURI = this.releaseNotesURI.spec; + } + + if (this.installTelemetryInfo) { + this.addon.installTelemetryInfo = this.installTelemetryInfo; + } + } + + /** + * Called after the add-on is a local file and the signature and install + * manifest can be read. + * + * @param {nsIFile} file + * The file from which to load the manifest. + * @returns {Promise} + */ + async loadManifest(file) { + let pkg; + try { + pkg = Package.get(file); + } catch (e) { + return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]); + } + + try { + try { + this.addon = await loadManifest(pkg, this.location, this.existingAddon); + } catch (e) { + return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]); + } + + if (!this.addon.id) { + let msg = `Cannot find id for addon ${file.path}.`; + if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) { + msg += ` Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`; + } + + return Promise.reject([ + AddonManager.ERROR_CORRUPT_FILE, + new Error(msg), + ]); + } + + if ( + AppConstants.platform == "android" && + this.addon.type !== "extension" + ) { + return Promise.reject([ + AddonManager.ERROR_UNSUPPORTED_ADDON_TYPE, + `Unsupported add-on type: ${this.addon.type}`, + ]); + } + + if (this.existingAddon) { + // Check various conditions related to upgrades + if (this.addon.id != this.existingAddon.id) { + return Promise.reject([ + AddonManager.ERROR_INCORRECT_ID, + `Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`, + ]); + } + + if (this.existingAddon.isWebExtension && !this.addon.isWebExtension) { + // This condition is never met on regular Firefox builds. + // Remove it along with externalExtensionLoaders (bug 1674799). + return Promise.reject([ + AddonManager.ERROR_UNEXPECTED_ADDON_TYPE, + "WebExtensions may not be updated to other extension types", + ]); + } + if (this.existingAddon.type != this.addon.type) { + return Promise.reject([ + AddonManager.ERROR_UNEXPECTED_ADDON_TYPE, + `Refusing to change addon type from ${this.existingAddon.type} to ${this.addon.type}`, + ]); + } + + if (this.version !== this.addon.version) { + return Promise.reject([ + AddonManager.ERROR_UNEXPECTED_ADDON_VERSION, + `Expected addon version ${this.version} instead of ${this.addon.version}`, + ]); + } + } + + if (XPIExports.XPIDatabase.mustSign(this.addon.type)) { + if (this.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) { + // This add-on isn't properly signed by a signature that chains to the + // trusted root. + let state = this.addon.signedState; + this.addon = null; + + if (state == AddonManager.SIGNEDSTATE_MISSING) { + return Promise.reject([ + AddonManager.ERROR_SIGNEDSTATE_REQUIRED, + "signature is required but missing", + ]); + } + + return Promise.reject([ + AddonManager.ERROR_CORRUPT_FILE, + "signature verification failed", + ]); + } + } + } finally { + pkg.close(); + } + + this.updatePersistedMetadata(); + + this.addon._install = this; + this.name = this.addon.selectedLocale.name; + this.type = this.addon.type; + this.version = this.addon.version; + + // Setting the iconURL to something inside the XPI locks the XPI and + // makes it impossible to delete on Windows. + + // Try to load from the existing cache first + let repoAddon = await lazy.AddonRepository.getCachedAddonByID( + this.addon.id + ); + + // It wasn't there so try to re-download it + if (!repoAddon) { + try { + [repoAddon] = await lazy.AddonRepository.cacheAddons([this.addon.id]); + } catch (err) { + logger.debug( + `Error getting metadata for ${this.addon.id}: ${err.message}` + ); + } + } + + this.addon._repositoryAddon = repoAddon; + this.name = this.name || this.addon._repositoryAddon.name; + this.addon.appDisabled = !XPIExports.XPIDatabase.isUsableAddon(this.addon); + return undefined; + } + + getIcon(desiredSize = 64) { + if (!this.addon.icons || !this.file) { + return null; + } + + let { icon } = lazy.IconDetails.getPreferredIcon( + this.addon.icons, + null, + desiredSize + ); + if (icon.startsWith("chrome://")) { + return icon; + } + return getJarURI(this.file, icon).spec; + } + + /** + * This method should be called when the XPI is ready to be installed, + * i.e., when a download finishes or when a local file has been verified. + * It should only be called from install() when the install is in + * STATE_DOWNLOADED (which actually means that the file is available + * and has been verified). + */ + checkPrompt() { + (async () => { + if (this.promptHandler) { + let info = { + existingAddon: this.existingAddon ? this.existingAddon.wrapper : null, + addon: this.addon.wrapper, + icon: this.getIcon(), + // Used in AMTelemetry to detect the install flow related to this prompt. + install: this.wrapper, + }; + + try { + await this.promptHandler(info); + } catch (err) { + if (this.error < 0) { + logger.info(`Install of ${this.addon.id} failed ${this.error}`); + this.state = AddonManager.STATE_INSTALL_FAILED; + this._cleanup(); + // In some cases onOperationCancelled is called during failures + // to install/uninstall/enable/disable addons. We may need to + // do that here in the future. + this._callInstallListeners("onInstallFailed"); + this.removeTemporaryFile(); + } else { + logger.info(`Install of ${this.addon.id} cancelled by user`); + this.state = AddonManager.STATE_CANCELLED; + this._cleanup(); + this._callInstallListeners( + "onInstallCancelled", + /* aCancelledByUser */ true + ); + } + return; + } + } + this.state = AddonManager.STATE_PROMPTS_DONE; + this.install(); + })(); + } + + /** + * This method should be called when we have the XPI and any needed + * permissions prompts have been completed. If there are any upgrade + * listeners, they are invoked and the install moves into STATE_POSTPONED. + * Otherwise, the install moves into STATE_INSTALLING + */ + checkForBlockers() { + // If an upgrade listener is registered for this add-on, pass control + // over the upgrade to the add-on. + if (AddonManagerPrivate.hasUpgradeListener(this.addon.id)) { + logger.info( + `add-on ${this.addon.id} has an upgrade listener, postponing upgrade until restart` + ); + let resumeFn = () => { + this.continuePostponedInstall(); + }; + this.postpone(resumeFn); + return; + } + + this.state = AddonManager.STATE_READY; + this.install(); + } + + /** + * Installs the add-on into the install location. + */ + async startInstall() { + this.state = AddonManager.STATE_INSTALLING; + if (!this._callInstallListeners("onInstallStarted")) { + this.state = AddonManager.STATE_DOWNLOADED; + this.removeTemporaryFile(); + this._cleanup(); + this._callInstallListeners( + "onInstallCancelled", + /* aCancelledByUser */ false + ); + return; + } + + // Reinstall existing user-disabled addon (of the same installed version). + // If addon is marked to be uninstalled - don't reinstall it. + if ( + this.existingAddon && + this.existingAddon.location === this.location && + this.existingAddon.version === this.addon.version && + this.existingAddon.userDisabled && + !this.existingAddon.pendingUninstall + ) { + await XPIExports.XPIDatabase.updateAddonDisabledState( + this.existingAddon, + { + userDisabled: false, + } + ); + this.state = AddonManager.STATE_INSTALLED; + this._callInstallListeners("onInstallEnded", this.existingAddon.wrapper); + this._cleanup(); + return; + } + + let isSameLocation = this.existingAddon?.location == this.location; + let willActivate = + isSameLocation || + !this.existingAddon || + this.location.hasPrecedence(this.existingAddon.location); + + logger.debug( + "Starting install of " + this.addon.id + " from " + this.sourceURI.spec + ); + AddonManagerPrivate.callAddonListeners( + "onInstalling", + this.addon.wrapper, + false + ); + + let stagedAddon = this.location.installer.getStagingDir(); + + try { + await this.location.installer.requestStagingDir(); + + // remove any previously staged files + await this.unstageInstall(stagedAddon); + + stagedAddon.append(`${this.addon.id}.xpi`); + + await this.stageInstall(false, stagedAddon, isSameLocation); + + this._cleanup(); + + let install = async () => { + // Mark this instance of the addon as inactive if it is being + // superseded by an addon in a different location. + if ( + willActivate && + this.existingAddon && + this.existingAddon.active && + !isSameLocation + ) { + XPIExports.XPIDatabase.updateAddonActive(this.existingAddon, false); + } + + // Install the new add-on into its final location + let file = await this.location.installer.installAddon({ + id: this.addon.id, + source: stagedAddon, + }); + + // Update the metadata in the database + this.addon.sourceBundle = file; + // If this addon will be the active addon, make it visible. + this.addon.visible = willActivate; + + if (isSameLocation) { + this.addon = XPIExports.XPIDatabase.updateAddonMetadata( + this.existingAddon, + this.addon, + file.path + ); + let state = this.location.get(this.addon.id); + if (state) { + state.syncWithDB(this.addon, true); + } else { + logger.warn( + "Unexpected missing XPI state for add-on ${id}", + this.addon + ); + } + } else { + this.addon.active = this.addon.visible && !this.addon.disabled; + this.addon = XPIExports.XPIDatabase.addToDatabase( + this.addon, + file.path + ); + XPIExports.XPIInternal.XPIStates.addAddon(this.addon); + this.addon.installDate = this.addon.updateDate; + XPIExports.XPIDatabase.saveChanges(); + } + XPIExports.XPIInternal.XPIStates.save(); + + AddonManagerPrivate.callAddonListeners( + "onInstalled", + this.addon.wrapper + ); + + logger.debug(`Install of ${this.sourceURI.spec} completed.`); + this.state = AddonManager.STATE_INSTALLED; + this._callInstallListeners("onInstallEnded", this.addon.wrapper); + + XPIExports.XPIDatabase.recordAddonTelemetry(this.addon); + + // Notify providers that a new theme has been enabled. + if (this.addon.type === "theme" && this.addon.active) { + AddonManagerPrivate.notifyAddonChanged( + this.addon.id, + this.addon.type + ); + } + + // Clear the colorways builtins migrated to a non-builtin themes + // form the list of the retained themes. + if ( + this.existingAddon?.isBuiltinColorwayTheme && + !this.addon.isBuiltin && + XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled + ) { + XPIExports.BuiltInThemesHelpers.unretainMigratedColorwayTheme( + this.addon.id + ); + } + }; + + this._startupPromise = (async () => { + if (!willActivate) { + await install(); + } else if (this.existingAddon) { + await XPIExports.XPIInternal.BootstrapScope.get( + this.existingAddon + ).update(this.addon, !this.addon.disabled, install); + + if (this.addon.disabled) { + flushJarCache(this.file); + } + } else { + await install(); + await XPIExports.XPIInternal.BootstrapScope.get(this.addon).install( + undefined, + true + ); + } + })(); + + await this._startupPromise; + } catch (e) { + logger.warn( + `Failed to install ${this.file.path} from ${this.sourceURI.spec} to ${stagedAddon.path}`, + e + ); + + if (stagedAddon.exists()) { + recursiveRemove(stagedAddon); + } + this.state = AddonManager.STATE_INSTALL_FAILED; + this.error = AddonManager.ERROR_FILE_ACCESS; + this._cleanup(); + AddonManagerPrivate.callAddonListeners( + "onOperationCancelled", + this.addon.wrapper + ); + this._callInstallListeners("onInstallFailed"); + } finally { + this.removeTemporaryFile(); + this.location.installer.releaseStagingDir(); + } + } + + /** + * Stages an add-on for install. + * + * @param {boolean} restartRequired + * If true, the final installation will be deferred until the + * next app startup. + * @param {nsIFile} stagedAddon + * The file where the add-on should be staged. + * @param {boolean} isSameLocation + * True if this installation is an upgrade for an existing + * add-on in the same location. + * @throws if the file cannot be staged. + */ + async stageInstall(restartRequired, stagedAddon, isSameLocation) { + logger.debug(`Addon ${this.addon.id} will be installed as a packed xpi`); + stagedAddon.leafName = `${this.addon.id}.xpi`; + + try { + await IOUtils.copy(this.file.path, stagedAddon.path); + + let calculatedHash = getHashForFile(stagedAddon, this.fileHash.algorithm); + if (calculatedHash != this.fileHash.data) { + logger.warn( + `Staged file hash (${calculatedHash}) did not match initial hash (${this.fileHash.data})` + ); + throw new Error("Refusing to stage add-on because it has been damaged"); + } + } catch (e) { + await IOUtils.remove(stagedAddon.path, { ignoreAbsent: true }); + throw e; + } + + if (restartRequired) { + // Point the add-on to its extracted files as the xpi may get deleted + this.addon.sourceBundle = stagedAddon; + + logger.debug( + `Staged install of ${this.addon.id} from ${this.sourceURI.spec} ready; waiting for restart.` + ); + if (isSameLocation) { + delete this.existingAddon.pendingUpgrade; + this.existingAddon.pendingUpgrade = this.addon; + } + } + + if (this.state === AddonManager.STATE_POSTPONED) { + // Cache the AddonInternal as it may have updated compatibility info. We + // do that unconditionally in case the staged install isn't finalized in + // the same session. That way, on the next app startup, the add-on will + // be installed. + this.location.stageAddon(this.addon.id, this.addon.toJSON()); + } + } + + /** + * Removes any previously staged upgrade. + * + * @param {nsIFile} stagingDir + * The staging directory from which to unstage the install. + */ + async unstageInstall(stagingDir) { + this.location.unstageAddon(this.addon.id); + + await removeAsync(getFile(this.addon.id, stagingDir)); + + await removeAsync(getFile(`${this.addon.id}.xpi`, stagingDir)); + } + + /** + * Postone a pending update, until restart or until the add-on resumes. + * + * @param {function} resumeFn + * A function for the add-on to run when resuming. + * @param {boolean} requiresRestart + * Whether this add-on requires restart. + */ + async postpone(resumeFn, requiresRestart = true) { + this.state = AddonManager.STATE_POSTPONED; + + let stagingDir = this.location.installer.getStagingDir(); + + try { + await this.location.installer.requestStagingDir(); + await this.unstageInstall(stagingDir); + + let stagedAddon = getFile(`${this.addon.id}.xpi`, stagingDir); + + await this.stageInstall(requiresRestart, stagedAddon, true); + } catch (e) { + logger.warn(`Failed to postpone install of ${this.addon.id}`, e); + this.state = AddonManager.STATE_INSTALL_FAILED; + this.error = AddonManager.ERROR_FILE_ACCESS; + this._cleanup(); + this.removeTemporaryFile(); + this.location.installer.releaseStagingDir(); + this._callInstallListeners("onInstallFailed"); + return; + } + + this._callInstallListeners("onInstallPostponed"); + + // upgrade has been staged for restart, provide a way for it to call the + // resume function. + let callback = AddonManagerPrivate.getUpgradeListener(this.addon.id); + if (callback) { + callback({ + version: this.version, + install: () => { + switch (this.state) { + case AddonManager.STATE_POSTPONED: + if (resumeFn) { + resumeFn(); + } + break; + default: + logger.warn( + `${this.addon.id} cannot resume postponed upgrade from state (${this.state})` + ); + break; + } + }, + }); + } + // Release the staging directory lock, but since the staging dir is populated + // it will not be removed until resumed or installed by restart. + // See also cleanStagingDir() + this.location.installer.releaseStagingDir(); + } + + _callInstallListeners(event, ...args) { + switch (event) { + case "onDownloadCancelled": + case "onDownloadFailed": + case "onInstallCancelled": + case "onInstallFailed": + let rej = Promise.reject(new Error(`Install failed: ${event}`)); + rej.catch(() => {}); + this._resolveInstallPromise(rej); + break; + case "onInstallEnded": + this._resolveInstallPromise( + Promise.resolve(this._startupPromise).then(() => args[0]) + ); + break; + } + return AddonManagerPrivate.callInstallListeners( + event, + this.listeners, + this.wrapper, + ...args + ); + } +} + +var LocalAddonInstall = class extends AddonInstall { + /** + * Initialises this install to be an install from a local file. + */ + async init() { + this.file = this.sourceURI.QueryInterface(Ci.nsIFileURL).file; + + if (!this.file.exists()) { + logger.warn("XPI file " + this.file.path + " does not exist"); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_NETWORK_FAILURE; + this._cleanup(); + return; + } + + this.state = AddonManager.STATE_DOWNLOADED; + this.progress = this.file.fileSize; + this.maxProgress = this.file.fileSize; + + let algorithm = this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO; + if (this.hash) { + try { + CryptoHash(this.hash.algorithm); + } catch (e) { + logger.warn( + "Unknown hash algorithm '" + + this.hash.algorithm + + "' for addon " + + this.sourceURI.spec, + e + ); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_INCORRECT_HASH; + this._cleanup(); + return; + } + } + + if (!this._setFileHash(getHashForFile(this.file, algorithm))) { + logger.warn( + `File hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})` + ); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_INCORRECT_HASH; + this._cleanup(); + return; + } + + try { + await this.loadManifest(this.file); + } catch ([error, message]) { + logger.warn("Invalid XPI", message); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = error; + this._cleanup(); + this._callInstallListeners("onNewInstall"); + flushJarCache(this.file); + return; + } + + let addon = await XPIExports.XPIDatabase.getVisibleAddonForID( + this.addon.id + ); + + this.existingAddon = addon; + this.addon.propagateDisabledState(this.existingAddon); + await this.addon.updateBlocklistState(); + this.addon.updateDate = Date.now(); + this.addon.installDate = addon ? addon.installDate : this.addon.updateDate; + + // Report if blocked add-on becomes unblocked through this install. + if ( + addon?.blocklistState === nsIBlocklistService.STATE_BLOCKED && + this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED + ) { + this.addon.recordAddonBlockChangeTelemetry("addon_install"); + } + + if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) { + this.error = AddonManager.ERROR_BLOCKLISTED; + } + + if (!this.addon.isCompatible) { + this.state = AddonManager.STATE_CHECKING_UPDATE; + + await new Promise(resolve => { + new UpdateChecker( + this.addon, + { + onUpdateFinished: (aAddon, aError) => { + this.state = AddonManager.STATE_DOWNLOADED; + // If checking for an updated compatibility range fails or the + // add-on is still incompatible, then set the expected + // `install.error` to `ERROR_INCOMPATIBLE`. + if (!this.addon.isCompatible) { + this.error = AddonManager.ERROR_INCOMPATIBLE; + } + if (aError < 0) { + logger.warn( + `UpdateChecker failed to download updates for ${this.addon.id}, error code: ${aError}` + ); + } else { + this._callInstallListeners("onNewInstall"); + } + resolve(); + }, + }, + AddonManager.UPDATE_WHEN_ADDON_INSTALLED + ); + }); + } else { + this._callInstallListeners("onNewInstall"); + } + } + + install() { + if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) { + // For a local install, this state means that verification of the + // file failed (e.g., the hash or signature or manifest contents + // were invalid). It doesn't make sense to retry anything in this + // case but we have callers who don't know if their AddonInstall + // object is a local file or a download so accommodate them here. + this._callInstallListeners("onDownloadFailed"); + return this._installPromise; + } + return super.install(); + } +}; + +var DownloadAddonInstall = class extends AddonInstall { + /** + * Instantiates a DownloadAddonInstall + * + * @param {XPIStateLocation} installLocation + * The XPIStateLocation the add-on will be installed into + * @param {nsIURL} url + * The nsIURL to get the add-on from + * @param {Object} [options = {}] + * Additional options for the install + * @param {string} [options.hash] + * An optional hash for the add-on + * @param {AddonInternal} [options.existingAddon] + * The add-on this install will update if known + * @param {XULElement} [options.browser] + * The browser performing the install, used to display + * authentication prompts. + * @param {nsIPrincipal} [options.principal] + * The principal to use. If not present, will default to browser.contentPrincipal. + * @param {string} [options.name] + * An optional name for the add-on + * @param {string} [options.type] + * An optional type for the add-on + * @param {Object} [options.icons] + * Optional icons for the add-on + * @param {string} [options.version] + * The expected version for the add-on. + * Required for updates, i.e. when existingAddon is set. + * @param {function(string) : Promise} [options.promptHandler] + * A callback to prompt the user before installing. + * @param {boolean} [options.sendCookies] + * Whether cookies should be sent when downloading the add-on. + */ + constructor(installLocation, url, options = {}) { + super(installLocation, url, options); + + this.browser = options.browser; + this.loadingPrincipal = + options.triggeringPrincipal || + (this.browser && this.browser.contentPrincipal) || + Services.scriptSecurityManager.getSystemPrincipal(); + this.sendCookies = Boolean(options.sendCookies); + + this.state = AddonManager.STATE_AVAILABLE; + + this.stream = null; + this.crypto = null; + this.badCertHandler = null; + this.restartDownload = false; + this.downloadStartedAt = null; + + this._callInstallListeners("onNewInstall", this.listeners, this.wrapper); + } + + install() { + switch (this.state) { + case AddonManager.STATE_AVAILABLE: + this.startDownload(); + break; + case AddonManager.STATE_DOWNLOAD_FAILED: + case AddonManager.STATE_INSTALL_FAILED: + case AddonManager.STATE_CANCELLED: + this.removeTemporaryFile(); + this.state = AddonManager.STATE_AVAILABLE; + this.error = 0; + this.progress = 0; + this.maxProgress = -1; + this.hash = this.originalHash; + this.fileHash = null; + this.startDownload(); + break; + default: + return super.install(); + } + return this._installPromise; + } + + cancel() { + // If we're done downloading the file but still processing it we cannot + // cancel the installation. We just call the base class which will handle + // the request by throwing an error. + if (this.channel && this.state == AddonManager.STATE_DOWNLOADING) { + logger.debug("Cancelling download of " + this.sourceURI.spec); + this.channel.cancel(Cr.NS_BINDING_ABORTED); + } else { + super.cancel(); + } + } + + observe(aSubject, aTopic, aData) { + // Network is going offline + this.cancel(); + } + + /** + * Starts downloading the add-on's XPI file. + */ + startDownload() { + this.downloadStartedAt = Cu.now(); + + this.state = AddonManager.STATE_DOWNLOADING; + if (!this._callInstallListeners("onDownloadStarted")) { + logger.debug( + "onDownloadStarted listeners cancelled installation of addon " + + this.sourceURI.spec + ); + this.state = AddonManager.STATE_CANCELLED; + this._cleanup(); + this._callInstallListeners("onDownloadCancelled"); + return; + } + + // If a listener changed our state then do not proceed with the download + if (this.state != AddonManager.STATE_DOWNLOADING) { + return; + } + + if (this.channel) { + // A previous download attempt hasn't finished cleaning up yet, signal + // that it should restart when complete + logger.debug("Waiting for previous download to complete"); + this.restartDownload = true; + return; + } + + this.openChannel(); + } + + openChannel() { + this.restartDownload = false; + + try { + this.file = getTemporaryFile(); + this.ownsTempFile = true; + this.stream = new FileOutputStream( + this.file, + lazy.FileUtils.MODE_WRONLY | + lazy.FileUtils.MODE_CREATE | + lazy.FileUtils.MODE_TRUNCATE, + lazy.FileUtils.PERMS_FILE, + 0 + ); + } catch (e) { + logger.warn( + "Failed to start download for addon " + this.sourceURI.spec, + e + ); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_FILE_ACCESS; + this._cleanup(); + this._callInstallListeners("onDownloadFailed"); + return; + } + + let listener = Cc[ + "@mozilla.org/network/stream-listener-tee;1" + ].createInstance(Ci.nsIStreamListenerTee); + listener.init(this, this.stream); + try { + this.badCertHandler = new lazy.CertUtils.BadCertHandler( + !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS + ); + + this.channel = lazy.NetUtil.newChannel({ + uri: this.sourceURI, + securityFlags: + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT, + contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD, + loadingPrincipal: this.loadingPrincipal, + }); + this.channel.notificationCallbacks = this; + if (this.sendCookies) { + if (this.channel instanceof Ci.nsIHttpChannelInternal) { + this.channel.forceAllowThirdPartyCookie = true; + } + } else { + this.channel.loadFlags |= Ci.nsIRequest.LOAD_ANONYMOUS; + } + this.channel.asyncOpen(listener); + + Services.obs.addObserver(this, "network:offline-about-to-go-offline"); + } catch (e) { + logger.warn( + "Failed to start download for addon " + this.sourceURI.spec, + e + ); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_NETWORK_FAILURE; + this._cleanup(); + this._callInstallListeners("onDownloadFailed"); + } + } + + /* + * Update the crypto hasher with the new data and call the progress listeners. + * + * @see nsIStreamListener + */ + onDataAvailable(aRequest, aInputstream, aOffset, aCount) { + this.crypto.updateFromStream(aInputstream, aCount); + this.progress += aCount; + if (!this._callInstallListeners("onDownloadProgress")) { + // TODO cancel the download and make it available again (bug 553024) + } + } + + /* + * Check the redirect response for a hash of the target XPI and verify that + * we don't end up on an insecure channel. + * + * @see nsIChannelEventSink + */ + asyncOnChannelRedirect(aOldChannel, aNewChannel, aFlags, aCallback) { + if ( + !this.hash && + aOldChannel.originalURI.schemeIs("https") && + aOldChannel instanceof Ci.nsIHttpChannel + ) { + try { + let hashStr = aOldChannel.getResponseHeader("X-Target-Digest"); + let hashSplit = hashStr.toLowerCase().split(":"); + this.hash = { + algorithm: hashSplit[0], + data: hashSplit[1], + }; + } catch (e) {} + } + + // Verify that we don't end up on an insecure channel if we haven't got a + // hash to verify with (see bug 537761 for discussion) + if (!this.hash) { + this.badCertHandler.asyncOnChannelRedirect( + aOldChannel, + aNewChannel, + aFlags, + aCallback + ); + } else { + aCallback.onRedirectVerifyCallback(Cr.NS_OK); + } + + this.channel = aNewChannel; + } + + /* + * This is the first chance to get at real headers on the channel. + * + * @see nsIStreamListener + */ + onStartRequest(aRequest) { + if (this.hash) { + try { + this.crypto = CryptoHash(this.hash.algorithm); + } catch (e) { + logger.warn( + "Unknown hash algorithm '" + + this.hash.algorithm + + "' for addon " + + this.sourceURI.spec, + e + ); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = AddonManager.ERROR_INCORRECT_HASH; + this._cleanup(); + this._callInstallListeners("onDownloadFailed"); + aRequest.cancel(Cr.NS_BINDING_ABORTED); + return; + } + } else { + // We always need something to consume data from the inputstream passed + // to onDataAvailable so just create a dummy cryptohasher to do that. + this.crypto = CryptoHash(DEFAULT_HASH_ALGO); + } + + this.progress = 0; + if (aRequest instanceof Ci.nsIChannel) { + try { + this.maxProgress = aRequest.contentLength; + } catch (e) {} + logger.debug( + "Download started for " + + this.sourceURI.spec + + " to file " + + this.file.path + ); + } + } + + /* + * The download is complete. + * + * @see nsIStreamListener + */ + onStopRequest(aRequest, aStatus) { + this.stream.close(); + this.channel = null; + this.badCerthandler = null; + Services.obs.removeObserver(this, "network:offline-about-to-go-offline"); + + let crypto = this.crypto; + this.crypto = null; + + // If the download was cancelled then update the state and send events + if (aStatus == Cr.NS_BINDING_ABORTED) { + if (this.state == AddonManager.STATE_DOWNLOADING) { + logger.debug("Cancelled download of " + this.sourceURI.spec); + this.state = AddonManager.STATE_CANCELLED; + this._cleanup(); + this._callInstallListeners("onDownloadCancelled"); + // If a listener restarted the download then there is no need to + // remove the temporary file + if (this.state != AddonManager.STATE_CANCELLED) { + return; + } + } + + this.removeTemporaryFile(); + if (this.restartDownload) { + this.openChannel(); + } + return; + } + + logger.debug("Download of " + this.sourceURI.spec + " completed."); + + if (Components.isSuccessCode(aStatus)) { + if ( + !(aRequest instanceof Ci.nsIHttpChannel) || + aRequest.requestSucceeded + ) { + if (!this.hash && aRequest instanceof Ci.nsIChannel) { + try { + lazy.CertUtils.checkCert( + aRequest, + !lazy.AddonSettings.INSTALL_REQUIREBUILTINCERTS + ); + } catch (e) { + this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, e); + return; + } + } + + if (!this._setFileHash(getHashStringForCrypto(crypto))) { + this.downloadFailed( + AddonManager.ERROR_INCORRECT_HASH, + `Downloaded file hash (${this.fileHash.data}) did not match provided hash (${this.hash.data})` + ); + return; + } + + this.loadManifest(this.file).then( + () => { + if (this.addon.isCompatible) { + this.downloadCompleted(); + } else { + // TODO Should we send some event here (bug 557716)? + this.state = AddonManager.STATE_CHECKING_UPDATE; + new UpdateChecker( + this.addon, + { + onUpdateFinished: aAddon => this.downloadCompleted(), + }, + AddonManager.UPDATE_WHEN_ADDON_INSTALLED + ); + } + }, + ([error, message]) => { + this.removeTemporaryFile(); + this.downloadFailed(error, message); + } + ); + } else if (aRequest instanceof Ci.nsIHttpChannel) { + this.downloadFailed( + AddonManager.ERROR_NETWORK_FAILURE, + aRequest.responseStatus + " " + aRequest.responseStatusText + ); + } else { + this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus); + } + } else { + this.downloadFailed(AddonManager.ERROR_NETWORK_FAILURE, aStatus); + } + } + + /** + * Notify listeners that the download failed. + * + * @param {string} aReason + * Something to log about the failure + * @param {integer} aError + * The error code to pass to the listeners + */ + downloadFailed(aReason, aError) { + logger.warn("Download of " + this.sourceURI.spec + " failed", aError); + this.state = AddonManager.STATE_DOWNLOAD_FAILED; + this.error = aReason; + this._cleanup(); + this._callInstallListeners("onDownloadFailed"); + + // If the listener hasn't restarted the download then remove any temporary + // file + if (this.state == AddonManager.STATE_DOWNLOAD_FAILED) { + logger.debug( + "downloadFailed: removing temp file for " + this.sourceURI.spec + ); + this.removeTemporaryFile(); + } else { + logger.debug( + "downloadFailed: listener changed AddonInstall state for " + + this.sourceURI.spec + + " to " + + this.state + ); + } + } + + /** + * Notify listeners that the download completed. + */ + async downloadCompleted() { + let wasUpdate = !!this.existingAddon; + let aAddon = await XPIExports.XPIDatabase.getVisibleAddonForID( + this.addon.id + ); + if (aAddon) { + this.existingAddon = aAddon; + } + + this.state = AddonManager.STATE_DOWNLOADED; + this.addon.updateDate = Date.now(); + + if (this.existingAddon) { + this.addon.installDate = this.existingAddon.installDate; + } else { + this.addon.installDate = this.addon.updateDate; + } + this.addon.propagateDisabledState(this.existingAddon); + await this.addon.updateBlocklistState(); + + // Report if blocked add-on becomes unblocked through this install/update. + if ( + aAddon?.blocklistState === nsIBlocklistService.STATE_BLOCKED && + this.addon.blocklistState === nsIBlocklistService.STATE_NOT_BLOCKED + ) { + this.addon.recordAddonBlockChangeTelemetry( + wasUpdate ? "addon_update" : "addon_install" + ); + } + + if (this.addon.blocklistState === nsIBlocklistService.STATE_BLOCKED) { + this.error = AddonManager.ERROR_BLOCKLISTED; + } else if (!this.addon.isCompatible) { + this.error = AddonManager.ERROR_INCOMPATIBLE; + } + + if (this._callInstallListeners("onDownloadEnded")) { + // If a listener changed our state then do not proceed with the install + if (this.state != AddonManager.STATE_DOWNLOADED) { + return; + } + + // proceed with the install state machine. + this.install(); + } + } + + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPrompt2)) { + let win = null; + if (this.browser) { + win = this.browser.contentWindow || this.browser.ownerGlobal; + } + + let factory = Cc["@mozilla.org/prompter;1"].getService( + Ci.nsIPromptFactory + ); + let prompt = factory.getPrompt(win, Ci.nsIAuthPrompt2); + + if (this.browser && prompt instanceof Ci.nsILoginManagerAuthPrompter) { + prompt.browser = this.browser; + } + + return prompt; + } else if (iid.equals(Ci.nsIChannelEventSink)) { + return this; + } + + return this.badCertHandler.getInterface(iid); + } +}; + +/** + * Creates a new AddonInstall for an update. + * + * @param {function} aCallback + * The callback to pass the new AddonInstall to + * @param {AddonInternal} aAddon + * The add-on being updated + * @param {Object} aUpdate + * The metadata about the new version from the update manifest + * @param {boolean} isUserRequested + * An optional boolean, true if the install object is related to a user triggered update. + */ +function createUpdate(aCallback, aAddon, aUpdate, isUserRequested) { + let url = Services.io.newURI(aUpdate.updateURL); + + (async function () { + let opts = { + hash: aUpdate.updateHash, + existingAddon: aAddon, + name: aAddon.selectedLocale.name, + type: aAddon.type, + icons: aAddon.icons, + version: aUpdate.version, + isUserRequestedUpdate: isUserRequested, + }; + + try { + if (aUpdate.updateInfoURL) { + opts.releaseNotesURI = Services.io.newURI( + escapeAddonURI(aAddon, aUpdate.updateInfoURL) + ); + } + } catch (e) { + // If the releaseNotesURI cannot be parsed then just ignore it. + } + + let install; + if (url instanceof Ci.nsIFileURL) { + install = new LocalAddonInstall(aAddon.location, url, opts); + await install.init(); + } else { + let loc = aAddon.location; + if ( + aAddon.isBuiltinColorwayTheme && + XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled + ) { + // Builtin colorways theme needs to be updated by installing the version + // got from AMO into the profile location and not using the location + // where the builtin addon is currently installed. + logger.info( + `Overriding location to APP_PROFILE on builtin colorway theme update for "${aAddon.id}"` + ); + loc = XPIExports.XPIInternal.XPIStates.getLocation( + XPIExports.XPIInternal.KEY_APP_PROFILE + ); + } + install = new DownloadAddonInstall(loc, url, opts); + } + + aCallback(install); + })(); +} + +// Maps instances of AddonInstall to AddonInstallWrapper +const wrapperMap = new WeakMap(); +let installFor = wrapper => wrapperMap.get(wrapper); + +// Numeric id included in the install telemetry events to correlate multiple events related +// to the same install or update flow. +let nextInstallId = 0; + +/** + * Creates a wrapper for an AddonInstall that only exposes the public API + * + * @param {AddonInstall} aInstall + * The AddonInstall to create a wrapper for + */ +function AddonInstallWrapper(aInstall) { + wrapperMap.set(this, aInstall); + this.installId = ++nextInstallId; +} + +AddonInstallWrapper.prototype = { + get __AddonInstallInternal__() { + return AppConstants.DEBUG ? installFor(this) : undefined; + }, + + get error() { + return installFor(this).error; + }, + + set error(err) { + installFor(this).error = err; + }, + + get type() { + return installFor(this).type; + }, + + get iconURL() { + return installFor(this).icons[32]; + }, + + get existingAddon() { + let install = installFor(this); + return install.existingAddon ? install.existingAddon.wrapper : null; + }, + + get addon() { + let install = installFor(this); + return install.addon ? install.addon.wrapper : null; + }, + + get sourceURI() { + return installFor(this).sourceURI; + }, + + set promptHandler(handler) { + installFor(this).promptHandler = handler; + }, + + get promptHandler() { + return installFor(this).promptHandler; + }, + + get installTelemetryInfo() { + return installFor(this).installTelemetryInfo; + }, + + get isUserRequestedUpdate() { + return Boolean(installFor(this).isUserRequestedUpdate); + }, + + get downloadStartedAt() { + return installFor(this).downloadStartedAt; + }, + + get hashedAddonId() { + const addon = this.addon; + + if (!addon) { + return null; + } + + return computeSha256HashAsString(addon.id); + }, + + install() { + return installFor(this).install(); + }, + + postpone(returnFn, requiresRestart) { + return installFor(this).postpone(returnFn, requiresRestart); + }, + + cancel() { + installFor(this).cancel(); + }, + + continuePostponedInstall() { + return installFor(this).continuePostponedInstall(); + }, + + addListener(listener) { + installFor(this).addListener(listener); + }, + + removeListener(listener) { + installFor(this).removeListener(listener); + }, +}; + +[ + "name", + "version", + "icons", + "releaseNotesURI", + "file", + "state", + "progress", + "maxProgress", +].forEach(function (aProp) { + Object.defineProperty(AddonInstallWrapper.prototype, aProp, { + get() { + return installFor(this)[aProp]; + }, + enumerable: true, + }); +}); + +/** + * Creates a new update checker. + * + * @param {AddonInternal} aAddon + * The add-on to check for updates + * @param {UpdateListener} aListener + * An UpdateListener to notify of updates + * @param {integer} aReason + * The reason for the update check + * @param {string} [aAppVersion] + * An optional application version to check for updates for + * @param {string} [aPlatformVersion] + * An optional platform version to check for updates for + * @throws if the aListener or aReason arguments are not valid + */ +var AddonUpdateChecker; + +export var UpdateChecker = function ( + aAddon, + aListener, + aReason, + aAppVersion, + aPlatformVersion +) { + if (!aListener || !aReason) { + throw Components.Exception("", Cr.NS_ERROR_INVALID_ARG); + } + + ({ AddonUpdateChecker } = ChromeUtils.importESModule( + "resource://gre/modules/addons/AddonUpdateChecker.sys.mjs" + )); + + this.addon = aAddon; + aAddon._updateCheck = this; + XPIInstall.doing(this); + this.listener = aListener; + this.appVersion = aAppVersion; + this.platformVersion = aPlatformVersion; + this.syncCompatibility = + aReason == AddonManager.UPDATE_WHEN_NEW_APP_INSTALLED; + this.isUserRequested = aReason == AddonManager.UPDATE_WHEN_USER_REQUESTED; + + let updateURL = aAddon.updateURL; + if (!updateURL) { + if ( + aReason == AddonManager.UPDATE_WHEN_PERIODIC_UPDATE && + Services.prefs.getPrefType(PREF_EM_UPDATE_BACKGROUND_URL) == + Services.prefs.PREF_STRING + ) { + updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_BACKGROUND_URL); + } else { + updateURL = Services.prefs.getCharPref(PREF_EM_UPDATE_URL); + } + } + + const UPDATE_TYPE_COMPATIBILITY = 32; + const UPDATE_TYPE_NEWVERSION = 64; + + aReason |= UPDATE_TYPE_COMPATIBILITY; + if ("onUpdateAvailable" in this.listener) { + aReason |= UPDATE_TYPE_NEWVERSION; + } + + let url = escapeAddonURI(aAddon, updateURL, aReason, aAppVersion); + this._parser = AddonUpdateChecker.checkForUpdates(aAddon.id, url, this); +}; + +UpdateChecker.prototype = { + addon: null, + listener: null, + appVersion: null, + platformVersion: null, + syncCompatibility: null, + + /** + * Calls a method on the listener passing any number of arguments and + * consuming any exceptions. + * + * @param {string} aMethod + * The method to call on the listener + * @param {any[]} aArgs + * Additional arguments to pass to the listener. + */ + callListener(aMethod, ...aArgs) { + if (!(aMethod in this.listener)) { + return; + } + + try { + this.listener[aMethod].apply(this.listener, aArgs); + } catch (e) { + logger.warn("Exception calling UpdateListener method " + aMethod, e); + } + }, + + /** + * Called when AddonUpdateChecker completes the update check + * + * @param {object[]} aUpdates + * The list of update details for the add-on + */ + async onUpdateCheckComplete(aUpdates) { + XPIInstall.done(this.addon._updateCheck); + this.addon._updateCheck = null; + let AUC = AddonUpdateChecker; + let ignoreMaxVersion = false; + // Ignore strict compatibility for dictionaries by default. + let ignoreStrictCompat = this.addon.type == "dictionary"; + if (!AddonManager.checkCompatibility) { + ignoreMaxVersion = true; + ignoreStrictCompat = true; + } else if ( + !AddonManager.strictCompatibility && + !this.addon.strictCompatibility + ) { + ignoreMaxVersion = true; + } + + // Always apply any compatibility update for the current version + let compatUpdate = AUC.getCompatibilityUpdate( + aUpdates, + this.addon.version, + this.syncCompatibility, + null, + null, + ignoreMaxVersion, + ignoreStrictCompat + ); + // Apply the compatibility update to the database + if (compatUpdate) { + this.addon.applyCompatibilityUpdate(compatUpdate, this.syncCompatibility); + } + + // If the request is for an application or platform version that is + // different to the current application or platform version then look for a + // compatibility update for those versions. + if ( + (this.appVersion && + Services.vc.compare(this.appVersion, Services.appinfo.version) != 0) || + (this.platformVersion && + Services.vc.compare( + this.platformVersion, + Services.appinfo.platformVersion + ) != 0) + ) { + compatUpdate = AUC.getCompatibilityUpdate( + aUpdates, + this.addon.version, + false, + this.appVersion, + this.platformVersion, + ignoreMaxVersion, + ignoreStrictCompat + ); + } + + if (compatUpdate) { + this.callListener("onCompatibilityUpdateAvailable", this.addon.wrapper); + } else { + this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper); + } + + function sendUpdateAvailableMessages(aSelf, aInstall) { + if (aInstall) { + aSelf.callListener( + "onUpdateAvailable", + aSelf.addon.wrapper, + aInstall.wrapper + ); + } else { + aSelf.callListener("onNoUpdateAvailable", aSelf.addon.wrapper); + } + aSelf.callListener( + "onUpdateFinished", + aSelf.addon.wrapper, + AddonManager.UPDATE_STATUS_NO_ERROR + ); + } + + let update = await AUC.getNewestCompatibleUpdate( + aUpdates, + this.addon, + this.appVersion, + this.platformVersion, + ignoreMaxVersion, + ignoreStrictCompat + ); + + if (update && !this.addon.location.locked) { + for (let currentInstall of XPIInstall.installs) { + // Skip installs that don't match the available update + if ( + currentInstall.existingAddon != this.addon || + currentInstall.version != update.version + ) { + continue; + } + + // If the existing install has not yet started downloading then send an + // available update notification. If it is already downloading then + // don't send any available update notification + if (currentInstall.state == AddonManager.STATE_AVAILABLE) { + logger.debug("Found an existing AddonInstall for " + this.addon.id); + sendUpdateAvailableMessages(this, currentInstall); + } else { + sendUpdateAvailableMessages(this, null); + } + return; + } + + createUpdate( + aInstall => { + sendUpdateAvailableMessages(this, aInstall); + }, + this.addon, + update, + this.isUserRequested + ); + } else { + sendUpdateAvailableMessages(this, null); + } + }, + + /** + * Called when AddonUpdateChecker fails the update check + * + * @param {any} aError + * An error status + */ + onUpdateCheckError(aError) { + XPIInstall.done(this.addon._updateCheck); + this.addon._updateCheck = null; + this.callListener("onNoCompatibilityUpdateAvailable", this.addon.wrapper); + this.callListener("onNoUpdateAvailable", this.addon.wrapper); + this.callListener("onUpdateFinished", this.addon.wrapper, aError); + }, + + /** + * Called to cancel an in-progress update check + */ + cancel() { + let parser = this._parser; + if (parser) { + this._parser = null; + // This will call back to onUpdateCheckError with a CANCELLED error + parser.cancel(); + } + }, +}; + +/** + * Creates a new AddonInstall to install an add-on from a local file. + * + * @param {nsIFile} file + * The file to install + * @param {XPIStateLocation} location + * The location to install to + * @param {Object?} [telemetryInfo] + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @returns {Promise} + * A Promise that resolves with the new install object. + */ +function createLocalInstall(file, location, telemetryInfo) { + if (!location) { + location = XPIExports.XPIInternal.XPIStates.getLocation( + XPIExports.XPIInternal.KEY_APP_PROFILE + ); + } + let url = Services.io.newFileURI(file); + + try { + let install = new LocalAddonInstall(location, url, { telemetryInfo }); + return install.init().then(() => install); + } catch (e) { + logger.error("Error creating install", e); + return Promise.resolve(null); + } +} + +/** + * Uninstall an addon from a location. This allows removing non-visible + * addons, such as system addon upgrades, when a higher precedence addon + * is installed. + * + * @param {string} addonID + * ID of the addon being removed. + * @param {XPIStateLocation} location + * The location to remove the addon from. + */ +async function uninstallAddonFromLocation(addonID, location) { + let existing = await XPIExports.XPIDatabase.getAddonInLocation( + addonID, + location.name + ); + if (!existing) { + return; + } + if (existing.active) { + let a = await AddonManager.getAddonByID(addonID); + if (a) { + await a.uninstall(); + } + } else { + XPIExports.XPIDatabase.removeAddonMetadata(existing); + location.removeAddon(addonID); + XPIExports.XPIInternal.XPIStates.save(); + AddonManagerPrivate.callAddonListeners("onUninstalled", existing); + } +} + +class DirectoryInstaller { + constructor(location) { + this.location = location; + + this._stagingDirLock = 0; + this._stagingDirPromise = null; + } + + get name() { + return this.location.name; + } + + get dir() { + return this.location.dir; + } + set dir(val) { + this.location.dir = val; + this.location.path = val.path; + } + + /** + * Gets the staging directory to put add-ons that are pending install and + * uninstall into. + * + * @returns {nsIFile} + */ + getStagingDir() { + return getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir); + } + + requestStagingDir() { + this._stagingDirLock++; + + if (this._stagingDirPromise) { + return this._stagingDirPromise; + } + + let stagepath = PathUtils.join( + this.dir.path, + XPIExports.XPIInternal.DIR_STAGE + ); + return (this._stagingDirPromise = IOUtils.makeDirectory(stagepath, { + createAncestors: true, + ignoreExisting: true, + }).catch(e => { + logger.error("Failed to create staging directory", e); + throw e; + })); + } + + releaseStagingDir() { + this._stagingDirLock--; + + if (this._stagingDirLock == 0) { + this._stagingDirPromise = null; + this.cleanStagingDir(); + } + + return Promise.resolve(); + } + + /** + * Removes the specified files or directories in the staging directory and + * then if the staging directory is empty attempts to remove it. + * + * @param {string[]} [aLeafNames = []] + * An array of file or directory to remove from the directory, the + * array may be empty + */ + cleanStagingDir(aLeafNames = []) { + let dir = this.getStagingDir(); + + // SystemAddonInstaller getStatingDir may return null if there isn't + // any addon set directory returned by SystemAddonInstaller._loadAddonSet. + if (!dir) { + return; + } + + for (let name of aLeafNames) { + let file = getFile(name, dir); + recursiveRemove(file); + } + + if (this._stagingDirLock > 0) { + return; + } + + // eslint-disable-next-line no-unused-vars + for (let file of XPIExports.XPIInternal.iterDirectory(dir)) { + return; + } + + try { + setFilePermissions(dir, lazy.FileUtils.PERMS_DIRECTORY); + dir.remove(false); + } catch (e) { + logger.warn("Failed to remove staging dir", e); + // Failing to remove the staging directory is ignorable + } + } + + /** + * Returns a directory that is normally on the same filesystem as the rest of + * the install location and can be used for temporarily storing files during + * safe move operations. Calling this method will delete the existing trash + * directory and its contents. + * + * @returns {nsIFile} + */ + getTrashDir() { + let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir); + let trashDirExists = trashDir.exists(); + try { + if (trashDirExists) { + recursiveRemove(trashDir); + } + trashDirExists = false; + } catch (e) { + logger.warn("Failed to remove trash directory", e); + } + if (!trashDirExists) { + trashDir.create( + Ci.nsIFile.DIRECTORY_TYPE, + lazy.FileUtils.PERMS_DIRECTORY + ); + } + + return trashDir; + } + + /** + * Installs an add-on into the install location. + * + * @param {Object} options + * Installation options. + * @param {string} options.id + * The ID of the add-on to install + * @param {nsIFile} options.source + * The source nsIFile to install from + * @param {string} options.action + * What to we do with the given source file: + * "move" + * Default action, the source files will be moved to the new + * location, + * "copy" + * The source files will be copied, + * "proxy" + * A "proxy file" is going to refer to the source file path + * @returns {nsIFile} + * An nsIFile indicating where the add-on was installed to + */ + installAddon({ id, source, action = "move" }) { + let trashDir = this.getTrashDir(); + + let transaction = new SafeInstallOperation(); + + let moveOldAddon = aId => { + let file = getFile(aId, this.dir); + if (file.exists()) { + transaction.moveUnder(file, trashDir); + } + + file = getFile(`${aId}.xpi`, this.dir); + if (file.exists()) { + flushJarCache(file); + transaction.moveUnder(file, trashDir); + } + }; + + // If any of these operations fails the finally block will clean up the + // temporary directory + try { + moveOldAddon(id); + if (action == "copy") { + transaction.copy(source, this.dir); + } else if (action == "move") { + flushJarCache(source); + transaction.moveUnder(source, this.dir); + } + // Do nothing for the proxy file as we sideload an addon permanently + } finally { + // It isn't ideal if this cleanup fails but it isn't worth rolling back + // the install because of it. + try { + recursiveRemove(trashDir); + } catch (e) { + logger.warn( + `Failed to remove trash directory when installing ${id}`, + e + ); + } + } + + let newFile = this.dir.clone(); + + if (action == "proxy") { + // When permanently installing sideloaded addon, we just put a proxy file + // referring to the addon sources + newFile.append(id); + + writeStringToFile(newFile, source.path); + } else { + newFile.append(source.leafName); + } + + try { + newFile.lastModifiedTime = Date.now(); + } catch (e) { + logger.warn(`failed to set lastModifiedTime on ${newFile.path}`, e); + } + + return newFile; + } + + /** + * Uninstalls an add-on from this location. + * + * @param {string} aId + * The ID of the add-on to uninstall + * @throws if the ID does not match any of the add-ons installed + */ + uninstallAddon(aId) { + let file = getFile(aId, this.dir); + if (!file.exists()) { + file.leafName += ".xpi"; + } + + if (!file.exists()) { + logger.warn( + `Attempted to remove ${aId} from ${this.name} but it was already gone` + ); + this.location.delete(aId); + return; + } + + if (file.leafName != aId) { + logger.debug( + `uninstallAddon: flushing jar cache ${file.path} for addon ${aId}` + ); + flushJarCache(file); + } + + // In case this is a foreignInstall we do not want to remove the file if + // the location is locked. + if (!this.location.locked) { + let trashDir = this.getTrashDir(); + let transaction = new SafeInstallOperation(); + + try { + transaction.moveUnder(file, trashDir); + } finally { + // It isn't ideal if this cleanup fails, but it is probably better than + // rolling back the uninstall at this point + try { + recursiveRemove(trashDir); + } catch (e) { + logger.warn( + `Failed to remove trash directory when uninstalling ${aId}`, + e + ); + } + } + } + + this.location.removeAddon(aId); + } +} + +class SystemAddonInstaller extends DirectoryInstaller { + constructor(location) { + super(location); + + this._baseDir = location._baseDir; + this._nextDir = null; + } + + get _addonSet() { + return this.location._addonSet; + } + set _addonSet(val) { + this.location._addonSet = val; + } + + /** + * Saves the current set of system add-ons + * + * @param {Object} aAddonSet - object containing schema, directory and set + * of system add-on IDs and versions. + */ + static _saveAddonSet(aAddonSet) { + Services.prefs.setStringPref( + XPIExports.XPIInternal.PREF_SYSTEM_ADDON_SET, + JSON.stringify(aAddonSet) + ); + } + + static _loadAddonSet() { + return XPIExports.XPIInternal.SystemAddonLocation._loadAddonSet(); + } + + /** + * Gets the staging directory to put add-ons that are pending install and + * uninstall into. + * + * @returns {nsIFile} + * Staging directory for system add-on upgrades. + */ + getStagingDir() { + this._addonSet = SystemAddonInstaller._loadAddonSet(); + let dir = null; + if (this._addonSet.directory) { + this.dir = getFile(this._addonSet.directory, this._baseDir); + dir = getFile(XPIExports.XPIInternal.DIR_STAGE, this.dir); + } else { + logger.info("SystemAddonInstaller directory is missing"); + } + + return dir; + } + + requestStagingDir() { + this._addonSet = SystemAddonInstaller._loadAddonSet(); + if (this._addonSet.directory) { + this.dir = getFile(this._addonSet.directory, this._baseDir); + } + return super.requestStagingDir(); + } + + isValidAddon(aAddon) { + if (aAddon.appDisabled) { + logger.warn( + `System add-on ${aAddon.id} isn't compatible with the application.` + ); + return false; + } + + return true; + } + + /** + * Tests whether the loaded add-on information matches what is expected. + * + * @param {Map} aAddons + * The set of add-ons to check. + * @returns {boolean} + * True if all of the given add-ons are valid. + */ + isValid(aAddons) { + for (let id of Object.keys(this._addonSet.addons)) { + if (!aAddons.has(id)) { + logger.warn( + `Expected add-on ${id} is missing from the system add-on location.` + ); + return false; + } + + let addon = aAddons.get(id); + if (addon.version != this._addonSet.addons[id].version) { + logger.warn( + `Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.` + ); + return false; + } + + if (!this.isValidAddon(addon)) { + return false; + } + } + + return true; + } + + /** + * Resets the add-on set so on the next startup the default set will be used. + */ + async resetAddonSet() { + logger.info("Removing all system add-on upgrades."); + + // remove everything from the pref first, if uninstall + // fails then at least they will not be re-activated on + // next restart. + let addonSet = this._addonSet; + this._addonSet = { schema: 1, addons: {} }; + SystemAddonInstaller._saveAddonSet(this._addonSet); + + // If this is running at app startup, the pref being cleared + // will cause later stages of startup to notice that the + // old updates are now gone. + // + // Updates will only be explicitly uninstalled if they are + // removed restartlessly, for instance if they are no longer + // part of the latest update set. + if (addonSet) { + for (let addonID of Object.keys(addonSet.addons)) { + await uninstallAddonFromLocation(addonID, this.location); + } + } + } + + /** + * Removes any directories not currently in use or pending use after a + * restart. Any errors that happen here don't really matter as we'll attempt + * to cleanup again next time. + */ + async cleanDirectories() { + try { + let children = await IOUtils.getChildren(this._baseDir.path, { + ignoreAbsent: true, + }); + for (let path of children) { + // Skip the directory currently in use + if (this.dir && this.dir.path == path) { + continue; + } + + // Skip the next directory + if (this._nextDir && this._nextDir.path == path) { + continue; + } + + await IOUtils.remove(path, { + ignoreAbsent: true, + recursive: true, + }); + } + } catch (e) { + logger.error("Failed to clean updated system add-ons directories.", e); + } + } + + /** + * Installs a new set of system add-ons into the location and updates the + * add-on set in prefs. + * + * @param {Array} aAddons - An array of addons to install. + */ + async installAddonSet(aAddons) { + // Make sure the base dir exists + await IOUtils.makeDirectory(this._baseDir.path, { ignoreExisting: true }); + + let addonSet = SystemAddonInstaller._loadAddonSet(); + + // Remove any add-ons that are no longer part of the set. + const ids = aAddons.map(a => a.id); + for (let addonID of Object.keys(addonSet.addons)) { + if (!ids.includes(addonID)) { + await uninstallAddonFromLocation(addonID, this.location); + } + } + + let newDir = this._baseDir.clone(); + newDir.append("blank"); + + while (true) { + newDir.leafName = Services.uuid.generateUUID().toString(); + try { + await IOUtils.makeDirectory(newDir.path, { ignoreExisting: false }); + break; + } catch (e) { + logger.debug( + "Could not create new system add-on updates dir, retrying", + e + ); + } + } + + // Record the new upgrade directory. + let state = { schema: 1, directory: newDir.leafName, addons: {} }; + SystemAddonInstaller._saveAddonSet(state); + + this._nextDir = newDir; + + let installs = []; + for (let addon of aAddons) { + let install = await createLocalInstall( + addon._sourceBundle, + this.location, + // Make sure that system addons being installed for the first time through + // Balrog have telemetryInfo associated with them (on the contrary the ones + // updated through Balrog but part of the build will already have the same + // `source`, but we expect no `method` to be set for them). + { + source: "system-addon", + method: "product-updates", + } + ); + installs.push(install); + } + + async function installAddon(install) { + // Make the new install own its temporary file. + install.ownsTempFile = true; + install.install(); + } + + async function postponeAddon(install) { + install.ownsTempFile = true; + let resumeFn; + if (AddonManagerPrivate.hasUpgradeListener(install.addon.id)) { + logger.info( + `system add-on ${install.addon.id} has an upgrade listener, postponing upgrade set until restart` + ); + resumeFn = () => { + logger.info( + `${install.addon.id} has resumed a previously postponed addon set` + ); + install.location.installer.resumeAddonSet(installs); + }; + } + await install.postpone(resumeFn); + } + + let previousState; + + try { + // All add-ons in position, create the new state and store it in prefs + state = { schema: 1, directory: newDir.leafName, addons: {} }; + for (let addon of aAddons) { + state.addons[addon.id] = { + version: addon.version, + }; + } + + previousState = SystemAddonInstaller._loadAddonSet(); + SystemAddonInstaller._saveAddonSet(state); + + let blockers = aAddons.filter(addon => + AddonManagerPrivate.hasUpgradeListener(addon.id) + ); + + if (blockers.length) { + await waitForAllPromises(installs.map(postponeAddon)); + } else { + await waitForAllPromises(installs.map(installAddon)); + } + } catch (e) { + // Roll back to previous upgrade set (if present) on restart. + if (previousState) { + SystemAddonInstaller._saveAddonSet(previousState); + } + // Otherwise, roll back to built-in set on restart. + // TODO try to do these restartlessly + await this.resetAddonSet(); + + try { + await IOUtils.remove(newDir.path, { recursive: true }); + } catch (e) { + logger.warn( + `Failed to remove failed system add-on directory ${newDir.path}.`, + e + ); + } + throw e; + } + } + + /** + * Resumes upgrade of a previously-delayed add-on set. + * + * @param {AddonInstall[]} installs + * The set of installs to resume. + */ + async resumeAddonSet(installs) { + async function resumeAddon(install) { + install.state = AddonManager.STATE_DOWNLOADED; + install.location.installer.releaseStagingDir(); + install.install(); + } + + let blockers = installs.filter(install => + AddonManagerPrivate.hasUpgradeListener(install.addon.id) + ); + + if (blockers.length > 1) { + logger.warn( + "Attempted to resume system add-on install but upgrade blockers are still present" + ); + } else { + await waitForAllPromises(installs.map(resumeAddon)); + } + } + + /** + * Returns a directory that is normally on the same filesystem as the rest of + * the install location and can be used for temporarily storing files during + * safe move operations. Calling this method will delete the existing trash + * directory and its contents. + * + * @returns {nsIFile} + */ + getTrashDir() { + let trashDir = getFile(XPIExports.XPIInternal.DIR_TRASH, this.dir); + let trashDirExists = trashDir.exists(); + try { + if (trashDirExists) { + recursiveRemove(trashDir); + } + trashDirExists = false; + } catch (e) { + logger.warn("Failed to remove trash directory", e); + } + if (!trashDirExists) { + trashDir.create( + Ci.nsIFile.DIRECTORY_TYPE, + lazy.FileUtils.PERMS_DIRECTORY + ); + } + + return trashDir; + } + + /** + * Installs an add-on into the install location. + * + * @param {string} id + * The ID of the add-on to install + * @param {nsIFile} source + * The source nsIFile to install from + * @returns {nsIFile} + * An nsIFile indicating where the add-on was installed to + */ + installAddon({ id, source }) { + let trashDir = this.getTrashDir(); + let transaction = new SafeInstallOperation(); + + // If any of these operations fails the finally block will clean up the + // temporary directory + try { + flushJarCache(source); + + transaction.moveUnder(source, this.dir); + } finally { + // It isn't ideal if this cleanup fails but it isn't worth rolling back + // the install because of it. + try { + recursiveRemove(trashDir); + } catch (e) { + logger.warn( + `Failed to remove trash directory when installing ${id}`, + e + ); + } + } + + let newFile = getFile(source.leafName, this.dir); + + try { + newFile.lastModifiedTime = Date.now(); + } catch (e) { + logger.warn("failed to set lastModifiedTime on " + newFile.path, e); + } + + return newFile; + } + + // old system add-on upgrade dirs get automatically removed + uninstallAddon(aAddon) {} +} + +var AppUpdate = { + findAddonUpdates(addon, reason, appVersion, platformVersion) { + return new Promise((resolve, reject) => { + let update = null; + addon.findUpdates( + { + onUpdateAvailable(addon2, install) { + update = install; + }, + + onUpdateFinished(addon2, error) { + if (error == AddonManager.UPDATE_STATUS_NO_ERROR) { + resolve(update); + } else { + reject(error); + } + }, + }, + reason, + appVersion, + platformVersion || appVersion + ); + }); + }, + + stageInstall(installer) { + return new Promise((resolve, reject) => { + let listener = { + onDownloadEnded: install => { + install.postpone(); + }, + onInstallFailed: install => { + install.removeListener(listener); + reject(); + }, + onInstallEnded: install => { + // We shouldn't end up here, but if we do, resolve + // since we've installed. + install.removeListener(listener); + resolve(); + }, + onInstallPostponed: install => { + // At this point the addon is staged for restart. + install.removeListener(listener); + resolve(); + }, + }; + + installer.addListener(listener); + installer.install(); + }); + }, + + async stageLangpackUpdates(nextVersion, nextPlatformVersion) { + let updates = []; + let addons = await AddonManager.getAddonsByTypes(["locale"]); + for (let addon of addons) { + updates.push( + this.findAddonUpdates( + addon, + AddonManager.UPDATE_WHEN_NEW_APP_DETECTED, + nextVersion, + nextPlatformVersion + ) + .then(update => update && this.stageInstall(update)) + .catch(e => { + logger.debug(`addon.findUpdate error: ${e}`); + }) + ); + } + return Promise.all(updates); + }, +}; + +export var XPIInstall = { + // An array of currently active AddonInstalls + installs: new Set(), + + createLocalInstall, + flushJarCache, + newVersionReason, + recursiveRemove, + syncLoadManifest, + loadManifestFromFile, + uninstallAddonFromLocation, + + stageLangpacksForAppUpdate(nextVersion, nextPlatformVersion) { + return AppUpdate.stageLangpackUpdates(nextVersion, nextPlatformVersion); + }, + + // Keep track of in-progress operations that support cancel() + _inProgress: [], + + doing(aCancellable) { + this._inProgress.push(aCancellable); + }, + + done(aCancellable) { + let i = this._inProgress.indexOf(aCancellable); + if (i != -1) { + this._inProgress.splice(i, 1); + return true; + } + return false; + }, + + cancelAll() { + // Cancelling one may alter _inProgress, so don't use a simple iterator + while (this._inProgress.length) { + let c = this._inProgress.shift(); + try { + c.cancel(); + } catch (e) { + logger.warn("Cancel failed", e); + } + } + }, + + /** + * @param {string} id + * The expected ID of the add-on. + * @param {nsIFile} file + * The XPI file to install the add-on from. + * @param {XPIStateLocation} location + * The install location to install the add-on to. + * @param {string?} [oldAppVersion] + * The version of the application last run with this profile or null + * if it is a new profile or the version is unknown + * @returns {AddonInternal} + * The installed Addon object, upon success. + */ + async installDistributionAddon(id, file, location, oldAppVersion) { + let addon = await loadManifestFromFile(file, location); + addon.installTelemetryInfo = { source: "distribution" }; + + if (addon.id != id) { + throw new Error( + `File file ${file.path} contains an add-on with an incorrect ID` + ); + } + + let state = location.get(id); + + if (state) { + try { + let existingAddon = await loadManifestFromFile(state.file, location); + + if (Services.vc.compare(addon.version, existingAddon.version) <= 0) { + return null; + } + } catch (e) { + // Bad add-on in the profile so just proceed and install over the top + logger.warn( + "Profile contains an add-on with a bad or missing install " + + `manifest at ${state.path}, overwriting`, + e + ); + } + } else if ( + addon.type === "locale" && + oldAppVersion && + Services.vc.compare(oldAppVersion, "67") < 0 + ) { + /* Distribution language packs didn't get installed due to the signing + issues so we need to force them to be reinstalled. */ + Services.prefs.clearUserPref( + XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id + ); + } else if ( + Services.prefs.getBoolPref( + XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id, + false + ) + ) { + return null; + } + + // Install the add-on + addon.sourceBundle = location.installer.installAddon({ + id, + source: file, + action: "copy", + }); + + XPIExports.XPIInternal.XPIStates.addAddon(addon); + logger.debug(`Installed distribution add-on ${id}`); + + Services.prefs.setBoolPref( + XPIExports.XPIInternal.PREF_BRANCH_INSTALLED_ADDON + id, + true + ); + + return addon; + }, + + /** + * Completes the install of an add-on which was staged during the last + * session. + * + * @param {string} id + * The expected ID of the add-on. + * @param {object} metadata + * The parsed metadata for the staged install. + * @param {XPIStateLocation} location + * The install location to install the add-on to. + * @returns {AddonInternal} + * The installed Addon object, upon success. + */ + async installStagedAddon(id, metadata, location) { + let source = getFile(`${id}.xpi`, location.installer.getStagingDir()); + + // Check that the directory's name is a valid ID. + if (!gIDTest.test(id) || !source.exists() || !source.isFile()) { + throw new Error(`Ignoring invalid staging directory entry: ${id}`); + } + + let addon = await loadManifestFromFile(source, location); + + if ( + XPIExports.XPIDatabase.mustSign(addon.type) && + addon.signedState <= AddonManager.SIGNEDSTATE_MISSING + ) { + throw new Error( + `Refusing to install staged add-on ${id} with signed state ${addon.signedState}` + ); + } + + // Import saved metadata before checking for compatibility. + addon.importMetadata(metadata); + + // Ensure a staged addon is compatible with the current running version of + // Firefox. If a prior version of the addon is installed, it will remain. + if (!addon.isCompatible) { + throw new Error( + `Add-on ${addon.id} is not compatible with application version.` + ); + } + + logger.debug(`Processing install of ${id} in ${location.name}`); + let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon(id); + // This part of the startup file changes is called from + // processPendingFileChanges, no addons are started yet. + // Here we handle copying the xpi into its proper place, later + // processFileChanges will call update. + try { + addon.sourceBundle = location.installer.installAddon({ + id, + source, + }); + XPIExports.XPIInternal.XPIStates.addAddon(addon); + } catch (e) { + if (existingAddon) { + // Re-install the old add-on + XPIExports.XPIInternal.get(existingAddon).install(); + } + throw e; + } + + return addon; + }, + + async updateSystemAddons() { + let systemAddonLocation = XPIExports.XPIInternal.XPIStates.getLocation( + XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS + ); + if (!systemAddonLocation) { + return; + } + + let installer = systemAddonLocation.installer; + + // Don't do anything in safe mode + if (Services.appinfo.inSafeMode) { + return; + } + + // Download the list of system add-ons + let url = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_UPDATE_URL, null); + if (!url) { + await installer.cleanDirectories(); + return; + } + + url = await lazy.UpdateUtils.formatUpdateURL(url); + + logger.info(`Starting system add-on update check from ${url}.`); + let res = await lazy.ProductAddonChecker.getProductAddonList( + url, + true + ).catch(e => logger.error(`System addon update list error ${e}`)); + + // If there was no list then do nothing. + if (!res || !res.addons) { + logger.info("No system add-ons list was returned."); + await installer.cleanDirectories(); + return; + } + + let addonList = new Map( + res.addons.map(spec => [spec.id, { spec, path: null, addon: null }]) + ); + + let setMatches = (wanted, existing) => { + if (wanted.size != existing.size) { + return false; + } + + for (let [id, addon] of existing) { + let wantedInfo = wanted.get(id); + + if (!wantedInfo) { + return false; + } + if (wantedInfo.spec.version != addon.version) { + return false; + } + } + + return true; + }; + + // If this matches the current set in the profile location then do nothing. + let updatedAddons = addonMap( + await XPIExports.XPIDatabase.getAddonsInLocation( + XPIExports.XPIInternal.KEY_APP_SYSTEM_ADDONS + ) + ); + if (setMatches(addonList, updatedAddons)) { + logger.info("Retaining existing updated system add-ons."); + await installer.cleanDirectories(); + return; + } + + // If this matches the current set in the default location then reset the + // updated set. + let defaultAddons = addonMap( + await XPIExports.XPIDatabase.getAddonsInLocation( + XPIExports.XPIInternal.KEY_APP_SYSTEM_DEFAULTS + ) + ); + if (setMatches(addonList, defaultAddons)) { + logger.info("Resetting system add-ons."); + await installer.resetAddonSet(); + await installer.cleanDirectories(); + return; + } + + // Download all the add-ons + async function downloadAddon(item) { + try { + let sourceAddon = updatedAddons.get(item.spec.id); + if (sourceAddon && sourceAddon.version == item.spec.version) { + // Copying the file to a temporary location has some benefits. If the + // file is locked and cannot be read then we'll fall back to + // downloading a fresh copy. We later mark the install object with + // ownsTempFile so that we will cleanup later (see installAddonSet). + try { + let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile).path; + let uniquePath = await IOUtils.createUniqueFile(tmpDir, "tmpaddon"); + await IOUtils.copy(sourceAddon._sourceBundle.path, uniquePath); + // Make sure to update file modification times so this is detected + // as a new add-on. + await IOUtils.setModificationTime(uniquePath); + item.path = uniquePath; + } catch (e) { + logger.warn( + `Failed make temporary copy of ${sourceAddon._sourceBundle.path}.`, + e + ); + } + } + if (!item.path) { + item.path = await lazy.ProductAddonChecker.downloadAddon(item.spec); + } + item.addon = await loadManifestFromFile( + nsIFile(item.path), + systemAddonLocation + ); + } catch (e) { + logger.error(`Failed to download system add-on ${item.spec.id}`, e); + } + } + await Promise.all(Array.from(addonList.values()).map(downloadAddon)); + + // The download promises all resolve regardless, now check if they all + // succeeded + let validateAddon = item => { + if (item.spec.id != item.addon.id) { + logger.warn( + `Downloaded system add-on expected to be ${item.spec.id} but was ${item.addon.id}.` + ); + return false; + } + + if (item.spec.version != item.addon.version) { + logger.warn( + `Expected system add-on ${item.spec.id} to be version ${item.spec.version} but was ${item.addon.version}.` + ); + return false; + } + + if (!installer.isValidAddon(item.addon)) { + return false; + } + + return true; + }; + + if ( + !Array.from(addonList.values()).every( + item => item.path && item.addon && validateAddon(item) + ) + ) { + throw new Error( + "Rejecting updated system add-on set that either could not " + + "be downloaded or contained unusable add-ons." + ); + } + + // Install into the install location + logger.info("Installing new system add-on set"); + await installer.installAddonSet( + Array.from(addonList.values()).map(a => a.addon) + ); + }, + + /** + * Called to test whether installing XPI add-ons is enabled. + * + * @returns {boolean} + * True if installing is enabled. + */ + isInstallEnabled() { + // Default to enabled if the preference does not exist + return Services.prefs.getBoolPref(PREF_XPI_ENABLED, true); + }, + + /** + * Called to test whether installing XPI add-ons by direct URL requests is + * whitelisted. + * + * @returns {boolean} + * True if installing by direct requests is whitelisted + */ + isDirectRequestWhitelisted() { + // Default to whitelisted if the preference does not exist. + return Services.prefs.getBoolPref(PREF_XPI_DIRECT_WHITELISTED, true); + }, + + /** + * Called to test whether installing XPI add-ons from file referrers is + * whitelisted. + * + * @returns {boolean} + * True if installing from file referrers is whitelisted + */ + isFileRequestWhitelisted() { + // Default to whitelisted if the preference does not exist. + return Services.prefs.getBoolPref(PREF_XPI_FILE_WHITELISTED, true); + }, + + /** + * Called to test whether installing XPI add-ons from a URI is allowed. + * + * @param {nsIPrincipal} aInstallingPrincipal + * The nsIPrincipal that initiated the install + * @returns {boolean} + * True if installing is allowed + */ + isInstallAllowed(aInstallingPrincipal) { + if (!this.isInstallEnabled()) { + return false; + } + + let uri = aInstallingPrincipal.URI; + + // Direct requests without a referrer are either whitelisted or blocked. + if (!uri) { + return this.isDirectRequestWhitelisted(); + } + + // Local referrers can be whitelisted. + if ( + this.isFileRequestWhitelisted() && + (uri.schemeIs("chrome") || uri.schemeIs("file")) + ) { + return true; + } + + XPIExports.XPIDatabase.importPermissions(); + + let permission = Services.perms.testPermissionFromPrincipal( + aInstallingPrincipal, + XPIExports.XPIInternal.XPI_PERMISSION + ); + if (permission == Ci.nsIPermissionManager.DENY_ACTION) { + return false; + } + + let requireWhitelist = Services.prefs.getBoolPref( + PREF_XPI_WHITELIST_REQUIRED, + true + ); + if ( + requireWhitelist && + permission != Ci.nsIPermissionManager.ALLOW_ACTION + ) { + return false; + } + + let requireSecureOrigin = Services.prefs.getBoolPref( + PREF_INSTALL_REQUIRESECUREORIGIN, + true + ); + let safeSchemes = ["https", "chrome", "file"]; + if (requireSecureOrigin && !safeSchemes.includes(uri.scheme)) { + return false; + } + + return true; + }, + + /** + * Called to get an AddonInstall to download and install an add-on from a URL. + * + * @param {nsIURI} aUrl + * The URL to be installed + * @param {object} [aOptions] + * Additional options for this install. + * @param {string?} [aOptions.hash] + * A hash for the install + * @param {string} [aOptions.name] + * A name for the install + * @param {Object} [aOptions.icons] + * Icon URLs for the install + * @param {string} [aOptions.version] + * A version for the install + * @param {XULElement} [aOptions.browser] + * The browser performing the install + * @param {Object} [aOptions.telemetryInfo] + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @param {boolean} [aOptions.sendCookies = false] + * Whether cookies should be sent when downloading the add-on. + * @param {string} [aOptions.useSystemLocation = false] + * If true installs to the system profile location. + * @returns {AddonInstall} + */ + async getInstallForURL(aUrl, aOptions) { + let locationName = aOptions.useSystemLocation + ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE + : XPIExports.XPIInternal.KEY_APP_PROFILE; + let location = XPIExports.XPIInternal.XPIStates.getLocation(locationName); + if (!location) { + throw Components.Exception( + "Invalid location name", + Cr.NS_ERROR_INVALID_ARG + ); + } + + let url = Services.io.newURI(aUrl); + + if (url instanceof Ci.nsIFileURL) { + let install = new LocalAddonInstall(location, url, aOptions); + await install.init(); + return install.wrapper; + } + + let install = new DownloadAddonInstall(location, url, aOptions); + return install.wrapper; + }, + + /** + * Called to get an AddonInstall to install an add-on from a local file. + * + * @param {nsIFile} aFile + * The file to be installed + * @param {Object?} [aInstallTelemetryInfo] + * An optional object which provides details about the installation source + * included in the addon manager telemetry events. + * @param {boolean} [aUseSystemLocation = false] + * If true install to the system profile location. + * @returns {AddonInstall?} + */ + async getInstallForFile( + aFile, + aInstallTelemetryInfo, + aUseSystemLocation = false + ) { + let location = XPIExports.XPIInternal.XPIStates.getLocation( + aUseSystemLocation + ? XPIExports.XPIInternal.KEY_APP_SYSTEM_PROFILE + : XPIExports.XPIInternal.KEY_APP_PROFILE + ); + let install = await createLocalInstall( + aFile, + location, + aInstallTelemetryInfo + ); + return install ? install.wrapper : null; + }, + + /** + * Called to get the current AddonInstalls, optionally limiting to a list of + * types. + * + * @param {Array?} aTypes + * An array of types or null to get all types + * @returns {AddonInstall[]} + */ + getInstallsByTypes(aTypes) { + let results = [...this.installs]; + if (aTypes) { + results = results.filter(install => { + return aTypes.includes(install.type); + }); + } + + return results.map(install => install.wrapper); + }, + + /** + * Temporarily installs add-on from a local XPI file or directory. + * As this is intended for development, the signature is not checked and + * the add-on does not persist on application restart. + * + * @param {nsIFile} aFile + * An nsIFile for the unpacked add-on directory or XPI file. + * + * @returns {Promise} + * A Promise that resolves to an Addon object on success, or rejects + * if the add-on is not a valid restartless add-on or if the + * same ID is already installed. + */ + async installTemporaryAddon(aFile) { + let installLocation = XPIExports.XPIInternal.TemporaryInstallLocation; + + if (XPIExports.XPIInternal.isXPI(aFile.leafName)) { + flushJarCache(aFile); + } + let addon = await loadManifestFromFile(aFile, installLocation); + addon.rootURI = XPIExports.XPIInternal.getURIForResourceInFile( + aFile, + "" + ).spec; + + await this._activateAddon(addon, { temporarilyInstalled: true }); + + logger.debug(`Install of temporary addon in ${aFile.path} completed.`); + return addon.wrapper; + }, + + /** + * Installs an add-on from a built-in location + * (ie a resource: url referencing assets shipped with the application) + * + * @param {string} base + * A string containing the base URL. Must be a resource: URL. + * @returns {Promise} + * A Promise that resolves to an Addon object when the addon is + * installed. + */ + async installBuiltinAddon(base) { + // We have to get this before the install, as the install will overwrite + // the pref. We then keep the value for this run, so we can restore + // the selected theme once it becomes available. + if (lastSelectedTheme === null) { + lastSelectedTheme = Services.prefs.getCharPref(PREF_SELECTED_THEME, ""); + } + + let baseURL = Services.io.newURI(base); + + // WebExtensions need to be able to iterate through the contents of + // an extension (for localization). It knows how to do this with + // jar: and file: URLs, so translate the provided base URL to + // something it can use. + if (baseURL.scheme !== "resource") { + throw new Error("Built-in addons must use resource: URLS"); + } + + let pkg = builtinPackage(baseURL); + let addon = await loadManifest(pkg, XPIExports.XPIInternal.BuiltInLocation); + addon.rootURI = base; + + // If this is a theme, decide whether to enable it. Themes are + // disabled by default. However: + // + // We always want one theme to be active, falling back to the + // default theme when the active theme is disabled. + // During a theme migration, such as a change in the path to the addon, we + // will need to ensure a correct theme is enabled. + if (addon.type === "theme") { + if ( + addon.id === lastSelectedTheme || + (!lastSelectedTheme.endsWith("@mozilla.org") && + addon.id === lazy.AddonSettings.DEFAULT_THEME_ID && + !XPIExports.XPIDatabase.getAddonsByType("theme").some( + theme => !theme.disabled + )) + ) { + addon.userDisabled = false; + } + } + await this._activateAddon(addon); + return addon.wrapper; + }, + + /** + * Activate a newly installed addon. + * This function handles all the bookkeeping related to a new addon + * and invokes whatever bootstrap methods are necessary. + * Note that this function is only used for temporary and built-in + * installs, it is very similar to AddonInstall::startInstall(). + * It would be great to merge this function with that one some day. + * + * @param {AddonInternal} addon The addon to activate + * @param {object} [extraParams] Any extra parameters to pass to the + * bootstrap install() method + * + * @returns {Promise} + */ + async _activateAddon(addon, extraParams = {}) { + if (addon.appDisabled) { + let message = `Add-on ${addon.id} is not compatible with application version.`; + + let app = addon.matchingTargetApplication; + if (app) { + if (app.minVersion) { + message += ` add-on minVersion: ${app.minVersion}.`; + } + if (app.maxVersion) { + message += ` add-on maxVersion: ${app.maxVersion}.`; + } + } + throw new Error(message); + } + + let oldAddon = await XPIExports.XPIDatabase.getVisibleAddonForID(addon.id); + + let willActivate = + !oldAddon || + oldAddon.location == addon.location || + addon.location.hasPrecedence(oldAddon.location); + + let install = () => { + addon.visible = willActivate; + // Themes are generally not enabled by default at install time, + // unless enabled by the front-end code. If they are meant to be + // enabled, they will already have been enabled by this point. + if (addon.type !== "theme" || addon.location.isTemporary) { + addon.userDisabled = false; + } + addon.active = addon.visible && !addon.disabled; + + addon = XPIExports.XPIDatabase.addToDatabase( + addon, + addon._sourceBundle ? addon._sourceBundle.path : null + ); + + XPIExports.XPIInternal.XPIStates.addAddon(addon); + XPIExports.XPIInternal.XPIStates.save(); + }; + + AddonManagerPrivate.callAddonListeners("onInstalling", addon.wrapper); + + if (!willActivate) { + addon.installDate = Date.now(); + + install(); + } else if (oldAddon) { + logger.warn( + `Addon with ID ${oldAddon.id} already installed, ` + + "older version will be disabled" + ); + + addon.installDate = oldAddon.installDate; + + await XPIExports.XPIInternal.BootstrapScope.get(oldAddon).update( + addon, + true, + install + ); + } else { + addon.installDate = Date.now(); + + install(); + let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(addon); + await bootstrap.install(undefined, true, extraParams); + } + + AddonManagerPrivate.callInstallListeners( + "onExternalInstall", + null, + addon.wrapper, + oldAddon ? oldAddon.wrapper : null, + false + ); + AddonManagerPrivate.callAddonListeners("onInstalled", addon.wrapper); + + // Notify providers that a new theme has been enabled. + if (addon.type === "theme" && !addon.userDisabled) { + AddonManagerPrivate.notifyAddonChanged(addon.id, addon.type, false); + } + }, + + /** + * Uninstalls an add-on, immediately if possible or marks it as pending + * uninstall if not. + * + * @param {DBAddonInternal} aAddon + * The DBAddonInternal to uninstall + * @param {boolean} aForcePending + * Force this addon into the pending uninstall state (used + * e.g. while the add-on manager is open and offering an + * "undo" button) + * @throws if the addon cannot be uninstalled because it is in an install + * location that does not allow it + */ + async uninstallAddon(aAddon, aForcePending) { + if (!aAddon.inDatabase) { + throw new Error( + `Cannot uninstall addon ${aAddon.id} because it is not installed` + ); + } + let { location } = aAddon; + + // If the addon is sideloaded into a location that does not allow + // sideloads, it is a legacy sideload. We allow those to be uninstalled. + let isLegacySideload = + aAddon.foreignInstall && + !(location.scope & lazy.AddonSettings.SCOPES_SIDELOAD); + + if (location.locked && !isLegacySideload) { + throw new Error( + `Cannot uninstall addon ${aAddon.id} ` + + `from locked install location ${location.name}` + ); + } + + if (aForcePending && aAddon.pendingUninstall) { + throw new Error("Add-on is already marked to be uninstalled"); + } + + if (aAddon._updateCheck) { + logger.debug(`Cancel in-progress update check for ${aAddon.id}`); + aAddon._updateCheck.cancel(); + } + + let wasActive = aAddon.active; + let wasPending = aAddon.pendingUninstall; + + if (aForcePending) { + // We create an empty directory in the staging directory to indicate + // that an uninstall is necessary on next startup. Temporary add-ons are + // automatically uninstalled on shutdown anyway so there is no need to + // do this for them. + if (!aAddon.location.isTemporary && aAddon.location.installer) { + let stage = getFile( + aAddon.id, + aAddon.location.installer.getStagingDir() + ); + if (!stage.exists()) { + stage.create( + Ci.nsIFile.DIRECTORY_TYPE, + lazy.FileUtils.PERMS_DIRECTORY + ); + } + } + + XPIExports.XPIDatabase.setAddonProperties(aAddon, { + pendingUninstall: true, + }); + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true); + let xpiState = aAddon.location.get(aAddon.id); + if (xpiState) { + xpiState.enabled = false; + XPIExports.XPIInternal.XPIStates.save(); + } else { + logger.warn( + "Can't find XPI state while uninstalling ${id} from ${location}", + aAddon + ); + } + } + + // If the add-on is not visible then there is no need to notify listeners. + if (!aAddon.visible) { + return; + } + + let wrapper = aAddon.wrapper; + + // If the add-on wasn't already pending uninstall then notify listeners. + if (!wasPending) { + AddonManagerPrivate.callAddonListeners( + "onUninstalling", + wrapper, + !!aForcePending + ); + } + + let existingAddon = XPIExports.XPIInternal.XPIStates.findAddon( + aAddon.id, + loc => loc != aAddon.location + ); + + let bootstrap = XPIExports.XPIInternal.BootstrapScope.get(aAddon); + if (!aForcePending) { + let existing; + if (existingAddon) { + existing = await XPIExports.XPIDatabase.getAddonInLocation( + aAddon.id, + existingAddon.location.name + ); + } + + let uninstall = () => { + XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id); + if (aAddon.location.installer) { + aAddon.location.installer.uninstallAddon(aAddon.id); + } + XPIExports.XPIDatabase.removeAddonMetadata(aAddon); + aAddon.location.removeAddon(aAddon.id); + AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); + + if (existing) { + // Migrate back to the existing addon, unless it was a builtin colorway theme, + // in that case we also make sure to remove the addon from the builtin location. + if ( + existing.isBuiltinColorwayTheme && + XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled + ) { + existing.location.removeAddon(existing.id); + } else { + XPIExports.XPIDatabase.makeAddonVisible(existing); + AddonManagerPrivate.callAddonListeners( + "onInstalling", + existing.wrapper, + false + ); + + if (!existing.disabled) { + XPIExports.XPIDatabase.updateAddonActive(existing, true); + } + } + } + }; + + // Migrate back to the existing addon, unless it was a builtin colorway theme. + if ( + existing && + !( + existing.isBuiltinColorwayTheme && + XPIExports.BuiltInThemesHelpers.isColorwayMigrationEnabled + ) + ) { + await bootstrap.update(existing, !existing.disabled, uninstall); + + AddonManagerPrivate.callAddonListeners("onInstalled", existing.wrapper); + } else { + aAddon.location.removeAddon(aAddon.id); + await bootstrap.uninstall(); + uninstall(); + } + } else if (aAddon.active) { + XPIExports.XPIInternal.XPIStates.disableAddon(aAddon.id); + bootstrap.shutdown( + XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_UNINSTALL + ); + XPIExports.XPIDatabase.updateAddonActive(aAddon, false); + } + + // Notify any other providers that a new theme has been enabled + // (when the active theme is uninstalled, the default theme is enabled). + if (aAddon.type === "theme" && wasActive) { + AddonManagerPrivate.notifyAddonChanged(null, aAddon.type); + } + }, + + /** + * Cancels the pending uninstall of an add-on. + * + * @param {DBAddonInternal} aAddon + * The DBAddonInternal to cancel uninstall for + */ + cancelUninstallAddon(aAddon) { + if (!aAddon.inDatabase) { + throw new Error("Can only cancel uninstall for installed addons."); + } + if (!aAddon.pendingUninstall) { + throw new Error("Add-on is not marked to be uninstalled"); + } + + if (!aAddon.location.isTemporary && aAddon.location.installer) { + aAddon.location.installer.cleanStagingDir([aAddon.id]); + } + + XPIExports.XPIDatabase.setAddonProperties(aAddon, { + pendingUninstall: false, + }); + + if (!aAddon.visible) { + return; + } + + aAddon.location.get(aAddon.id).syncWithDB(aAddon); + XPIExports.XPIInternal.XPIStates.save(); + + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true); + + if (!aAddon.disabled) { + XPIExports.XPIInternal.BootstrapScope.get(aAddon).startup( + XPIExports.XPIInternal.BOOTSTRAP_REASONS.ADDON_INSTALL + ); + XPIExports.XPIDatabase.updateAddonActive(aAddon, true); + } + + let wrapper = aAddon.wrapper; + AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper); + + // Notify any other providers that this theme is now enabled again. + if (aAddon.type === "theme" && aAddon.active) { + AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false); + } + }, + + DirectoryInstaller, + SystemAddonInstaller, +}; diff --git a/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs new file mode 100644 index 0000000000..d5ffd06d11 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/XPIProvider.sys.mjs @@ -0,0 +1,3377 @@ +/* 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 load and run + * extensions at startup. Anything which is not required immediately at + * startup should go in XPIInstall.sys.mjs or XPIDatabase.sys.mjs if at all + * possible, in order to minimize the impact on startup performance. + */ + +/** + * @typedef {number} integer + */ + +/* 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"; +import { + AddonManager, + AddonManagerPrivate, +} from "resource://gre/modules/AddonManager.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + AddonSettings: "resource://gre/modules/addons/AddonSettings.sys.mjs", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", + Dictionary: "resource://gre/modules/Extension.sys.mjs", + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + JSONFile: "resource://gre/modules/JSONFile.sys.mjs", + Langpack: "resource://gre/modules/Extension.sys.mjs", + SitePermission: "resource://gre/modules/Extension.sys.mjs", + TelemetrySession: "resource://gre/modules/TelemetrySession.sys.mjs", +}); + +XPCOMUtils.defineLazyServiceGetters(lazy, { + aomStartup: [ + "@mozilla.org/addons/addon-manager-startup;1", + "amIAddonManagerStartup", + ], + resProto: [ + "@mozilla.org/network/protocol;1?name=resource", + "nsISubstitutingProtocolHandler", + ], + spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"], + timerManager: [ + "@mozilla.org/updates/timer-manager;1", + "nsIUpdateTimerManager", + ], +}); + +const nsIFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" +); +const FileInputStream = Components.Constructor( + "@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init" +); + +const PREF_DB_SCHEMA = "extensions.databaseSchema"; +const PREF_PENDING_OPERATIONS = "extensions.pendingOperations"; +const PREF_EM_ENABLED_SCOPES = "extensions.enabledScopes"; +const PREF_EM_STARTUP_SCAN_SCOPES = "extensions.startupScanScopes"; +// xpinstall.signatures.required only supported in dev builds +const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required"; +const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required"; +const PREF_INSTALL_DISTRO_ADDONS = "extensions.installDistroAddons"; +const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon."; +const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet"; + +const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId"; + +// Specify a list of valid built-in add-ons to load. +const BUILT_IN_ADDONS_URI = "chrome://browser/content/built_in_addons.json"; + +const DIR_EXTENSIONS = "extensions"; +const DIR_SYSTEM_ADDONS = "features"; +const DIR_APP_SYSTEM_PROFILE = "system-extensions"; +const DIR_STAGE = "staged"; +const DIR_TRASH = "trash"; + +const FILE_XPI_STATES = "addonStartup.json.lz4"; + +const KEY_PROFILEDIR = "ProfD"; +const KEY_ADDON_APP_DIR = "XREAddonAppDir"; +const KEY_APP_DISTRIBUTION = "XREAppDist"; +const KEY_APP_FEATURES = "XREAppFeat"; + +const KEY_APP_PROFILE = "app-profile"; +const KEY_APP_SYSTEM_PROFILE = "app-system-profile"; +const KEY_APP_SYSTEM_ADDONS = "app-system-addons"; +const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults"; +const KEY_APP_BUILTINS = "app-builtin"; +const KEY_APP_GLOBAL = "app-global"; +const KEY_APP_SYSTEM_LOCAL = "app-system-local"; +const KEY_APP_SYSTEM_SHARE = "app-system-share"; +const KEY_APP_SYSTEM_USER = "app-system-user"; +const KEY_APP_TEMPORARY = "app-temporary"; + +const TEMPORARY_ADDON_SUFFIX = "@temporary-addon"; + +const STARTUP_MTIME_SCOPES = [ + KEY_APP_GLOBAL, + KEY_APP_SYSTEM_LOCAL, + KEY_APP_SYSTEM_SHARE, + KEY_APP_SYSTEM_USER, +]; + +const NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions"; +const XPI_PERMISSION = "install"; + +const XPI_SIGNATURE_CHECK_PERIOD = 24 * 60 * 60; + +const DB_SCHEMA = 35; + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "enabledScopesPref", + PREF_EM_ENABLED_SCOPES, + AddonManager.SCOPE_ALL +); + +Object.defineProperty(lazy, "enabledScopes", { + get() { + // The profile location is always enabled + return lazy.enabledScopesPref | AddonManager.SCOPE_PROFILE; + }, +}); + +function encoded(strings, ...values) { + let result = []; + + for (let [i, string] of strings.entries()) { + result.push(string); + if (i < values.length) { + result.push(encodeURIComponent(values[i])); + } + } + + return result.join(""); +} + +const BOOTSTRAP_REASONS = { + APP_STARTUP: 1, + APP_SHUTDOWN: 2, + ADDON_ENABLE: 3, + ADDON_DISABLE: 4, + ADDON_INSTALL: 5, + ADDON_UNINSTALL: 6, + ADDON_UPGRADE: 7, + ADDON_DOWNGRADE: 8, +}; + +// All addonTypes supported by the XPIProvider. These values can be passed to +// AddonManager.getAddonsByTypes in order to get XPIProvider.getAddonsByTypes +// to return only supported add-ons. Without these, it is possible for +// AddonManager.getAddonsByTypes to return addons from other providers, or even +// add-on types that are no longer supported by XPIProvider. +const ALL_XPI_TYPES = new Set([ + "dictionary", + "extension", + "locale", + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + "sitepermission-deprecated", + "theme", +]); + +/** + * Valid IDs fit this pattern. + */ +var gIDTest = + /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i; + +import { Log } from "resource://gre/modules/Log.sys.mjs"; + +const LOGGER_ID = "addons.xpi"; + +// Create a new logger for use by all objects in this Addons XPI Provider module +// (Requires AddonManager.jsm) +var logger = Log.repository.getLogger(LOGGER_ID); + +/** + * Spins the event loop until the given promise resolves, and then eiter returns + * its success value or throws its rejection value. + * + * @param {Promise} promise + * The promise to await. + * @returns {any} + * The promise's resolution value, if any. + */ +function awaitPromise(promise) { + let success = undefined; + let result = null; + + promise.then( + val => { + success = true; + result = val; + }, + val => { + success = false; + result = val; + } + ); + + Services.tm.spinEventLoopUntil( + "XPIProvider.sys.mjs:awaitPromise", + () => success !== undefined + ); + + if (!success) { + throw result; + } + return result; +} + +/** + * Returns a nsIFile instance for the given path, relative to the given + * base file, if provided. + * + * @param {string} path + * The (possibly relative) path of the file. + * @param {nsIFile} [base] + * An optional file to use as a base path if `path` is relative. + * @returns {nsIFile} + */ +function getFile(path, base = null) { + // First try for an absolute path, as we get in the case of proxy + // files. Ideally we would try a relative path first, but on Windows, + // paths which begin with a drive letter are valid as relative paths, + // and treated as such. + try { + return new nsIFile(path); + } catch (e) { + // Ignore invalid relative paths. The only other error we should see + // here is EOM, and either way, any errors that we care about should + // be re-thrown below. + } + + // If the path isn't absolute, we must have a base path. + let file = base.clone(); + file.appendRelativePath(path); + return file; +} + +/** + * Returns true if the given file, based on its name, should be treated + * as an XPI. If the file does not have an appropriate extension, it is + * assumed to be an unpacked add-on. + * + * @param {string} filename + * The filename to check. + * @param {boolean} [strict = false] + * If true, this file is in a location maintained by the browser, and + * must have a strict, lower-case ".xpi" extension. + * @returns {boolean} + * True if the file is an XPI. + */ +function isXPI(filename, strict) { + if (strict) { + return filename.endsWith(".xpi"); + } + let ext = filename.slice(-4).toLowerCase(); + return ext === ".xpi" || ext === ".zip"; +} + +/** + * Returns the extension expected ID for a given file in an extension install + * directory. + * + * @param {nsIFile} file + * The extension XPI file or unpacked directory. + * @returns {AddonId?} + * The add-on ID, if valid, or null otherwise. + */ +function getExpectedID(file) { + let { leafName } = file; + let id = isXPI(leafName, true) ? leafName.slice(0, -4) : leafName; + if (gIDTest.test(id)) { + return id; + } + return null; +} + +/** + * Evaluates whether an add-on is allowed to run in safe mode. + * + * @param {AddonInternal} aAddon + * The add-on to check + * @returns {boolean} + * True if the add-on should run in safe mode + */ +function canRunInSafeMode(aAddon) { + let location = aAddon.location || null; + if (!location) { + return false; + } + + // Even though the updated system add-ons aren't generally run in safe mode we + // include them here so their uninstall functions get called when switching + // back to the default set. + + // TODO product should make the call about temporary add-ons running + // in safe mode. assuming for now that they are. + return location.isTemporary || location.isSystem || location.isBuiltin; +} + +/** + * Gets an nsIURI for a file within another file, either a directory or an XPI + * file. If aFile is a directory then this will return a file: URI, if it is an + * XPI file then it will return a jar: URI. + * + * @param {nsIFile} aFile + * The file containing the resources, must be either a directory or an + * XPI file + * @param {string} aPath + * The path to find the resource at, "/" separated. If aPath is empty + * then the uri to the root of the contained files will be returned + * @returns {nsIURI} + * An nsIURI pointing at the resource + */ +function getURIForResourceInFile(aFile, aPath) { + if (!isXPI(aFile.leafName)) { + let resource = aFile.clone(); + if (aPath) { + aPath.split("/").forEach(part => resource.append(part)); + } + + return Services.io.newFileURI(resource); + } + + return buildJarURI(aFile, aPath); +} + +/** + * Creates a jar: URI for a file inside a ZIP file. + * + * @param {nsIFile} aJarfile + * The ZIP file as an nsIFile + * @param {string} aPath + * The path inside the ZIP file + * @returns {nsIURI} + * An nsIURI for the file + */ +function buildJarURI(aJarfile, aPath) { + let uri = Services.io.newFileURI(aJarfile); + uri = "jar:" + uri.spec + "!/" + aPath; + return Services.io.newURI(uri); +} + +function maybeResolveURI(uri) { + if (uri.schemeIs("resource")) { + return Services.io.newURI(lazy.resProto.resolveURI(uri)); + } + return uri; +} + +/** + * Iterates over the entries in a given directory. + * + * Fails silently if the given directory does not exist. + * + * @param {nsIFile} aDir + * Directory to iterate. + */ +function* iterDirectory(aDir) { + let dirEnum; + try { + dirEnum = aDir.directoryEntries; + let file; + while ((file = dirEnum.nextFile)) { + yield file; + } + } catch (e) { + if (aDir.exists()) { + logger.warn(`Can't iterate directory ${aDir.path}`, e); + } + } finally { + if (dirEnum) { + dirEnum.close(); + } + } +} + +/** + * Migrate data about an addon to match the change made in bug 857456 + * in which "webextension-foo" types were converted to "foo" and the + * "loader" property was added to distinguish different addon types. + * + * @param {Object} addon The addon info to migrate. + * @returns {boolean} True if the addon data was converted, false if not. + */ +function migrateAddonLoader(addon) { + if (addon.hasOwnProperty("loader")) { + return false; + } + + switch (addon.type) { + case "extension": + case "dictionary": + case "locale": + case "theme": + addon.loader = "bootstrap"; + break; + + case "webextension": + addon.type = "extension"; + addon.loader = null; + break; + + case "webextension-dictionary": + addon.type = "dictionary"; + addon.loader = null; + break; + + case "webextension-langpack": + addon.type = "locale"; + addon.loader = null; + break; + + case "webextension-theme": + addon.type = "theme"; + addon.loader = null; + break; + + default: + logger.warn(`Not converting unknown addon type ${addon.type}`); + } + return true; +} + +/** + * The on-disk state of an individual XPI, created from an Object + * as stored in the addonStartup.json file. + */ +const JSON_FIELDS = Object.freeze([ + "dependencies", + "enabled", + "file", + "loader", + "lastModifiedTime", + "path", + "recommendationState", + "rootURI", + "runInSafeMode", + "signedState", + "signedDate", + "startupData", + "telemetryKey", + "type", + "version", +]); + +class XPIState { + constructor(location, id, saved = {}) { + this.location = location; + this.id = id; + + // Set default values. + this.type = "extension"; + + for (let prop of JSON_FIELDS) { + if (prop in saved) { + this[prop] = saved[prop]; + } + } + + // Builds prior to be 1512436 did not include the rootURI property. + // If we're updating from such a build, add that property now. + if (!("rootURI" in this) && this.file) { + this.rootURI = getURIForResourceInFile(this.file, "").spec; + } + + if (!this.telemetryKey) { + this.telemetryKey = this.getTelemetryKey(); + } + + if ( + saved.currentModifiedTime && + saved.currentModifiedTime != this.lastModifiedTime + ) { + this.lastModifiedTime = saved.currentModifiedTime; + } else if (saved.currentModifiedTime === null) { + this.missing = true; + } + } + + // Compatibility shim getters for legacy callers in XPIDatabase.sys.mjs. + get mtime() { + return this.lastModifiedTime; + } + get active() { + return this.enabled; + } + + /** + * @property {string} path + * The full on-disk path of the add-on. + */ + get path() { + return this.file && this.file.path; + } + set path(path) { + this.file = path ? getFile(path, this.location.dir) : null; + } + + /** + * @property {string} relativePath + * The path to the add-on relative to its parent location, or + * the full path if its parent location has no on-disk path. + */ + get relativePath() { + if (this.location.dir && this.location.dir.contains(this.file)) { + let path = this.file.getRelativePath(this.location.dir); + if (AppConstants.platform == "win") { + path = path.replace(/\//g, "\\"); + } + return path; + } + return this.path; + } + + /** + * Returns a JSON-compatible representation of this add-on's state + * data, to be saved to addonStartup.json. + * + * @returns {Object} + */ + toJSON() { + let json = { + dependencies: this.dependencies, + enabled: this.enabled, + lastModifiedTime: this.lastModifiedTime, + loader: this.loader, + path: this.relativePath, + recommendationState: this.recommendationState, + rootURI: this.rootURI, + runInSafeMode: this.runInSafeMode, + signedState: this.signedState, + signedDate: this.signedDate, + telemetryKey: this.telemetryKey, + version: this.version, + }; + if (this.type != "extension") { + json.type = this.type; + } + if (this.startupData) { + json.startupData = this.startupData; + } + return json; + } + + get isWebExtension() { + return this.loader == null; + } + + get isPrivileged() { + return lazy.ExtensionData.getIsPrivileged({ + signedState: this.signedState, + builtIn: this.location.isBuiltin, + temporarilyInstalled: this.location.isTemporary, + }); + } + + /** + * Update the last modified time for an add-on on disk. + * + * @param {nsIFile} aFile + * The location of the add-on. + * @returns {boolean} + * True if the time stamp has changed. + */ + getModTime(aFile) { + let mtime = 0; + try { + // Clone the file object so we always get the actual mtime, rather + // than whatever value it may have cached. + mtime = aFile.clone().lastModifiedTime; + } catch (e) { + logger.warn("Can't get modified time of ${path}", aFile, e); + } + + let changed = mtime != this.lastModifiedTime; + this.lastModifiedTime = mtime; + return changed; + } + + /** + * Returns a string key by which to identify this add-on in telemetry + * and crash reports. + * + * @returns {string} + */ + getTelemetryKey() { + return encoded`${this.id}:${this.version}`; + } + + get resolvedRootURI() { + return maybeResolveURI(Services.io.newURI(this.rootURI)); + } + + /** + * Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true, + * update the last-modified time. This should probably be made async, but for now we + * don't want to maintain parallel sync and async versions of the scan. + * + * Caller is responsible for doing XPIStates.save() if necessary. + * + * @param {DBAddonInternal} aDBAddon + * The DBAddonInternal for this add-on. + * @param {boolean} [aUpdated = false] + * The add-on was updated, so we must record new modified time. + */ + syncWithDB(aDBAddon, aUpdated = false) { + logger.debug("Updating XPIState for " + JSON.stringify(aDBAddon)); + // If the add-on changes from disabled to enabled, we should re-check the modified time. + // If this is a newly found add-on, it won't have an 'enabled' field but we + // did a full recursive scan in that case, so we don't need to do it again. + // We don't use aDBAddon.active here because it's not updated until after restart. + let mustGetMod = aDBAddon.visible && !aDBAddon.disabled && !this.enabled; + + this.enabled = aDBAddon.visible && !aDBAddon.disabled; + + this.version = aDBAddon.version; + this.type = aDBAddon.type; + this.loader = aDBAddon.loader; + + if (aDBAddon.startupData) { + this.startupData = aDBAddon.startupData; + } + + this.telemetryKey = this.getTelemetryKey(); + + this.dependencies = aDBAddon.dependencies; + this.runInSafeMode = canRunInSafeMode(aDBAddon); + this.signedState = aDBAddon.signedState; + this.signedDate = aDBAddon.signedDate; + this.file = aDBAddon._sourceBundle; + this.rootURI = aDBAddon.rootURI; + this.recommendationState = aDBAddon.recommendationState; + + if ((aUpdated || mustGetMod) && this.file) { + this.getModTime(this.file); + if (this.lastModifiedTime != aDBAddon.updateDate) { + aDBAddon.updateDate = this.lastModifiedTime; + if (XPIExports.XPIDatabase.initialized) { + XPIExports.XPIDatabase.saveChanges(); + } + } + } + } +} + +/** + * Manages the state data for add-ons in a given install location. + * + * @param {string} name + * The name of the install location (e.g., "app-profile"). + * @param {string | nsIFile | null} path + * The on-disk path of the install location. May be null for some + * locations which do not map to a specific on-disk path. + * @param {integer} scope + * The scope of add-ons installed in this location. + * @param {object} [saved] + * The persisted JSON state data to restore. + */ +class XPIStateLocation extends Map { + constructor(name, path, scope, saved) { + super(); + + this.name = name; + this.scope = scope; + if (path instanceof Ci.nsIFile) { + this.dir = path; + this.path = path.path; + } else { + this.path = path; + this.dir = this.path && new nsIFile(this.path); + } + this.staged = {}; + this.changed = false; + + if (saved) { + this.restore(saved); + } + + this._installer = undefined; + } + + hasPrecedence(otherLocation) { + let locations = Array.from(XPIStates.locations()); + return locations.indexOf(this) <= locations.indexOf(otherLocation); + } + + get installer() { + if (this._installer === undefined) { + this._installer = this.makeInstaller(); + } + return this._installer; + } + + makeInstaller() { + return null; + } + + restore(saved) { + if (!this.path && saved.path) { + this.path = saved.path; + this.dir = new nsIFile(this.path); + } + this.staged = saved.staged || {}; + this.changed = saved.changed || false; + + for (let [id, data] of Object.entries(saved.addons || {})) { + let xpiState = this._addState(id, data); + + // Make a note that this state was restored from saved data. But + // only if this location hasn't moved since the last startup, + // since that causes problems for new system add-on bundles. + if (!this.path || this.path == saved.path) { + xpiState.wasRestored = true; + } + } + } + + /** + * Returns a JSON-compatible representation of this location's state + * data, to be saved to addonStartup.json. + * + * @returns {Object} + */ + toJSON() { + let json = { + addons: {}, + staged: this.staged, + }; + + if (this.path) { + json.path = this.path; + } + + if (STARTUP_MTIME_SCOPES.includes(this.name)) { + json.checkStartupModifications = true; + } + + for (let [id, addon] of this.entries()) { + json.addons[id] = addon; + } + return json; + } + + get hasStaged() { + for (let key in this.staged) { + return true; + } + return false; + } + + _addState(addonId, saved) { + let xpiState = new XPIState(this, addonId, saved); + this.set(addonId, xpiState); + return xpiState; + } + + /** + * Adds state data for the given DB add-on to the DB. + * + * @param {DBAddon} addon + * The DBAddon to add. + */ + addAddon(addon) { + logger.debug( + "XPIStates adding add-on ${id} in ${location}: ${path}", + addon + ); + + XPIProvider.persistStartupData(addon); + + let xpiState = this._addState(addon.id, { file: addon._sourceBundle }); + xpiState.syncWithDB(addon, true); + + XPIProvider.addTelemetry(addon.id, { location: this.name }); + } + + /** + * Remove the XPIState for an add-on and save the new state. + * + * @param {string} aId + * The ID of the add-on. + */ + removeAddon(aId) { + if (this.has(aId)) { + this.delete(aId); + XPIStates.save(); + } + } + + /** + * Adds stub state data for the local file to the DB. + * + * @param {string} addonId + * The ID of the add-on represented by the given file. + * @param {nsIFile} file + * The local file or directory containing the add-on. + * @returns {XPIState} + */ + addFile(addonId, file) { + let xpiState = this._addState(addonId, { + enabled: false, + file: file.clone(), + }); + xpiState.getModTime(xpiState.file); + return xpiState; + } + + /** + * Adds metadata for a staged install which should be performed after + * the next restart. + * + * @param {string} addonId + * The ID of the staged install. The leaf name of the XPI + * within the location's staging directory must correspond to + * this ID. + * @param {object} metadata + * The JSON metadata of the parsed install, to be used during + * the next startup. + */ + stageAddon(addonId, metadata) { + this.staged[addonId] = metadata; + XPIStates.save(); + } + + /** + * Removes staged install metadata for the given add-on ID. + * + * @param {string} addonId + * The ID of the staged install. + */ + unstageAddon(addonId) { + if (addonId in this.staged) { + delete this.staged[addonId]; + XPIStates.save(); + } + } + + *getStagedAddons() { + for (let [id, metadata] of Object.entries(this.staged)) { + yield [id, metadata]; + } + } + + /** + * Returns true if the given addon was installed in this location by a text + * file pointing to its real path. + * + * @param {string} aId + * The ID of the addon + * @returns {boolean} + */ + isLinkedAddon(aId) { + if (!this.dir) { + return true; + } + return this.has(aId) && !this.dir.contains(this.get(aId).file); + } + + get isTemporary() { + return false; + } + + get isSystem() { + return false; + } + + get isBuiltin() { + return false; + } + + get hidden() { + return this.isBuiltin; + } + + // If this property is false, it does not implement readAddons() + // interface. This is used for the temporary and built-in locations + // that do not correspond to a physical location that can be scanned. + get enumerable() { + return true; + } +} + +class TemporaryLocation extends XPIStateLocation { + /** + * @param {string} name + * The string identifier for the install location. + */ + constructor(name) { + super(name, null, AddonManager.SCOPE_TEMPORARY); + this.locked = false; + } + + makeInstaller() { + // Installs are a no-op. We only register that add-ons exist, and + // run them from their current location. + return { + installAddon() {}, + uninstallAddon() {}, + }; + } + + toJSON() { + return {}; + } + + get isTemporary() { + return true; + } + + get enumerable() { + return false; + } +} + +var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY); + +/** + * A "location" for addons installed from assets packged into the app. + */ +var BuiltInLocation = new (class _BuiltInLocation extends XPIStateLocation { + constructor() { + super(KEY_APP_BUILTINS, null, AddonManager.SCOPE_APPLICATION); + this.locked = false; + } + + // The installer object is responsible for moving files around on disk + // when (un)installing an addon. Since this location handles only addons + // that are embedded within the browser, these are no-ops. + makeInstaller() { + return { + installAddon() {}, + uninstallAddon() {}, + }; + } + + get hidden() { + return false; + } + + get isBuiltin() { + return true; + } + + get enumerable() { + return false; + } + + // Builtin addons are never linked, return false + // here for correct behavior elsewhere. + isLinkedAddon(/* aId */) { + return false; + } +})(); + +/** + * An object which identifies a directory install location for add-ons. The + * location consists of a directory which contains the add-ons installed in the + * location. + * + */ +class DirectoryLocation extends XPIStateLocation { + /** + * Each add-on installed in the location is either a directory containing the + * add-on's files or a text file containing an absolute path to the directory + * containing the add-ons files. The directory or text file must have the same + * name as the add-on's ID. + * + * @param {string} name + * The string identifier for the install location. + * @param {nsIFile} dir + * The directory for the install location. + * @param {integer} scope + * The scope of add-ons installed in this location. + * @param {boolean} [locked = true] + * If false, the location accepts new add-on installs. + * @param {boolean} [system = false] + * If true, the location is a system addon location. + */ + constructor(name, dir, scope, locked = true, system = false) { + super(name, dir, scope); + this.locked = locked; + this._isSystem = system; + } + + makeInstaller() { + if (this.locked) { + return null; + } + return new XPIExports.XPIInstall.DirectoryInstaller(this); + } + + /** + * Reads a single-line file containing the path to a directory, and + * returns an nsIFile pointing to that directory, if successful. + * + * @param {nsIFile} aFile + * The file containing the directory path + * @returns {nsIFile?} + * An nsIFile object representing the linked directory, or null + * on error. + */ + _readLinkFile(aFile) { + let linkedDirectory; + if (aFile.isSymlink()) { + linkedDirectory = aFile.clone(); + try { + linkedDirectory.normalize(); + } catch (e) { + logger.warn( + `Symbolic link ${aFile.path} points to a path ` + + `which does not exist` + ); + return null; + } + } else { + let fis = new FileInputStream(aFile, -1, -1, false); + let line = {}; + fis.QueryInterface(Ci.nsILineInputStream).readLine(line); + fis.close(); + + if (line.value) { + linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsIFile + ); + try { + linkedDirectory.initWithPath(line.value); + } catch (e) { + linkedDirectory.setRelativeDescriptor(aFile.parent, line.value); + } + } + } + + if (linkedDirectory) { + if (!linkedDirectory.exists()) { + logger.warn( + `File pointer ${aFile.path} points to ${linkedDirectory.path} ` + + "which does not exist" + ); + return null; + } + + if (!linkedDirectory.isDirectory()) { + logger.warn( + `File pointer ${aFile.path} points to ${linkedDirectory.path} ` + + "which is not a directory" + ); + return null; + } + + return linkedDirectory; + } + + logger.warn(`File pointer ${aFile.path} does not contain a path`); + return null; + } + + /** + * Finds all the add-ons installed in this location. + * + * @returns {Map} + * A map of add-ons present in this location. + */ + readAddons() { + let addons = new Map(); + + if (!this.dir) { + return addons; + } + + // Use a snapshot of the directory contents to avoid possible issues with + // iterating over a directory while removing files from it (the YAFFS2 + // embedded filesystem has this issue, see bug 772238). + for (let entry of Array.from(iterDirectory(this.dir))) { + let id = getExpectedID(entry); + if (!id) { + if (![DIR_STAGE, DIR_TRASH].includes(entry.leafName)) { + logger.debug( + "Ignoring file: name is not a valid add-on ID: ${}", + entry.path + ); + } + continue; + } + + if (id == entry.leafName && (entry.isFile() || entry.isSymlink())) { + let newEntry = this._readLinkFile(entry); + if (!newEntry) { + logger.debug(`Deleting stale pointer file ${entry.path}`); + try { + entry.remove(true); + } catch (e) { + logger.warn(`Failed to remove stale pointer file ${entry.path}`, e); + // Failing to remove the stale pointer file is ignorable + } + continue; + } + + entry = newEntry; + } + + addons.set(id, entry); + } + return addons; + } + + get isSystem() { + return this._isSystem; + } +} + +/** + * An object which identifies a built-in install location for add-ons, such + * as default system add-ons. + * + * This location should point either to a XPI, or a directory in a local build. + */ +class SystemAddonDefaults extends DirectoryLocation { + /** + * Read the manifest of allowed add-ons and build a mapping between ID and URI + * for each. + * + * @returns {Map} + * A map of add-ons present in this location. + */ + readAddons() { + let addons = new Map(); + + let manifest = XPIProvider.builtInAddons; + + if (!("system" in manifest)) { + logger.debug("No list of valid system add-ons found."); + return addons; + } + + for (let id of manifest.system) { + let file = this.dir.clone(); + file.append(`${id}.xpi`); + + // Only attempt to load unpacked directory if unofficial build. + if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) { + file = this.dir.clone(); + file.append(`${id}`); + } + + addons.set(id, file); + } + + return addons; + } + + get isSystem() { + return true; + } + + get isBuiltin() { + return true; + } +} + +/** + * An object which identifies a directory install location for system add-ons + * updates. + */ +class SystemAddonLocation extends DirectoryLocation { + /** + * The location consists of a directory which contains the add-ons installed. + * + * @param {string} name + * The string identifier for the install location. + * @param {nsIFile} dir + * The directory for the install location. + * @param {integer} scope + * The scope of add-ons installed in this location. + * @param {boolean} resetSet + * True to throw away the current add-on set + */ + constructor(name, dir, scope, resetSet) { + let addonSet = SystemAddonLocation._loadAddonSet(); + let directory = null; + + // The system add-on update directory is stored in a pref. + // Therefore, this is looked up before calling the + // constructor on the superclass. + if (addonSet.directory) { + directory = getFile(addonSet.directory, dir); + logger.info(`SystemAddonLocation scanning directory ${directory.path}`); + } else { + logger.info("SystemAddonLocation directory is missing"); + } + + super(name, directory, scope, false); + + this._addonSet = addonSet; + this._baseDir = dir; + + if (resetSet) { + this.installer.resetAddonSet(); + } + } + + makeInstaller() { + if (this.locked) { + return null; + } + return new XPIExports.XPIInstall.SystemAddonInstaller(this); + } + + /** + * Reads the current set of system add-ons + * + * @returns {Object} + */ + static _loadAddonSet() { + try { + let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null); + if (setStr) { + let addonSet = JSON.parse(setStr); + if (typeof addonSet == "object" && addonSet.schema == 1) { + return addonSet; + } + } + } catch (e) { + logger.error("Malformed system add-on set, resetting."); + } + + return { schema: 1, addons: {} }; + } + + readAddons() { + // Updated system add-ons are ignored in safe mode + if (Services.appinfo.inSafeMode) { + return new Map(); + } + + let addons = super.readAddons(); + + // Strip out any unexpected add-ons from the list + for (let id of addons.keys()) { + if (!(id in this._addonSet.addons)) { + addons.delete(id); + } + } + + return addons; + } + + /** + * Tests whether updated system add-ons are expected. + * + * @returns {boolean} + */ + isActive() { + return this.dir != null; + } + + get isSystem() { + return true; + } + + get isBuiltin() { + return true; + } +} + +/** + * An object that identifies a registry install location for add-ons. The location + * consists of a registry key which contains string values mapping ID to the + * path where an add-on is installed + * + */ +class WinRegLocation extends XPIStateLocation { + /** + * @param {string} name + * The string identifier for the install location. + * @param {integer} rootKey + * The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey). + * @param {integer} scope + * The scope of add-ons installed in this location. + */ + constructor(name, rootKey, scope) { + super(name, undefined, scope); + + this.locked = true; + this._rootKey = rootKey; + } + + /** + * Retrieves the path of this Application's data key in the registry. + */ + get _appKeyPath() { + let appVendor = Services.appinfo.vendor; + let appName = Services.appinfo.name; + + // XXX Thunderbird doesn't specify a vendor string + if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird") { + appVendor = "Mozilla"; + } + + return `SOFTWARE\\${appVendor}\\${appName}`; + } + + /** + * Read the registry and build a mapping between ID and path for each + * installed add-on. + * + * @returns {Map} + * A map of add-ons in this location. + */ + readAddons() { + let addons = new Map(); + + let path = `${this._appKeyPath}\\Extensions`; + let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + + // Reading the registry may throw an exception, and that's ok. In error + // cases, we just leave ourselves in the empty state. + try { + key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ); + } catch (e) { + return addons; + } + + try { + let count = key.valueCount; + for (let i = 0; i < count; ++i) { + let id = key.getValueName(i); + let file = new nsIFile(key.readStringValue(id)); + if (!file.exists()) { + logger.warn(`Ignoring missing add-on in ${file.path}`); + continue; + } + + addons.set(id, file); + } + } finally { + key.close(); + } + + return addons; + } +} + +/** + * Keeps track of the state of XPI add-ons on the file system. + */ +var XPIStates = { + // Map(location-name -> XPIStateLocation) + db: new Map(), + + _jsonFile: null, + + /** + * @property {Map} sideLoadedAddons + * A map of new add-ons detected during install location + * directory scans. Keys are add-on IDs, values are XPIState + * objects corresponding to those add-ons. + */ + sideLoadedAddons: new Map(), + + get size() { + let count = 0; + for (let location of this.locations()) { + count += location.size; + } + return count; + }, + + /** + * Load extension state data from addonStartup.json. + * + * @returns {Object} + */ + loadExtensionState() { + let state; + try { + state = lazy.aomStartup.readStartupData(); + } catch (e) { + logger.warn("Error parsing extensions state: ${error}", { error: e }); + } + + // When upgrading from a build prior to bug 857456, convert startup + // metadata. + let done = false; + for (let location of Object.values(state || {})) { + for (let data of Object.values(location.addons || {})) { + if (!migrateAddonLoader(data)) { + done = true; + break; + } + } + if (done) { + break; + } + } + + logger.debug("Loaded add-on state: ${}", state); + return state || {}; + }, + + /** + * Walk through all install locations, highest priority first, + * comparing the on-disk state of extensions to what is stored in prefs. + * + * @param {boolean} [ignoreSideloads = true] + * If true, ignore changes in scopes where we don't accept + * side-loads. + * + * @returns {boolean} + * True if anything has changed. + */ + scanForChanges(ignoreSideloads = true) { + let oldState = this.initialStateData || this.loadExtensionState(); + // We're called twice, do not restore the second time as new data + // may have been inserted since the first call. + let shouldRestoreLocationData = !this.initialStateData; + this.initialStateData = oldState; + + let changed = false; + let oldLocations = new Set(Object.keys(oldState)); + + let startupScanScopes; + if ( + Services.appinfo.appBuildID == + Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, "") + ) { + startupScanScopes = Services.prefs.getIntPref( + PREF_EM_STARTUP_SCAN_SCOPES, + 0 + ); + } else { + // If the build id has changed, we need to do a full scan on first startup. + Services.prefs.setCharPref( + PREF_EM_LAST_APP_BUILD_ID, + Services.appinfo.appBuildID + ); + startupScanScopes = AddonManager.SCOPE_ALL; + } + + for (let loc of XPIStates.locations()) { + oldLocations.delete(loc.name); + + if (shouldRestoreLocationData && oldState[loc.name]) { + loc.restore(oldState[loc.name]); + } + changed = changed || loc.changed; + + // Don't bother checking scopes where we don't accept side-loads. + if (ignoreSideloads && !(loc.scope & startupScanScopes)) { + continue; + } + + if (!loc.enumerable) { + continue; + } + + // Don't bother scanning scopes where we don't have addons installed if they + // do not allow sideloading new addons. Once we have an addon in one of those + // locations, we need to check the location for changes (updates/deletions). + if (!loc.size && !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD)) { + continue; + } + + let knownIds = new Set(loc.keys()); + for (let [id, file] of loc.readAddons()) { + knownIds.delete(id); + + let xpiState = loc.get(id); + if (!xpiState) { + // If the location is not supported for sideloading, skip new + // addons. We handle this here so changes for existing sideloads + // will function. + if ( + !loc.isSystem && + !(loc.scope & lazy.AddonSettings.SCOPES_SIDELOAD) + ) { + continue; + } + logger.debug("New add-on ${id} in ${loc}", { id, loc: loc.name }); + + changed = true; + xpiState = loc.addFile(id, file); + if (!loc.isSystem) { + this.sideLoadedAddons.set(id, xpiState); + } + } else { + let addonChanged = + xpiState.getModTime(file) || file.path != xpiState.path; + xpiState.file = file.clone(); + + if (addonChanged) { + changed = true; + logger.debug("Changed add-on ${id} in ${loc}", { + id, + loc: loc.name, + }); + } else { + logger.debug("Existing add-on ${id} in ${loc}", { + id, + loc: loc.name, + }); + } + } + XPIProvider.addTelemetry(id, { location: loc.name }); + } + + // Anything left behind in oldState was removed from the file system. + for (let id of knownIds) { + loc.delete(id); + changed = true; + } + } + + // If there's anything left in oldState, an install location that held add-ons + // was removed from the browser configuration. + changed = changed || oldLocations.size > 0; + + logger.debug("scanForChanges changed: ${rv}, state: ${state}", { + rv: changed, + state: this.db, + }); + return changed; + }, + + locations() { + return this.db.values(); + }, + + /** + * @param {string} name + * The location name. + * @param {XPIStateLocation} location + * The location object. + */ + addLocation(name, location) { + if (this.db.has(name)) { + throw new Error(`Trying to add duplicate location: ${name}`); + } + this.db.set(name, location); + }, + + /** + * Get the Map of XPI states for a particular location. + * + * @param {string} name + * The name of the install location. + * + * @returns {XPIStateLocation?} + * (id -> XPIState) or null if there are no add-ons in the location. + */ + getLocation(name) { + return this.db.get(name); + }, + + /** + * Get the XPI state for a specific add-on in a location. + * If the state is not in our cache, return null. + * + * @param {string} aLocation + * The name of the location where the add-on is installed. + * @param {string} aId + * The add-on ID + * + * @returns {XPIState?} + * The XPIState entry for the add-on, or null. + */ + getAddon(aLocation, aId) { + let location = this.db.get(aLocation); + return location && location.get(aId); + }, + + /** + * Find the highest priority location of an add-on by ID and return the + * XPIState. + * @param {string} aId + * The add-on IDa + * @param {function} aFilter + * An optional filter to apply to install locations. If provided, + * addons in locations that do not match the filter are not considered. + * + * @returns {XPIState?} + */ + findAddon(aId, aFilter = location => true) { + // Fortunately the Map iterator returns in order of insertion, which is + // also our highest -> lowest priority order. + for (let location of this.locations()) { + if (!aFilter(location)) { + continue; + } + if (location.has(aId)) { + return location.get(aId); + } + } + return undefined; + }, + + /** + * Iterates over the list of all enabled add-ons in any location. + */ + *enabledAddons() { + for (let location of this.locations()) { + for (let entry of location.values()) { + if (entry.enabled) { + yield entry; + } + } + } + }, + + /** + * Add a new XPIState for an add-on and synchronize it with the DBAddonInternal. + * + * @param {DBAddonInternal} aAddon + * The add-on to add. + */ + addAddon(aAddon) { + aAddon.location.addAddon(aAddon); + }, + + /** + * Save the current state of installed add-ons. + */ + save() { + if (!this._jsonFile) { + this._jsonFile = new lazy.JSONFile({ + path: PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + FILE_XPI_STATES + ), + finalizeAt: AddonManagerPrivate.finalShutdown, + compression: "lz4", + }); + this._jsonFile.data = this; + } + + this._jsonFile.saveSoon(); + }, + + toJSON() { + let data = {}; + for (let [key, loc] of this.db.entries()) { + if (!loc.isTemporary && (loc.size || loc.hasStaged)) { + data[key] = loc; + } + } + return data; + }, + + /** + * Remove the XPIState for an add-on and save the new state. + * + * @param {string} aLocation + * The name of the add-on location. + * @param {string} aId + * The ID of the add-on. + * + */ + removeAddon(aLocation, aId) { + logger.debug(`Removing XPIState for ${aLocation}: ${aId}`); + let location = this.db.get(aLocation); + if (location) { + location.removeAddon(aId); + this.save(); + } + }, + + /** + * Disable the XPIState for an add-on. + * + * @param {string} aId + * The ID of the add-on. + */ + disableAddon(aId) { + logger.debug(`Disabling XPIState for ${aId}`); + let state = this.findAddon(aId); + if (state) { + state.enabled = false; + } + }, +}; + +/** + * A helper class to manage the lifetime of and interaction with + * bootstrap scopes for an add-on. + * + * @param {Object} addon + * The add-on which owns this scope. Should be either an + * AddonInternal or XPIState object. + */ +class BootstrapScope { + constructor(addon) { + if (!addon.id || !addon.version || !addon.type) { + throw new Error("Addon must include an id, version, and type"); + } + + this.addon = addon; + this.instanceID = null; + this.scope = null; + this.started = false; + } + + /** + * Returns a BootstrapScope object for the given add-on. If an active + * scope exists, it is returned. Otherwise a new one is created. + * + * @param {Object} addon + * The add-on which owns this scope, as accepted by the + * constructor. + * @returns {BootstrapScope} + */ + static get(addon) { + let scope = XPIProvider.activeAddons.get(addon.id); + if (!scope) { + scope = new this(addon); + } + return scope; + } + + get file() { + return this.addon.file || this.addon._sourceBundle; + } + + get runInSafeMode() { + return "runInSafeMode" in this.addon + ? this.addon.runInSafeMode + : canRunInSafeMode(this.addon); + } + + /** + * Returns state information for use by an AsyncShutdown blocker. If + * the wrapped bootstrap scope has a fetchState method, it is called, + * and its result returned. If not, returns null. + * + * @returns {Object|null} + */ + fetchState() { + if (this.scope && this.scope.fetchState) { + return this.scope.fetchState(); + } + return null; + } + + /** + * Calls a bootstrap method for an add-on. + * + * @param {string} aMethod + * The name of the bootstrap method to call + * @param {integer} aReason + * The reason flag to pass to the bootstrap's startup method + * @param {Object} [aExtraParams = {}] + * An object of additional key/value pairs to pass to the method in + * the params argument + * @returns {any} + * The return value of the bootstrap method. + */ + async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) { + let { addon, runInSafeMode } = this; + if ( + Services.appinfo.inSafeMode && + !runInSafeMode && + aMethod !== "uninstall" + ) { + return null; + } + + try { + if (!this.scope) { + this.loadBootstrapScope(aReason); + } + + if (aMethod == "startup" || aMethod == "shutdown") { + aExtraParams.instanceID = this.instanceID; + } + + let method = undefined; + let { scope } = this; + try { + method = scope[aMethod]; + } catch (e) { + // An exception will be caught if the expected method is not defined. + // That will be logged below. + } + + if (aMethod == "startup") { + this.started = true; + } else if (aMethod == "shutdown") { + this.started = false; + + // Extensions are automatically deinitialized in the correct order at shutdown. + if (aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) { + this._pendingDisable = true; + for (let addon of XPIProvider.getDependentAddons(this.addon)) { + if (addon.active) { + await XPIExports.XPIDatabase.updateAddonDisabledState(addon); + } + } + } + } + + let params = { + id: addon.id, + version: addon.version, + resourceURI: addon.resolvedRootURI, + signedState: addon.signedState, + temporarilyInstalled: addon.location.isTemporary, + builtIn: addon.location.isBuiltin, + isSystem: addon.location.isSystem, + isPrivileged: addon.isPrivileged, + locationHidden: addon.location.hidden, + recommendationState: addon.recommendationState, + }; + + if (aMethod == "startup" && addon.startupData) { + params.startupData = addon.startupData; + } + + Object.assign(params, aExtraParams); + + let result; + if (!method) { + logger.warn( + `Add-on ${addon.id} is missing bootstrap method ${aMethod}` + ); + } else { + logger.debug( + `Calling bootstrap method ${aMethod} on ${addon.id} version ${addon.version}` + ); + + this._beforeCallBootstrapMethod(aMethod, params, aReason); + + try { + result = await method.call(scope, params, aReason); + } catch (e) { + logger.warn( + `Exception running bootstrap method ${aMethod} on ${addon.id}`, + e + ); + } + } + return result; + } finally { + // Extensions are automatically initialized in the correct order at startup. + if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) { + for (let addon of XPIProvider.getDependentAddons(this.addon)) { + XPIExports.XPIDatabase.updateAddonDisabledState(addon); + } + } + } + } + + // No-op method to be overridden by tests. + _beforeCallBootstrapMethod() {} + + /** + * Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason + * values as constants in the scope. + * + * @param {integer?} [aReason] + * The reason this bootstrap is being loaded, as passed to a + * bootstrap method. + */ + loadBootstrapScope(aReason) { + this.instanceID = Symbol(this.addon.id); + this._pendingDisable = false; + + XPIProvider.activeAddons.set(this.addon.id, this); + + // Mark the add-on as active for the crash reporter before loading. + // But not at app startup, since we'll already have added all of our + // annotations before starting any loads. + if (aReason !== BOOTSTRAP_REASONS.APP_STARTUP) { + XPIProvider.addAddonsToCrashReporter(); + } + + logger.debug(`Loading bootstrap scope from ${this.addon.rootURI}`); + + if (this.addon.isWebExtension) { + switch (this.addon.type) { + case "extension": + case "theme": + this.scope = lazy.Extension.getBootstrapScope(); + break; + + // TODO(Bug 1789718): Remove after the deprecated XPIProvider-based implementation is also removed. + case "sitepermission-deprecated": + this.scope = lazy.SitePermission.getBootstrapScope(); + break; + + case "locale": + this.scope = lazy.Langpack.getBootstrapScope(); + break; + + case "dictionary": + this.scope = lazy.Dictionary.getBootstrapScope(); + break; + + default: + throw new Error(`Unknown webextension type ${this.addon.type}`); + } + } else { + let loader = AddonManagerPrivate.externalExtensionLoaders.get( + this.addon.loader + ); + if (!loader) { + throw new Error(`Cannot find loader for ${this.addon.loader}`); + } + + this.scope = loader.loadScope(this.addon); + } + } + + /** + * Unloads a bootstrap scope by dropping all references to it and then + * updating the list of active add-ons with the crash reporter. + */ + unloadBootstrapScope() { + XPIProvider.activeAddons.delete(this.addon.id); + XPIProvider.addAddonsToCrashReporter(); + + this.scope = null; + this.startupPromise = null; + this.instanceID = null; + } + + /** + * Calls the bootstrap scope's startup method, with the given reason + * and extra parameters. + * + * @param {integer} reason + * The reason code for the startup call. + * @param {Object} [aExtraParams] + * Optional extra parameters to pass to the bootstrap method. + * @returns {Promise} + * Resolves when the startup method has run to completion, rejects + * if called late during shutdown. + */ + async startup(reason, aExtraParams) { + if (this.shutdownPromise) { + await this.shutdownPromise; + } + + if ( + Services.startup.isInOrBeyondShutdownPhase( + Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED + ) + ) { + let err = new Error( + `XPIProvider can't start bootstrap scope for ${this.addon.id} after shutdown was already granted` + ); + logger.warn("BoostrapScope startup failure: ${error}", { error: err }); + this.startupPromise = Promise.reject(err); + } else { + this.startupPromise = this.callBootstrapMethod( + "startup", + reason, + aExtraParams + ); + } + + return this.startupPromise; + } + + /** + * Calls the bootstrap scope's shutdown method, with the given reason + * and extra parameters. + * + * @param {integer} reason + * The reason code for the shutdown call. + * @param {Object} [aExtraParams] + * Optional extra parameters to pass to the bootstrap method. + */ + async shutdown(reason, aExtraParams) { + this.shutdownPromise = this._shutdown(reason, aExtraParams); + await this.shutdownPromise; + this.shutdownPromise = null; + } + + async _shutdown(reason, aExtraParams) { + await this.startupPromise; + return this.callBootstrapMethod("shutdown", reason, aExtraParams); + } + + /** + * If the add-on is already running, calls its "shutdown" method, and + * unloads its bootstrap scope. + * + * @param {integer} reason + * The reason code for the shutdown call. + * @param {Object} [aExtraParams] + * Optional extra parameters to pass to the bootstrap method. + */ + async disable() { + if (this.started) { + await this.shutdown(BOOTSTRAP_REASONS.ADDON_DISABLE); + // If we disable and re-enable very quickly, it's possible that + // the next startup() method will be called immediately after this + // shutdown method finishes. This almost never happens outside of + // tests. In tests, alas... + if (!this.started) { + this.unloadBootstrapScope(); + } + } + } + + /** + * Calls the bootstrap scope's install method, and optionally its + * startup method. + * + * @param {integer} reason + * The reason code for the calls. + * @param {boolean} [startup = false] + * If true, and the add-on is active, calls its startup method + * after its install method. + * @param {Object} [extraArgs] + * Optional extra parameters to pass to the bootstrap method. + * @returns {Promise} + * Resolves when the startup method has run to completion, if + * startup is required. + */ + install(reason = BOOTSTRAP_REASONS.ADDON_INSTALL, startup, extraArgs) { + return this._install(reason, false, startup, extraArgs); + } + + async _install(reason, callUpdate, startup, extraArgs) { + if (callUpdate) { + await this.callBootstrapMethod("update", reason, extraArgs); + } else { + this.callBootstrapMethod("install", reason, extraArgs); + } + + if (startup && this.addon.active) { + await this.startup(reason, extraArgs); + } else if (this.addon.disabled) { + this.unloadBootstrapScope(); + } + } + + /** + * Calls the bootstrap scope's uninstall method, and unloads its + * bootstrap scope. If the extension is already running, its shutdown + * method is called before its uninstall method. + * + * @param {integer} reason + * The reason code for the calls. + * @param {Object} [extraArgs] + * Optional extra parameters to pass to the bootstrap method. + * @returns {Promise} + * Resolves when the shutdown method has run to completion, if + * shutdown is required, and the uninstall method has been + * called. + */ + uninstall(reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL, extraArgs) { + return this._uninstall(reason, false, extraArgs); + } + + async _uninstall(reason, callUpdate, extraArgs) { + if (this.started) { + await this.shutdown(reason, extraArgs); + } + if (!callUpdate) { + this.callBootstrapMethod("uninstall", reason, extraArgs); + } + this.unloadBootstrapScope(); + + if (this.file) { + XPIExports.XPIInstall.flushJarCache(this.file); + } + } + + /** + * Calls the appropriate sequence of shutdown, uninstall, update, + * startup, and install methods for updating the current scope's + * add-on to the given new add-on, depending on the current state of + * the scope. + * + * @param {XPIState} newAddon + * The new add-on which is being installed, as expected by the + * constructor. + * @param {boolean} [startup = false] + * If true, and the new add-on is enabled, calls its startup + * method as its final operation. + * @param {function} [updateCallback] + * An optional callback function to call between uninstalling + * the old add-on and installing the new one. This callback + * should update any database state which is necessary for the + * startup of the new add-on. + * @returns {Promise} + * Resolves when all required bootstrap callbacks have + * completed. + */ + async update(newAddon, startup = false, updateCallback) { + let reason = XPIExports.XPIInstall.newVersionReason( + this.addon.version, + newAddon.version + ); + + let callUpdate = this.addon.isWebExtension && newAddon.isWebExtension; + + // BootstrapScope gets either an XPIState instance or an AddonInternal + // instance, when we update, we need the latter to access permissions + // from the manifest. + let existingAddon = this.addon; + + let extraArgs = { + oldVersion: existingAddon.version, + newVersion: newAddon.version, + }; + + // If we're updating an extension, we may need to read data to + // calculate permission changes. + if (callUpdate && existingAddon.type === "extension") { + if (this.addon instanceof XPIState) { + // The existing addon will be cached in the database. + existingAddon = await XPIExports.XPIDatabase.getAddonByID( + this.addon.id + ); + } + + if (newAddon instanceof XPIState) { + newAddon = await XPIExports.XPIInstall.loadManifestFromFile( + newAddon.file, + newAddon.location + ); + } + + Object.assign(extraArgs, { + userPermissions: newAddon.userPermissions, + optionalPermissions: newAddon.optionalPermissions, + oldPermissions: existingAddon.userPermissions, + oldOptionalPermissions: existingAddon.optionalPermissions, + }); + } + + await this._uninstall(reason, callUpdate, extraArgs); + + if (updateCallback) { + await updateCallback(); + } + + this.addon = newAddon; + return this._install(reason, callUpdate, startup, extraArgs); + } +} + +let resolveDBReady; +let dbReadyPromise = new Promise(resolve => { + resolveDBReady = resolve; +}); +let resolveProviderReady; +let providerReadyPromise = new Promise(resolve => { + resolveProviderReady = resolve; +}); + +export var XPIProvider = { + get name() { + return "XPIProvider"; + }, + + BOOTSTRAP_REASONS: Object.freeze(BOOTSTRAP_REASONS), + + // A Map of active addons to their bootstrapScope by ID + activeAddons: new Map(), + // Per-addon telemetry information + _telemetryDetails: {}, + // Have we started shutting down bootstrap add-ons? + _closing: false, + + // Promises awaited by the XPIProvider before resolving providerReadyPromise, + // (pushed into the array by XPIProvider maybeInstallBuiltinAddon and startup + // methods). + startupPromises: [], + + // Array of the bootstrap startup promises for the enabled addons being + // initiated during the XPIProvider startup. + // + // NOTE: XPIProvider will wait for these promises (and the startupPromises one) + // to have settled before allowing the application to proceed with shutting down + // (see quitApplicationGranted blocker at the end of the XPIProvider.startup). + enabledAddonsStartupPromises: [], + + databaseReady: Promise.all([dbReadyPromise, providerReadyPromise]), + + registerProvider() { + AddonManagerPrivate.registerProvider(this, Array.from(ALL_XPI_TYPES)); + }, + + // Check if the XPIDatabase has been loaded (without actually + // triggering unwanted imports or I/O) + get isDBLoaded() { + // Make sure we don't touch the XPIDatabase getter before it's + // actually loaded, and force an early load. + return ( + (Object.getOwnPropertyDescriptor(XPIExports, "XPIDatabase").value && + XPIExports.XPIDatabase.initialized) || + false + ); + }, + + /** + * Returns true if the add-on with the given ID is currently active, + * without forcing the add-ons database to load. + * + * @param {string} addonId + * The ID of the add-on to check. + * @returns {boolean} + */ + addonIsActive(addonId) { + let state = XPIStates.findAddon(addonId); + return state && state.enabled; + }, + + /** + * Returns an array of the add-on values in `enabledAddons`, + * sorted so that all of an add-on's dependencies appear in the array + * before itself. + * + * @returns {Array} + * A sorted array of add-on objects. Each value is a copy of the + * corresponding value in the `enabledAddons` object, with an + * additional `id` property, which corresponds to the key in that + * object, which is the same as the add-ons ID. + */ + sortBootstrappedAddons() { + function compare(a, b) { + if (a === b) { + return 0; + } + return a < b ? -1 : 1; + } + + // Sort the list so that ordering is deterministic. + let list = Array.from(XPIStates.enabledAddons()); + list.sort((a, b) => compare(a.id, b.id)); + + let addons = {}; + for (let entry of list) { + addons[entry.id] = entry; + } + + let res = new Set(); + let seen = new Set(); + + let add = addon => { + seen.add(addon.id); + + for (let id of addon.dependencies || []) { + if (id in addons && !seen.has(id)) { + add(addons[id]); + } + } + + res.add(addon.id); + }; + + Object.values(addons).forEach(add); + + return Array.from(res, id => addons[id]); + }, + + /* + * Adds metadata to the telemetry payload for the given add-on. + */ + addTelemetry(aId, aPayload) { + if (!this._telemetryDetails[aId]) { + this._telemetryDetails[aId] = {}; + } + Object.assign(this._telemetryDetails[aId], aPayload); + }, + + setupInstallLocations(aAppChanged) { + function DirectoryLoc(aName, aScope, aKey, aPaths, aLocked, aIsSystem) { + try { + var dir = lazy.FileUtils.getDir(aKey, aPaths); + } catch (e) { + return null; + } + return new DirectoryLocation(aName, dir, aScope, aLocked, aIsSystem); + } + + function SystemDefaultsLoc(name, scope, key, paths) { + try { + var dir = lazy.FileUtils.getDir(key, paths); + } catch (e) { + return null; + } + return new SystemAddonDefaults(name, dir, scope); + } + + function SystemLoc(aName, aScope, aKey, aPaths) { + try { + var dir = lazy.FileUtils.getDir(aKey, aPaths); + } catch (e) { + return null; + } + return new SystemAddonLocation(aName, dir, aScope, aAppChanged !== false); + } + + function RegistryLoc(aName, aScope, aKey) { + if ("nsIWindowsRegKey" in Ci) { + return new WinRegLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope); + } + } + + // These must be in order of priority, highest to lowest, + // for processFileChanges etc. to work + let locations = [ + [() => TemporaryInstallLocation, TemporaryInstallLocation.name, null], + + [ + DirectoryLoc, + KEY_APP_PROFILE, + AddonManager.SCOPE_PROFILE, + KEY_PROFILEDIR, + [DIR_EXTENSIONS], + false, + ], + + [ + DirectoryLoc, + KEY_APP_SYSTEM_PROFILE, + AddonManager.SCOPE_APPLICATION, + KEY_PROFILEDIR, + [DIR_APP_SYSTEM_PROFILE], + false, + true, + ], + + [ + SystemLoc, + KEY_APP_SYSTEM_ADDONS, + AddonManager.SCOPE_PROFILE, + KEY_PROFILEDIR, + [DIR_SYSTEM_ADDONS], + ], + + [ + SystemDefaultsLoc, + KEY_APP_SYSTEM_DEFAULTS, + AddonManager.SCOPE_PROFILE, + KEY_APP_FEATURES, + [], + ], + + [() => BuiltInLocation, KEY_APP_BUILTINS, AddonManager.SCOPE_APPLICATION], + + [ + DirectoryLoc, + KEY_APP_SYSTEM_USER, + AddonManager.SCOPE_USER, + "XREUSysExt", + [Services.appinfo.ID], + true, + ], + + [ + RegistryLoc, + "winreg-app-user", + AddonManager.SCOPE_USER, + "ROOT_KEY_CURRENT_USER", + ], + + [ + DirectoryLoc, + KEY_APP_GLOBAL, + AddonManager.SCOPE_APPLICATION, + KEY_ADDON_APP_DIR, + [DIR_EXTENSIONS], + true, + ], + + [ + DirectoryLoc, + KEY_APP_SYSTEM_SHARE, + AddonManager.SCOPE_SYSTEM, + "XRESysSExtPD", + [Services.appinfo.ID], + true, + ], + + [ + DirectoryLoc, + KEY_APP_SYSTEM_LOCAL, + AddonManager.SCOPE_SYSTEM, + "XRESysLExtPD", + [Services.appinfo.ID], + true, + ], + + [ + RegistryLoc, + "winreg-app-global", + AddonManager.SCOPE_SYSTEM, + "ROOT_KEY_LOCAL_MACHINE", + ], + ]; + + for (let [constructor, name, scope, ...args] of locations) { + if (!scope || lazy.enabledScopes & scope) { + try { + let loc = constructor(name, scope, ...args); + if (loc) { + XPIStates.addLocation(name, loc); + } + } catch (e) { + logger.warn( + `Failed to add ${constructor.name} install location ${name}`, + e + ); + } + } + } + }, + + /** + * Registers the built-in set of dictionaries with the spell check + * service. + */ + registerBuiltinDictionaries() { + this.dictionaries = {}; + for (let [lang, path] of Object.entries( + this.builtInAddons.dictionaries || {} + )) { + path = path.slice(0, -4) + ".aff"; + let uri = Services.io.newURI(`resource://gre/${path}`); + + this.dictionaries[lang] = uri; + lazy.spellCheck.addDictionary(lang, uri); + } + }, + + /** + * Unregisters the dictionaries in the given object, and re-registers + * any built-in dictionaries in their place, when they exist. + * + * @param {Object} aDicts + * An object containing a property with a dictionary language + * code and a nsIURI value for each dictionary to be + * unregistered. + */ + unregisterDictionaries(aDicts) { + let origDicts = lazy.spellCheck.dictionaries.slice(); + let toRemove = []; + + for (let [lang, uri] of Object.entries(aDicts)) { + if ( + lazy.spellCheck.removeDictionary(lang, uri) && + this.dictionaries.hasOwnProperty(lang) + ) { + lazy.spellCheck.addDictionary(lang, this.dictionaries[lang]); + } else { + toRemove.push(lang); + } + } + + lazy.spellCheck.dictionaries = origDicts.filter( + lang => !toRemove.includes(lang) + ); + }, + + /** + * Starts the XPI provider initializes the install locations and prefs. + * + * @param {boolean?} aAppChanged + * A tri-state value. Undefined means the current profile was created + * for this session, true means the profile already existed but was + * last used with an application with a different version number, + * false means that the profile was last used by this version of the + * application. + * @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 + */ + startup(aAppChanged, aOldAppVersion, aOldPlatformVersion) { + try { + AddonManagerPrivate.recordTimestamp("XPI_startup_begin"); + + logger.debug("startup"); + + this.builtInAddons = {}; + try { + let url = Services.io.newURI(BUILT_IN_ADDONS_URI); + let data = Cu.readUTF8URI(url); + this.builtInAddons = JSON.parse(data); + } catch (e) { + if (AppConstants.DEBUG) { + logger.debug("List of built-in add-ons is missing or invalid.", e); + } + } + + this.registerBuiltinDictionaries(); + + // Clear this at startup for xpcshell test restarts + this._telemetryDetails = {}; + // Register our details structure with AddonManager + AddonManagerPrivate.setTelemetryDetails("XPI", this._telemetryDetails); + + this.setupInstallLocations(aAppChanged); + + if (!AppConstants.MOZ_REQUIRE_SIGNING || Cu.isInAutomation) { + Services.prefs.addObserver(PREF_XPI_SIGNATURES_REQUIRED, this); + } + Services.prefs.addObserver(PREF_LANGPACK_SIGNATURES, this); + Services.obs.addObserver(this, NOTIFICATION_FLUSH_PERMISSIONS); + + this.checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion); + + AddonManagerPrivate.markProviderSafe(this); + + const lastTheme = Services.prefs.getCharPref( + "extensions.activeThemeID", + null + ); + + if ( + lastTheme === "recommended-1" || + lastTheme === "recommended-2" || + lastTheme === "recommended-3" || + lastTheme === "recommended-4" || + lastTheme === "recommended-5" + ) { + // The user is using a theme that was once bundled with Firefox, but no longer + // is. Clear their theme so that they will be forced to reset to the default. + this.startupPromises.push( + AddonManagerPrivate.notifyAddonChanged(null, "theme") + ); + } + this.maybeInstallBuiltinAddon( + "default-theme@mozilla.org", + "1.3", + "resource://default-theme/" + ); + + resolveProviderReady(Promise.all(this.startupPromises)); + + if (AppConstants.MOZ_CRASHREPORTER) { + // Annotate the crash report with relevant add-on information. + try { + // The `EMCheckCompatibility` annotation represents a boolean, but + // we've historically set it as a string so keep doing it for the + // time being. + Services.appinfo.annotateCrashReport( + "EMCheckCompatibility", + AddonManager.checkCompatibility.toString() + ); + } catch (e) {} + this.addAddonsToCrashReporter(); + } + + try { + AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_begin"); + + for (let addon of this.sortBootstrappedAddons()) { + // The startup update check above may have already started some + // extensions, make sure not to try to start them twice. + let activeAddon = this.activeAddons.get(addon.id); + if (activeAddon && activeAddon.started) { + continue; + } + try { + let reason = BOOTSTRAP_REASONS.APP_STARTUP; + // Eventually set INSTALLED reason when a bootstrap addon + // is dropped in profile folder and automatically installed + if ( + AddonManager.getStartupChanges( + AddonManager.STARTUP_CHANGE_INSTALLED + ).includes(addon.id) + ) { + reason = BOOTSTRAP_REASONS.ADDON_INSTALL; + } else if ( + AddonManager.getStartupChanges( + AddonManager.STARTUP_CHANGE_ENABLED + ).includes(addon.id) + ) { + reason = BOOTSTRAP_REASONS.ADDON_ENABLE; + } + this.enabledAddonsStartupPromises.push( + BootstrapScope.get(addon).startup(reason) + ); + } catch (e) { + logger.error( + "Failed to load bootstrap addon " + + addon.id + + " from " + + addon.descriptor, + e + ); + } + } + AddonManagerPrivate.recordTimestamp("XPI_bootstrap_addons_end"); + } catch (e) { + logger.error("bootstrap startup failed", e); + AddonManagerPrivate.recordException( + "XPI-BOOTSTRAP", + "startup failed", + e + ); + } + + // Let these shutdown a little earlier when they still have access to most + // of XPCOM + lazy.AsyncShutdown.quitApplicationGranted.addBlocker( + "XPIProvider shutdown", + async () => { + // Do not enter shutdown before we actually finished starting as this + // can lead to hangs as seen in bug 1814104. + await Promise.allSettled([ + ...this.startupPromises, + ...this.enabledAddonsStartupPromises, + ]); + + XPIProvider._closing = true; + + await XPIProvider.cleanupTemporaryAddons(); + for (let addon of XPIProvider.sortBootstrappedAddons().reverse()) { + // If no scope has been loaded for this add-on then there is no need + // to shut it down (should only happen when a bootstrapped add-on is + // pending enable) + let activeAddon = XPIProvider.activeAddons.get(addon.id); + if (!activeAddon || !activeAddon.started) { + continue; + } + + // If the add-on was pending disable then shut it down and remove it + // from the persisted data. + let reason = BOOTSTRAP_REASONS.APP_SHUTDOWN; + if (addon._pendingDisable) { + reason = BOOTSTRAP_REASONS.ADDON_DISABLE; + } else if (addon.location.name == KEY_APP_TEMPORARY) { + reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL; + let existing = XPIStates.findAddon( + addon.id, + loc => !loc.isTemporary + ); + if (existing) { + reason = XPIExports.XPIInstall.newVersionReason( + addon.version, + existing.version + ); + } + } + + let scope = BootstrapScope.get(addon); + let promise = scope.shutdown(reason); + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Extension shutdown: ${addon.id}`, + promise, + { + fetchState: scope.fetchState.bind(scope), + } + ); + } + } + ); + + // Detect final-ui-startup for telemetry reporting + Services.obs.addObserver(function observer() { + AddonManagerPrivate.recordTimestamp("XPI_finalUIStartup"); + Services.obs.removeObserver(observer, "final-ui-startup"); + }, "final-ui-startup"); + + // If we haven't yet loaded the XPI database, schedule loading it + // to occur once other important startup work is finished. We want + // this to happen relatively quickly after startup so the telemetry + // environment has complete addon information. + // + // Unfortunately we have to use a variety of ways do detect when it + // is time to load. In a regular browser process we just wait for + // sessionstore-windows-restored. In a browser toolbox process + // we wait for the toolbox to show up, based on xul-window-visible + // and a visible toolbox window. + // + // TelemetryEnvironment's EnvironmentAddonBuilder awaits databaseReady + // before releasing a blocker on AddonManager.beforeShutdown, which in its + // turn is a blocker of a shutdown blocker at "profile-before-change". + // To avoid a deadlock, trigger the DB load at "profile-before-change" if + // the database hasn't started loading yet. + // + // Finally, we have a test-only event called test-load-xpi-database + // as a temporary workaround for bug 1372845. The latter can be + // cleaned up when that bug is resolved. + if (!this.isDBLoaded) { + const EVENTS = [ + "sessionstore-windows-restored", + "xul-window-visible", + "profile-before-change", + "test-load-xpi-database", + ]; + let observer = (subject, topic, data) => { + if ( + topic == "xul-window-visible" && + !Services.wm.getMostRecentWindow("devtools:toolbox") + ) { + return; + } + + for (let event of EVENTS) { + Services.obs.removeObserver(observer, event); + } + + XPIExports.XPIDatabase.asyncLoadDB(); + }; + for (let event of EVENTS) { + Services.obs.addObserver(observer, event); + } + } + + AddonManagerPrivate.recordTimestamp("XPI_startup_end"); + + lazy.timerManager.registerTimer( + "xpi-signature-verification", + () => { + XPIExports.XPIDatabase.verifySignatures(); + }, + XPI_SIGNATURE_CHECK_PERIOD + ); + } catch (e) { + logger.error("startup failed", e); + AddonManagerPrivate.recordException("XPI", "startup failed", e); + } + }, + + /** + * Shuts down the database and releases all references. + * Return: Promise{integer} resolves / rejects with the result of + * flushing the XPI Database if it was loaded, + * 0 otherwise. + */ + async shutdown() { + logger.debug("shutdown"); + + this.activeAddons.clear(); + this.allAppGlobal = true; + + // Stop anything we were doing asynchronously + XPIExports.XPIInstall.cancelAll(); + + for (let install of XPIExports.XPIInstall.installs) { + if (install.onShutdown()) { + install.onShutdown(); + } + } + + // If there are pending operations then we must update the list of active + // add-ons + if (Services.prefs.getBoolPref(PREF_PENDING_OPERATIONS, false)) { + XPIExports.XPIDatabase.updateActiveAddons(); + Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, false); + } + + await XPIExports.XPIDatabase.shutdown(); + }, + + cleanupTemporaryAddons() { + let promises = []; + let tempLocation = TemporaryInstallLocation; + for (let [id, addon] of tempLocation.entries()) { + tempLocation.delete(id); + + let bootstrap = BootstrapScope.get(addon); + let existing = XPIStates.findAddon(id, loc => !loc.isTemporary); + + let cleanup = () => { + tempLocation.installer.uninstallAddon(id); + tempLocation.removeAddon(id); + }; + + let promise; + if (existing) { + promise = bootstrap.update(existing, false, () => { + cleanup(); + XPIExports.XPIDatabase.makeAddonLocationVisible( + id, + existing.location + ); + }); + } else { + promise = bootstrap.uninstall().then(cleanup); + } + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + `Temporary extension shutdown: ${addon.id}`, + promise + ); + promises.push(promise); + } + return Promise.all(promises); + }, + + /** + * Adds a list of currently active add-ons to the next crash report. + */ + addAddonsToCrashReporter() { + void (Services.appinfo instanceof Ci.nsICrashReporter); + if (!Services.appinfo.annotateCrashReport || Services.appinfo.inSafeMode) { + return; + } + + let data = Array.from(XPIStates.enabledAddons(), a => a.telemetryKey).join( + "," + ); + + try { + Services.appinfo.annotateCrashReport("Add-ons", data); + } catch (e) {} + + lazy.TelemetrySession.setAddOns(data); + }, + + /** + * Check the staging directories of install locations for any add-ons to be + * installed or add-ons to be uninstalled. + * + * @param {Object} aManifests + * A dictionary to add detected install manifests to for the purpose + * of passing through updated compatibility information + * @returns {boolean} + * True if an add-on was installed or uninstalled + */ + processPendingFileChanges(aManifests) { + let changed = false; + for (let loc of XPIStates.locations()) { + aManifests[loc.name] = {}; + // We can't install or uninstall anything in locked locations + if (loc.locked) { + continue; + } + + // Collect any install errors for specific removal from the staged directory + // during cleanStagingDir. Successful installs remove the files. + let stagedFailureNames = []; + let promises = []; + for (let [id, metadata] of loc.getStagedAddons()) { + loc.unstageAddon(id); + + aManifests[loc.name][id] = null; + promises.push( + XPIExports.XPIInstall.installStagedAddon(id, metadata, loc).then( + addon => { + aManifests[loc.name][id] = addon; + }, + error => { + delete aManifests[loc.name][id]; + stagedFailureNames.push(`${id}.xpi`); + + logger.error( + `Failed to install staged add-on ${id} in ${loc.name}`, + error + ); + } + ) + ); + } + + if (promises.length) { + changed = true; + awaitPromise(Promise.all(promises)); + } + + try { + if (changed || stagedFailureNames.length) { + loc.installer.cleanStagingDir(stagedFailureNames); + } + } catch (e) { + // Non-critical, just saves some perf on startup if we clean this up. + logger.debug("Error cleaning staging dir", e); + } + } + return changed; + }, + + /** + * Installs any add-ons located in the extensions directory of the + * application's distribution specific directory into the profile unless a + * newer version already exists or the user has previously uninstalled the + * distributed add-on. + * + * @param {Object} aManifests + * A dictionary to add new install manifests to to save having to + * reload them later + * @param {string} [aAppChanged] + * See checkForChanges + * @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 + * @returns {boolean} + * True if any new add-ons were installed + */ + installDistributionAddons(aManifests, aAppChanged, aOldAppVersion) { + let distroDirs = []; + try { + distroDirs.push( + lazy.FileUtils.getDir(KEY_APP_DISTRIBUTION, [DIR_EXTENSIONS]) + ); + } catch (e) { + return false; + } + + let availableLocales = []; + for (let file of iterDirectory(distroDirs[0])) { + if (file.isDirectory() && file.leafName.startsWith("locale-")) { + availableLocales.push(file.leafName.replace("locale-", "")); + } + } + + let locales = Services.locale.negotiateLanguages( + Services.locale.requestedLocales, + availableLocales, + undefined, + Services.locale.langNegStrategyMatching + ); + + // Also install addons from subdirectories that correspond to the requested + // locales. This allows for installing language packs and dictionaries. + for (let locale of locales) { + let langPackDir = distroDirs[0].clone(); + langPackDir.append(`locale-${locale}`); + distroDirs.push(langPackDir); + } + + let changed = false; + for (let distroDir of distroDirs) { + logger.warn(`Checking ${distroDir.path} for addons`); + for (let file of iterDirectory(distroDir)) { + if (!isXPI(file.leafName, true)) { + // Only warn for files, not directories + if (!file.isDirectory()) { + logger.warn(`Ignoring distribution: not an XPI: ${file.path}`); + } + continue; + } + + let id = getExpectedID(file); + if (!id) { + logger.warn( + `Ignoring distribution: name is not a valid add-on ID: ${file.path}` + ); + continue; + } + + /* If this is not an upgrade and we've already handled this extension + * just continue */ + if ( + !aAppChanged && + Services.prefs.prefHasUserValue(PREF_BRANCH_INSTALLED_ADDON + id) + ) { + continue; + } + + try { + let loc = XPIStates.getLocation(KEY_APP_PROFILE); + let addon = awaitPromise( + XPIExports.XPIInstall.installDistributionAddon( + id, + file, + loc, + aOldAppVersion + ) + ); + + if (addon) { + // aManifests may contain a copy of a newly installed add-on's manifest + // and we'll have overwritten that so instead cache our install manifest + // which will later be put into the database in processFileChanges + if (!(loc.name in aManifests)) { + aManifests[loc.name] = {}; + } + aManifests[loc.name][id] = addon; + changed = true; + } + } catch (e) { + logger.error(`Failed to install distribution add-on ${file.path}`, e); + } + } + } + + return changed; + }, + + /** + * Like `installBuiltinAddon`, but only installs the addon at `aBase` + * if an existing built-in addon with the ID `aID` and version doesn't + * already exist. + * + * @param {string} aID + * The ID of the add-on being registered. + * @param {string} aVersion + * The version of the add-on being registered. + * @param {string} aBase + * A string containing the base URL. Must be a resource: URL. + * @returns {Promise} a Promise that resolves when the addon is installed. + */ + async maybeInstallBuiltinAddon(aID, aVersion, aBase) { + let installed; + if (lazy.enabledScopes & BuiltInLocation.scope) { + let existing = BuiltInLocation.get(aID); + if (!existing || existing.version != aVersion) { + installed = this.installBuiltinAddon(aBase); + this.startupPromises.push(installed); + } + } + return installed; + }, + + getDependentAddons(aAddon) { + return Array.from(XPIExports.XPIDatabase.getAddons()).filter(addon => + addon.dependencies.includes(aAddon.id) + ); + }, + + /** + * Checks for any changes that have occurred since the last time the + * application was launched. + * + * @param {boolean?} [aAppChanged] + * A tri-state value. Undefined means the current profile was created + * for this session, true means the profile already existed but was + * last used with an application with a different version number, + * false means that the profile was last used by this version of the + * application. + * @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 + */ + checkForChanges(aAppChanged, aOldAppVersion, aOldPlatformVersion) { + logger.debug("checkForChanges"); + + // Keep track of whether and why we need to open and update the database at + // startup time. + let updateReasons = []; + if (aAppChanged) { + updateReasons.push("appChanged"); + } + + let installChanged = XPIStates.scanForChanges(aAppChanged === false); + if (installChanged) { + updateReasons.push("directoryState"); + } + + // First install any new add-ons into the locations, if there are any + // changes then we must update the database with the information in the + // install locations + let manifests = {}; + let updated = this.processPendingFileChanges(manifests); + if (updated) { + updateReasons.push("pendingFileChanges"); + } + + // This will be true if the previous session made changes that affect the + // active state of add-ons but didn't commit them properly (normally due + // to the application crashing) + let hasPendingChanges = Services.prefs.getBoolPref( + PREF_PENDING_OPERATIONS, + false + ); + if (hasPendingChanges) { + updateReasons.push("hasPendingChanges"); + } + + // If the application has changed then check for new distribution add-ons + if (Services.prefs.getBoolPref(PREF_INSTALL_DISTRO_ADDONS, true)) { + updated = this.installDistributionAddons( + manifests, + aAppChanged, + aOldAppVersion + ); + if (updated) { + updateReasons.push("installDistributionAddons"); + } + } + + // If the schema appears to have changed then we should update the database + if (DB_SCHEMA != Services.prefs.getIntPref(PREF_DB_SCHEMA, 0)) { + // If we don't have any add-ons, just update the pref, since we don't need to + // write the database + if (!XPIStates.size) { + logger.debug( + "Empty XPI database, setting schema version preference to " + + DB_SCHEMA + ); + Services.prefs.setIntPref(PREF_DB_SCHEMA, DB_SCHEMA); + } else { + updateReasons.push("schemaChanged"); + } + } + + // Catch and log any errors during the main startup + try { + let extensionListChanged = false; + // If the database needs to be updated then open it and then update it + // from the filesystem + if (updateReasons.length) { + AddonManagerPrivate.recordSimpleMeasure( + "XPIDB_startup_load_reasons", + updateReasons + ); + XPIExports.XPIDatabase.syncLoadDB(false); + try { + extensionListChanged = + XPIExports.XPIDatabaseReconcile.processFileChanges( + manifests, + aAppChanged, + aOldAppVersion, + aOldPlatformVersion, + updateReasons.includes("schemaChanged") + ); + } catch (e) { + logger.error("Failed to process extension changes at startup", e); + } + } + + // If the application crashed before completing any pending operations then + // we should perform them now. + if (extensionListChanged || hasPendingChanges) { + XPIExports.XPIDatabase.updateActiveAddons(); + return; + } + + logger.debug("No changes found"); + } catch (e) { + logger.error("Error during startup file checks", e); + } + }, + + /** + * Gets an array of add-ons which were placed in a known install location + * prior to startup of the current session, were detected by a directory scan + * of those locations, and are currently disabled. + * + * @returns {Promise>} + */ + async getNewSideloads() { + if (XPIStates.scanForChanges(false)) { + // We detected changes. Update the database to account for them. + await XPIExports.XPIDatabase.asyncLoadDB(false); + XPIExports.XPIDatabaseReconcile.processFileChanges({}, false); + XPIExports.XPIDatabase.updateActiveAddons(); + } + + let addons = await Promise.all( + Array.from(XPIStates.sideLoadedAddons.keys(), id => this.getAddonByID(id)) + ); + + return addons.filter( + addon => + addon && + addon.seen === false && + addon.permissions & AddonManager.PERM_CAN_ENABLE + ); + }, + + /** + * Called to test whether this provider supports installing a particular + * mimetype. + * + * @param {string} aMimetype + * The mimetype to check for + * @returns {boolean} + * True if the mimetype is application/x-xpinstall + */ + supportsMimetype(aMimetype) { + return aMimetype == "application/x-xpinstall"; + }, + + // Identify temporary install IDs. + isTemporaryInstallID(id) { + return id.endsWith(TEMPORARY_ADDON_SUFFIX); + }, + + /** + * Sets startupData for the given addon. The provided data will be stored + * in addonsStartup.json so it is available early during browser startup. + * Note that this file is read synchronously at startup, so startupData + * should be used with care. + * + * @param {string} aID + * The id of the addon to save startup data for. + * @param {any} aData + * The data to store. Must be JSON serializable. + */ + setStartupData(aID, aData) { + let state = XPIStates.findAddon(aID); + state.startupData = aData; + XPIStates.save(); + }, + + /** + * Persists some startupData into an addon if it is available in the current + * XPIState for the addon id. + * + * @param {AddonInternal} addon An addon to receive the startup data, typically an update that is occuring. + * @param {XPIState} state optional + */ + persistStartupData(addon, state) { + if (!addon.startupData) { + state = state || XPIStates.findAddon(addon.id); + if (state?.enabled) { + // Save persistent listener data if available. It will be + // removed later if necessary. + let persistentListeners = state.startupData?.persistentListeners; + addon.startupData = { persistentListeners }; + } + } + }, + + getAddonIDByInstanceID(aInstanceID) { + if (!aInstanceID || typeof aInstanceID != "symbol") { + throw Components.Exception( + "aInstanceID must be a Symbol()", + Cr.NS_ERROR_INVALID_ARG + ); + } + + for (let [id, val] of this.activeAddons) { + if (aInstanceID == val.instanceID) { + return id; + } + } + + return null; + }, + + async getAddonsByTypes(aTypes) { + if (aTypes && !aTypes.some(type => ALL_XPI_TYPES.has(type))) { + return []; + } + return XPIExports.XPIDatabase.getAddonsByTypes(aTypes); + }, + + /** + * Called to get active Addons of a particular type + * + * @param {Array?} aTypes + * An array of types to fetch. Can be null to get all types. + * @returns {Promise>} + */ + async getActiveAddons(aTypes) { + // If we already have the database loaded, returning full info is fast. + if (this.isDBLoaded) { + let addons = await this.getAddonsByTypes(aTypes); + return { + addons: addons.filter(addon => addon.isActive), + fullData: true, + }; + } + + let result = []; + for (let addon of XPIStates.enabledAddons()) { + if (aTypes && !aTypes.includes(addon.type)) { + continue; + } + let { scope, isSystem } = addon.location; + result.push({ + id: addon.id, + version: addon.version, + type: addon.type, + updateDate: addon.lastModifiedTime, + scope, + isSystem, + isWebExtension: addon.isWebExtension, + }); + } + + return { addons: result, fullData: false }; + }, + + /* + * Notified when a preference we're interested in has changed. + * + * @see nsIObserver + */ + observe(aSubject, aTopic, aData) { + switch (aTopic) { + case NOTIFICATION_FLUSH_PERMISSIONS: + if (!aData || aData == XPI_PERMISSION) { + XPIExports.XPIDatabase.importPermissions(); + } + break; + + case "nsPref:changed": + switch (aData) { + case PREF_XPI_SIGNATURES_REQUIRED: + case PREF_LANGPACK_SIGNATURES: + XPIExports.XPIDatabase.updateAddonAppDisabledStates(); + break; + } + } + }, + + uninstallSystemProfileAddon(aID) { + let location = XPIStates.getLocation(KEY_APP_SYSTEM_PROFILE); + return XPIExports.XPIInstall.uninstallAddonFromLocation(aID, location); + }, +}; + +for (let meth of [ + "getInstallForFile", + "getInstallForURL", + "getInstallsByTypes", + "installTemporaryAddon", + "installBuiltinAddon", + "isInstallAllowed", + "isInstallEnabled", + "updateSystemAddons", + "stageLangpacksForAppUpdate", +]) { + XPIProvider[meth] = function () { + return XPIExports.XPIInstall[meth](...arguments); + }; +} + +for (let meth of [ + "addonChanged", + "getAddonByID", + "getAddonBySyncGUID", + "updateAddonRepositoryData", + "updateAddonAppDisabledStates", +]) { + XPIProvider[meth] = function () { + return XPIExports.XPIDatabase[meth](...arguments); + }; +} + +export var XPIInternal = { + BOOTSTRAP_REASONS, + BootstrapScope, + BuiltInLocation, + DB_SCHEMA, + DIR_STAGE, + DIR_TRASH, + KEY_APP_PROFILE, + KEY_APP_SYSTEM_PROFILE, + KEY_APP_SYSTEM_ADDONS, + KEY_APP_SYSTEM_DEFAULTS, + PREF_BRANCH_INSTALLED_ADDON, + PREF_SYSTEM_ADDON_SET, + SystemAddonLocation, + TEMPORARY_ADDON_SUFFIX, + TemporaryInstallLocation, + XPIStates, + XPI_PERMISSION, + awaitPromise, + canRunInSafeMode, + getURIForResourceInFile, + isXPI, + iterDirectory, + maybeResolveURI, + migrateAddonLoader, + resolveDBReady, + + // Used by tests to shut down AddonManager. + overrideAsyncShutdown(mockAsyncShutdown) { + lazy.AsyncShutdown = mockAsyncShutdown; + }, +}; diff --git a/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs new file mode 100644 index 0000000000..b1304bcdfc --- /dev/null +++ b/toolkit/mozapps/extensions/internal/crypto-utils.sys.mjs @@ -0,0 +1,57 @@ +/* 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/. */ + +const CryptoHash = Components.Constructor( + "@mozilla.org/security/hash;1", + "nsICryptoHash", + "initWithString" +); + +export function computeHashAsString(hashType, input) { + const data = new Uint8Array(new TextEncoder().encode(input)); + const crypto = CryptoHash(hashType); + crypto.update(data, data.length); + return getHashStringForCrypto(crypto); +} + +/** + * Returns the string representation (hex) of the SHA256 hash of `input`. + * + * @param {string} input + * The value to hash. + * @returns {string} + * The hex representation of a SHA256 hash. + */ +export function computeSha256HashAsString(input) { + return computeHashAsString("sha256", input); +} + +/** + * Returns the string representation (hex) of the SHA1 hash of `input`. + * + * @param {string} input + * The value to hash. + * @returns {string} + * The hex representation of a SHA1 hash. + */ +export function computeSha1HashAsString(input) { + return computeHashAsString("sha1", input); +} + +/** + * Returns the string representation (hex) of a given CryptoHashInstance. + * + * @param {CryptoHash} aCrypto + * @returns {string} + * The hex representation of a SHA256 hash. + */ +export function getHashStringForCrypto(aCrypto) { + // return the two-digit hexadecimal code for a byte + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + + // convert the binary hash data to a hex string. + let binary = aCrypto.finish(/* base64 */ false); + let hash = Array.from(binary, c => toHexString(c.charCodeAt(0))); + return hash.join("").toLowerCase(); +} diff --git a/toolkit/mozapps/extensions/internal/moz.build b/toolkit/mozapps/extensions/internal/moz.build new file mode 100644 index 0000000000..4fcf6657d5 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/moz.build @@ -0,0 +1,29 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +EXTRA_JS_MODULES.addons += [ + "AddonRepository.sys.mjs", + "AddonSettings.sys.mjs", + "AddonUpdateChecker.sys.mjs", + "crypto-utils.sys.mjs", + "ProductAddonChecker.sys.mjs", + "siteperms-addon-utils.sys.mjs", + "XPIDatabase.sys.mjs", + "XPIExports.sys.mjs", + "XPIInstall.sys.mjs", + "XPIProvider.sys.mjs", +] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] != "android": + EXTRA_JS_MODULES.addons += [ + "GMPProvider.sys.mjs", + ## TODO consider extending it to mobile builds too (See Bug 1790084). + "SitePermsAddonProvider.sys.mjs", + ] + +TESTING_JS_MODULES += [ + "AddonTestUtils.sys.mjs", +] diff --git a/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs new file mode 100644 index 0000000000..86853d7168 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/siteperms-addon-utils.sys.mjs @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +export const GATED_PERMISSIONS = ["midi", "midi-sysex"]; +export const SITEPERMS_ADDON_PROVIDER_PREF = + "dom.sitepermsaddon-provider.enabled"; +export const SITEPERMS_ADDON_TYPE = "sitepermission"; +export const SITEPERMS_ADDON_BLOCKEDLIST_PREF = + "dom.sitepermsaddon-provider.separatedBlocklistedDomains"; + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "blocklistedOriginsSet", + SITEPERMS_ADDON_BLOCKEDLIST_PREF, + // Default value + "", + // onUpdate + null, + // transform + prefValue => new Set(prefValue.split(",")) +); + +/** + * @param {string} type + * @returns {boolean} + */ +export function isGatedPermissionType(type) { + return GATED_PERMISSIONS.includes(type); +} + +/** + * @param {string} siteOrigin + * @returns {boolean} + */ +export function isKnownPublicSuffix(siteOrigin) { + const { host } = new URL(siteOrigin); + + let isPublic = false; + // getKnownPublicSuffixFromHost throws when passed an IP, in such case, assume + // this is not a public etld. + try { + isPublic = Services.eTLD.getKnownPublicSuffixFromHost(host) == host; + } catch (e) {} + + return isPublic; +} + +/** + * ⚠️ This should be only used for testing purpose ⚠️ + * + * @param {Array} permissionTypes + * @throws if not called from xpcshell test + */ +export function addGatedPermissionTypesForXpcShellTests(permissionTypes) { + if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { + throw new Error("This should only be called from XPCShell tests"); + } + + GATED_PERMISSIONS.push(...permissionTypes); +} + +/** + * @param {nsIPrincipal} principal + * @returns {Boolean} + */ +export function isPrincipalInSitePermissionsBlocklist(principal) { + return lazy.blocklistedOriginsSet.has(principal.baseDomain); +} -- cgit v1.2.3