/* 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 { ExtensionParent } from "resource://gre/modules/ExtensionParent.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { DeferredTask: "resource://gre/modules/DeferredTask.sys.mjs", Extension: "resource://gre/modules/Extension.sys.mjs", ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs", PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", Schemas: "resource://gre/modules/Schemas.sys.mjs", }); XPCOMUtils.defineLazyServiceGetters(lazy, { aomStartup: [ "@mozilla.org/addons/addon-manager-startup;1", "amIAddonManagerStartup", ], }); const LAST_UPDATE_TAG_PREF_PREFIX = "extensions.dnr.lastStoreUpdateTag."; const { DefaultMap, ExtensionError } = ExtensionUtils; const { StartupCache } = ExtensionParent; // DNR Rules store subdirectory/file names and file extensions. // // NOTE: each extension's stored rules are stored in a per-extension file // and stored rules filename is derived from the extension uuid assigned // at install time. const RULES_STORE_DIRNAME = "extension-dnr"; const RULES_STORE_FILEEXT = ".json.lz4"; const RULES_CACHE_FILENAME = "extensions-dnr.sc.lz4"; const requireTestOnlyCallers = () => { if (!Services.env.exists("XPCSHELL_TEST_PROFILE_DIR")) { throw new Error("This should only be called from XPCShell tests"); } }; /** * Internal representation of the enabled static rulesets (used in StoreData * and Store methods type signatures). * * @typedef {object} EnabledStaticRuleset * @inner * @property {number} idx * Represent the position of the static ruleset in the manifest * `declarative_net_request.rule_resources` array. * @property {Array} rules * Represent the array of the DNR rules associated with the static * ruleset. */ // Class defining the format of the data stored into the per-extension files // managed by RulesetsStore. // // StoreData instances are saved in the profile extension-dir subdirectory as // lz4-compressed JSON files, only the ruleset_id is stored on disk for the // enabled static rulesets (while the actual rules would need to be loaded back // from the related rules JSON files part of the extension assets). class StoreData { // NOTE: Update schema version upgrade handling code in `RulesetsStore.#readData` // along with bumps to the schema version here. static VERSION = 1; static getLastUpdateTagPref(extensionUUID) { return `${LAST_UPDATE_TAG_PREF_PREFIX}${extensionUUID}`; } static getLastUpdateTag(extensionUUID) { return Services.prefs.getCharPref( this.getLastUpdateTagPref(extensionUUID), null ); } static storeLastUpdateTag(extensionUUID, lastUpdateTag) { Services.prefs.setCharPref( this.getLastUpdateTagPref(extensionUUID), lastUpdateTag ); } static clearLastUpdateTagPref(extensionUUID) { Services.prefs.clearUserPref(this.getLastUpdateTagPref(extensionUUID)); } static isStaleCacheEntry(extensionUUID, cacheStoreData) { return ( // Drop the cache entry if the data stored doesn't match the current // StoreData schema version (this shouldn't happen unless the file // have been manually restored by the user from an older firefox version). cacheStoreData.schemaVersion !== this.VERSION || // Drop the cache entry if the lastUpdateTag from the cached data entry // doesn't match the lastUpdateTag recorded in the prefs, the tag is applied // with a per-extension granularity to reduce the chances of cache misses // last update on the cached data for an unrelated extensions did not make it // to disk). cacheStoreData.lastUpdateTag != this.getLastUpdateTag(extensionUUID) ); } #extUUID; #initialLastUdateTag; #temporarilyInstalled; /** * @param {Extension} extension * The extension the StoreData is associated to. * @param {object} params * @param {string} params.extVersion * extension version * @param {string} [params.lastUpdateTag] * a tag associated to the data. It is only passed when we are loading the data * from the StartupCache file, while a new tag uuid string will be generated * for brand new data (and then new ones generated on each calls to the `updateRulesets` * method). * @param {number} [params.schemaVersion=StoreData.VERSION] * file schema version * @param {Map} [params.staticRulesets=new Map()] * map of the enabled static rulesets by ruleset_id, as resolved by * `Store.prototype.#getManifestStaticRulesets`. * NOTE: This map is converted in an array of the ruleset_id strings when the StoreData * instance is being stored on disk (see `toJSON` method) and then converted back to a Map * by `Store.prototype.#getManifestStaticRulesets` when the data is loaded back from disk. * @param {Array} [params.dynamicRuleset=[]] * array of dynamic rules stored by the extension. */ constructor( extension, { extVersion, lastUpdateTag, dynamicRuleset, staticRulesets, schemaVersion, } = {} ) { if (!(extension instanceof lazy.Extension)) { throw new Error("Missing mandatory extension parameter"); } this.schemaVersion = schemaVersion || this.constructor.VERSION; this.extVersion = extVersion ?? extension.version; this.#extUUID = extension.uuid; // Used to skip storing the data in the startupCache or storing the lastUpdateTag in // the about:config prefs. this.#temporarilyInstalled = extension.temporarilyInstalled; // The lastUpdateTag gets set (and updated) by calls to updateRulesets. this.lastUpdateTag = undefined; this.#initialLastUdateTag = lastUpdateTag; this.#updateRulesets({ staticRulesets: staticRulesets ?? new Map(), dynamicRuleset: dynamicRuleset ?? [], lastUpdateTag, }); } isFromStartupCache() { return this.#initialLastUdateTag == this.lastUpdateTag; } isFromTemporarilyInstalled() { return this.#temporarilyInstalled; } get isEmpty() { return !this.staticRulesets.size && !this.dynamicRuleset.length; } /** * Updates the static and or dynamic rulesets stored for the related * extension. * * NOTE: This method also: * - regenerates the lastUpdateTag associated as an unique identifier * of the revision for the stored data (used to detect stale startup * cache data) * - stores the lastUpdateTag into an about:config pref associated to * the extension uuid (also used as part of detecting stale startup * cache data), unless the extension is installed temporarily. * * @param {object} params * @param {Map} [params.staticRulesets] * optional new updated Map of static rulesets * (static rulesets are unchanged if not passed). * @param {Array} [params.dynamicRuleset=[]] * optional array of updated dynamic rules * (dynamic rules are unchanged if not passed). */ updateRulesets({ staticRulesets, dynamicRuleset } = {}) { let currentUpdateTag = this.lastUpdateTag; let lastUpdateTag = this.#updateRulesets({ staticRulesets, dynamicRuleset, }); // Tag each cache data entry with a value synchronously stored in an // about:config prefs, if on a browser restart the tag in the startupCache // data entry doesn't match the one in the about:config pref then the startup // cache entry is dropped as stale (assuming an issue prevented the updated // cache data to be written on disk, e.g. browser crash, failure on writing // on disk etc.), each entry is tagged separately to decrease the chances // of cache misses on unrelated cache data entries if only a few extension // got stale data in the startup cache file. if ( !this.isFromTemporarilyInstalled() && currentUpdateTag != lastUpdateTag ) { StoreData.storeLastUpdateTag(this.#extUUID, lastUpdateTag); } } #updateRulesets({ staticRulesets, dynamicRuleset, lastUpdateTag = Services.uuid.generateUUID().toString(), } = {}) { if (staticRulesets) { this.staticRulesets = staticRulesets; } if (dynamicRuleset) { this.dynamicRuleset = dynamicRuleset; } if (staticRulesets || dynamicRuleset) { this.lastUpdateTag = lastUpdateTag; } return this.lastUpdateTag; } // This method is used to convert the data in the format stored on disk // as a JSON file. toJSON() { const data = { schemaVersion: this.schemaVersion, extVersion: this.extVersion, // Only store the array of the enabled ruleset_id in the set of data // persisted in a JSON form. staticRulesets: this.staticRulesets ? Array.from(this.staticRulesets.entries(), ([id, _ruleset]) => id) : undefined, dynamicRuleset: this.dynamicRuleset, }; return data; } // This method is used to convert the data back to a StoreData class from // the format stored on disk as a JSON file. // NOTE: this method should be kept in sync with toJSON and make sure that // we do deserialize the same property we are serializing into the JSON file. static fromJSON(paramsFromJSON, extension) { let { schemaVersion, extVersion, staticRulesets, dynamicRuleset } = paramsFromJSON; return new StoreData(extension, { schemaVersion, extVersion, staticRulesets, dynamicRuleset, }); } } class Queue { #tasks = []; #runningTask = null; #closed = false; get hasPendingTasks() { return !!this.#runningTask || !!this.#tasks.length; } get isClosed() { return this.#closed; } async close() { if (this.#closed) { const lastTask = this.#tasks[this.#tasks.length - 1]; return lastTask?.deferred.promise; } const drainedQueuePromise = this.queueTask(() => {}); this.#closed = true; return drainedQueuePromise; } queueTask(callback) { if (this.#closed) { throw new Error("Unexpected queueTask call on closed queue"); } const deferred = lazy.PromiseUtils.defer(); this.#tasks.push({ callback, deferred }); // Run the queued task right away if there isn't one already running. if (!this.#runningTask) { this.#runNextTask(); } return deferred.promise; } async #runNextTask() { if (!this.#tasks.length) { this.#runningTask = null; return; } this.#runningTask = this.#tasks.shift(); const { callback, deferred } = this.#runningTask; try { let result = callback(); if (result instanceof Promise) { result = await result; } deferred.resolve(result); } catch (err) { deferred.reject(err); } this.#runNextTask(); } } /** * Class managing the rulesets persisted across browser sessions. * * The data gets stored in two per-extension files: * * - `ProfD/extension-dnr/EXT_UUID.json.lz4` is a lz4-compressed JSON file that is expected to include * the ruleset ids for the enabled static rulesets and the dynamic rules. * * All browser data stored is expected to be persisted across browser updates, but the enabled static ruleset * ids are expected to be reset and reinitialized from the extension manifest.json properties when the * add-on is being updated (either downgraded or upgraded). * * In case of unexpected data schema downgrades (which may be hit if the user explicit pass --allow-downgrade * while using an older browser version than the one used when the data has been stored), the entire stored * data is reset and re-initialized from scratch based on the manifest.json file. */ class RulesetsStore { constructor() { // Map this._data = new Map(); // Map> this._dataPromises = new Map(); // Map> this._savePromises = new Map(); // Map this._dataUpdateQueues = new DefaultMap(() => new Queue()); // Promise to await on to ensure the store parent directory exist // (the parent directory is shared by all extensions and so we only need one). this._ensureStoreDirectoryPromise = null; // Promise to await on to ensure (there is only one startupCache file for all // extensions and so we only need one): // - the cache file parent directory exist // - the cache file data has been loaded (if any was available and matching // the last DNR data stored on disk) // - the cache file data has been saved. this._ensureCacheDirectoryPromise = null; this._ensureCacheLoaded = null; this._saveCacheTask = null; // Map of the raw data read from the startupCache. // Map this._startupCacheData = new Map(); } /** * Wait for the startup cache data to be stored on disk. * * NOTE: Only meant to be used in xpcshell tests. * * @returns {Promise} */ async waitSaveCacheDataForTesting() { requireTestOnlyCallers(); if (this._saveCacheTask) { if (this._saveCacheTask.isRunning) { await this._saveCacheTask._runningPromise; } // #saveCacheDataNow() may schedule another save if anything has changed in between while (this._saveCacheTask.isArmed) { this._saveCacheTask.disarm(); await this.#saveCacheDataNow(); } } } /** * Remove store file for the given extension UUId from disk (used to remove all * data on addon uninstall). * * @param {string} extensionUUID * @returns {Promise} */ async clearOnUninstall(extensionUUID) { // TODO(Bug 1825510): call scheduleCacheDataSave to update the startup cache data // stored on disk, but skip it if it is late in the application shutdown. StoreData.clearLastUpdateTagPref(extensionUUID); const storeFile = this.#getStoreFilePath(extensionUUID); // TODO(Bug 1803363): consider collect telemetry on DNR store file removal errors. // TODO: consider catch and report unexpected errors await IOUtils.remove(storeFile, { ignoreAbsent: true }); } /** * Load (or initialize) the store file data for the given extension and * return an Array of the dynamic rules. * * @param {Extension} extension * * @returns {Promise>} * Resolve to a reference to the dynamic rules array. * NOTE: the caller should never mutate the content of this array, * updates to the dynamic rules should always go through * the `updateDynamicRules` method. */ async getDynamicRules(extension) { let data = await this.#getDataPromise(extension); return data.dynamicRuleset; } /** * Load (or initialize) the store file data for the given extension and * return a Map of the enabled static rulesets and their related rules. * * - if the extension manifest doesn't have any static rulesets declared in the * manifest, returns null * * - if the extension version from the stored data doesn't match the current * extension versions, the static rules are being reloaded from the manifest. * * @param {Extension} extension * * @returns {Promise>} * Resolves to a reference to the static rulesets map. * NOTE: the caller should never mutate the content of this map, * updates to the enabled static rulesets should always go through * the `updateEnabledStaticRulesets` method. */ async getEnabledStaticRulesets(extension) { let data = await this.#getDataPromise(extension); return data.staticRulesets; } async getAvailableStaticRuleCount(extension) { const { GUARANTEED_MINIMUM_STATIC_RULES } = lazy.ExtensionDNRLimits; const ruleResources = extension.manifest.declarative_net_request?.rule_resources; // TODO: return maximum rules count when no static rules is listed in the manifest? if (!Array.isArray(ruleResources)) { return GUARANTEED_MINIMUM_STATIC_RULES; } const enabledRulesets = await this.getEnabledStaticRulesets(extension); const enabledRulesCount = Array.from(enabledRulesets.values()).reduce( (acc, ruleset) => acc + ruleset.rules.length, 0 ); return GUARANTEED_MINIMUM_STATIC_RULES - enabledRulesCount; } /** * Initialize the DNR store for the given extension, it does also queue the task to make * sure that extension DNR API calls triggered while the initialization may still be * in progress will be executed sequentially. * * @param {Extension} extension * * @returns {Promise} A promise resolved when the async initialization has been * completed. */ async initExtension(extension) { const ensureExtensionRunning = () => { if (extension.hasShutdown) { throw new Error( `DNR store initialization abort, extension is already shutting down: ${extension.id}` ); } }; // Make sure we wait for pending save promise to have been // completed and old data unloaded (this may be hit if an // extension updates or reloads while there are still // rules updates being processed and then stored on disk). ensureExtensionRunning(); if (this._savePromises.has(extension.uuid)) { Cu.reportError( `Unexpected pending save task while reading DNR data after an install/update of extension "${extension.id}"` ); // await pending saving data to be saved and unloaded. await this.#unloadData(extension.uuid); // Make sure the extension is still running after awaiting on // unloadData to be completed. ensureExtensionRunning(); } return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { return this.#initExtension(extension); }); } /** * Update the dynamic rules, queue changes to prevent races between calls * that may be triggered while an update is still in process. * * @param {Extension} extension * @param {object} params * @param {Array} [params.removeRuleIds=[]] * @param {Array} [params.addRules=[]] * * @returns {Promise} A promise resolved when the dynamic rules async update has * been completed. */ async updateDynamicRules(extension, { removeRuleIds, addRules }) { return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { return this.#updateDynamicRules(extension, { removeRuleIds, addRules, }); }); } /** * Update the enabled rulesets, queue changes to prevent races between calls * that may be triggered while an update is still in process. * * @param {Extension} extension * @param {object} params * @param {Array} [params.disableRulesetIds=[]] * @param {Array} [params.enableRulesetIds=[]] * * @returns {Promise} A promise resolved when the enabled static rulesets async * update has been completed. */ async updateEnabledStaticRulesets( extension, { disableRulesetIds, enableRulesetIds } ) { return this._dataUpdateQueues.get(extension.uuid).queueTask(() => { return this.#updateEnabledStaticRulesets(extension, { disableRulesetIds, enableRulesetIds, }); }); } /** * Update DNR RulesetManager rules to match the current DNR rules enabled in the DNRStore. * * @param {Extension} extension * @param {object} [params] * @param {boolean} [params.updateStaticRulesets=true] * @param {boolean} [params.updateDynamicRuleset=true] */ updateRulesetManager( extension, { updateStaticRulesets = true, updateDynamicRuleset = true } = {} ) { if (!updateStaticRulesets && !updateDynamicRuleset) { return; } if ( !this._dataPromises.has(extension.uuid) || !this._data.has(extension.uuid) ) { throw new Error( `Unexpected call to updateRulesetManager before DNR store was fully initialized for extension "${extension.id}"` ); } const data = this._data.get(extension.uuid); const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); if (updateStaticRulesets) { let staticRulesetsMap = data.staticRulesets; // Convert into array and ensure order match the order of the rulesets in // the extension manifest. const enabledStaticRules = []; // Order the static rulesets by index of rule_resources in manifest.json. const orderedRulesets = Array.from(staticRulesetsMap.entries()).sort( ([_idA, rsA], [_idB, rsB]) => rsA.idx - rsB.idx ); for (const [rulesetId, ruleset] of orderedRulesets) { enabledStaticRules.push({ id: rulesetId, rules: ruleset.rules }); } ruleManager.setEnabledStaticRulesets(enabledStaticRules); } if (updateDynamicRuleset) { ruleManager.setDynamicRules(data.dynamicRuleset); } } /** * Return the store file path for the given the extension's uuid and the cache * file with startupCache data for all the extensions. * * @param {string} extensionUUID * @returns {{ storeFile: string | void, cacheFile: string}} * An object including the full paths to both the per-extension store file * for the given extension UUID and the full path to the single startupCache * file (which would include the cached data for all the extensions). */ getFilePaths(extensionUUID) { return { storeFile: this.#getStoreFilePath(extensionUUID), cacheFile: this.#getCacheFilePath(), }; } /** * Save the data for the given extension on disk. * * @param {Extension} extension */ async save(extension) { const { uuid, id } = extension; let savePromise = this._savePromises.get(uuid); if (!savePromise) { savePromise = this.#saveNow(uuid, id); this._savePromises.set(uuid, savePromise); IOUtils.profileBeforeChange.addBlocker( `Flush WebExtension DNR RulesetsStore: ${id}`, savePromise ); } return savePromise; } /** * Register an onClose shutdown handler to cleanup the data from memory when * the extension is shutting down. * * @param {Extension} extension * @returns {void} */ unloadOnShutdown(extension) { if (extension.hasShutdown) { throw new Error( `DNR store registering an extension shutdown handler too late, the extension is already shutting down: ${extension.id}` ); } const extensionUUID = extension.uuid; extension.callOnClose({ close: async () => this.#unloadData(extensionUUID), }); } /** * Return a branch new StoreData instance given an extension. * * @param {Extension} extension * @returns {StoreData} */ #getDefaults(extension) { return new StoreData(extension, { extUUID: extension.uuid, extVersion: extension.version, temporarilyInstalled: extension.temporarilyInstalled, }); } /** * Return the cache file path. * * @returns {string} * The absolute path to the startupCache file. */ #getCacheFilePath() { // When the application version changes, this file is removed by // RemoveComponentRegistries in nsAppRunner.cpp. return PathUtils.join( Services.dirsvc.get("ProfLD", Ci.nsIFile).path, "startupCache", RULES_CACHE_FILENAME ); } /** * Return the path to the store file given the extension's uuid. * * @param {string} extensionUUID * @returns {string} Full path to the store file for the extension. */ #getStoreFilePath(extensionUUID) { return PathUtils.join( Services.dirsvc.get("ProfD", Ci.nsIFile).path, RULES_STORE_DIRNAME, `${extensionUUID}${RULES_STORE_FILEEXT}` ); } #ensureCacheDirectory() { if (this._ensureCacheDirectoryPromise === null) { const file = this.#getCacheFilePath(); this._ensureCacheDirectoryPromise = IOUtils.makeDirectory( PathUtils.parent(file), { ignoreExisting: true, createAncestors: true, } ); } return this._ensureCacheDirectoryPromise; } #ensureStoreDirectory(extensionUUID) { // Currently all extensions share the same directory, so we can re-use this promise across all // `#ensureStoreDirectory` calls. if (this._ensureStoreDirectoryPromise === null) { const file = this.#getStoreFilePath(extensionUUID); this._ensureStoreDirectoryPromise = IOUtils.makeDirectory( PathUtils.parent(file), { ignoreExisting: true, createAncestors: true, } ); } return this._ensureStoreDirectoryPromise; } #getDataPromise(extension) { let dataPromise = this._dataPromises.get(extension.uuid); if (!dataPromise) { if (extension.hasShutdown) { throw new Error( `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` ); } this.unloadOnShutdown(extension); dataPromise = this.#readData(extension); this._dataPromises.set(extension.uuid, dataPromise); } return dataPromise; } /** * Reads the store file for the given extensions and all rules * for the enabled static ruleset ids listed in the store file. * * @param {Extension} extension * @param {object} [options] * @param {Array} [options.enabledRulesetIds] * An optional array of enabled ruleset ids to be loaded * (used to load a specific group of static rulesets, * either when the list of static rules needs to be recreated based * on the enabled rulesets, or when the extension is * changing the enabled rulesets using the `updateEnabledRulesets` * API method). * @param {boolean} [options.isUpdateEnabledRulesets] * Whether this is a call by updateEnabledRulesets. When true, * `enabledRulesetIds` contains the IDs of disabled rulesets that * should be enabled. Already-enabled rulesets are not included in * `enabledRulesetIds`. * @param {RuleQuotaCounter} [options.ruleQuotaCounter] * The counter of already-enabled rules that are not part of * `enabledRulesetIds`. Set when `isUpdateEnabledRulesets` is true. * This method may mutate its internal counters. * @returns {Promise>} * map of the enabled static rulesets by ruleset_id. */ async #getManifestStaticRulesets( extension, { enabledRulesetIds = null, isUpdateEnabledRulesets = false, ruleQuotaCounter, } = {} ) { // Map} const rulesets = new Map(); const ruleResources = extension.manifest.declarative_net_request?.rule_resources; if (!Array.isArray(ruleResources)) { return rulesets; } if (!isUpdateEnabledRulesets) { ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( /* isStaticRulesets */ true ); } const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, // Warnings on MAX_NUMBER_OF_STATIC_RULESETS are already // reported (see ExtensionDNR.validateManifestEntry, called // from the DNR API onManifestEntry callback). } = lazy.ExtensionDNRLimits; for (let [idx, { id, enabled, path }] of ruleResources.entries()) { // If passed enabledRulesetIds is used to determine if the enabled // rules in the manifest should be overridden from the list of // enabled static rulesets stored on disk. if (Array.isArray(enabledRulesetIds)) { enabled = enabledRulesetIds.includes(id); } // Duplicated ruleset ids are validated as part of the JSONSchema validation, // here we log a warning to signal that we are ignoring it if when the validation // error isn't strict (e.g. for non temporarily installed, which shouldn't normally // hit in the long run because we can also validate it before signing the extension). if (rulesets.has(id)) { Cu.reportError( `Disabled static ruleset with duplicated ruleset_id "${id}"` ); continue; } if (enabled && rulesets.size >= MAX_NUMBER_OF_ENABLED_STATIC_RULESETS) { // This is technically reported from the manifest validation, as a warning // on extension installed non temporarily, and so checked and logged here // in case we are hitting it while loading the enabled rulesets. Cu.reportError( `Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit (${MAX_NUMBER_OF_ENABLED_STATIC_RULESETS}): ruleset_id "${id}" (extension: "${extension.id}")` ); continue; } const readJSONStartTime = Cu.now(); const rawRules = enabled && (await fetch(path) .then(res => res.json()) .catch(err => { Cu.reportError(err); enabled = false; extension.packagingError( `Reading declarative_net_request static rules file ${path}: ${err.message}` ); })); ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime: readJSONStartTime }, `StaticRulesetsReadJSON, addonId: ${extension.id}` ); // Skip rulesets that are not enabled or can't be enabled (e.g. if we got error on loading or // parsing the rules JSON file). if (!enabled) { continue; } if (!Array.isArray(rawRules)) { extension.packagingError( `Reading declarative_net_request static rules file ${path}: rules file must contain an Array of rules` ); continue; } // TODO(Bug 1803369): consider to only report the errors and warnings about invalid static rules for // temporarily installed extensions (chrome only shows them for unpacked extensions). const logRuleValidationError = err => extension.packagingWarning(err); const validatedRules = this.#getValidatedRules(extension, id, rawRules, { logRuleValidationError, }); // NOTE: this is currently only accounting for valid rules because // only the valid rules will be actually be loaded. Reconsider if // we should instead also account for the rules that have been // ignored as invalid. try { ruleQuotaCounter.tryAddRules(id, validatedRules); } catch (e) { // If this is an API call (updateEnabledRulesets), just propagate the // error. Otherwise we are intializing the extension and should just // ignore the ruleset while reporting the error. if (isUpdateEnabledRulesets) { throw e; } // TODO(Bug 1803363): consider collect telemetry. Cu.reportError( `Ignoring static ruleset "${id}" in extension "${extension.id}" because: ${e.message}` ); continue; } rulesets.set(id, { idx, rules: validatedRules }); } return rulesets; } /** * Returns an array of validated and normalized Rule instances given an array * of raw rules data (e.g. in form of plain objects read from the static rules * JSON files or the dynamicRuleset property from the extension DNR store data). * * @param {Extension} extension * @param {string} rulesetId * @param {Array} rawRules * @param {object} options * @param {Function} [options.logRuleValidationError] * an optional callback to call for logging the * validation errors, defaults to use Cu.reportError * (but getManifestStaticRulesets overrides it to use * extensions.packagingWarning instead). * * @returns {Array} */ #getValidatedRules( extension, rulesetId, rawRules, { logRuleValidationError = err => Cu.reportError(err) } = {} ) { const startTime = Cu.now(); const validatedRulesTimerId = Glean.extensionsApisDnr.validateRulesTime.start(); try { const ruleValidator = new lazy.ExtensionDNR.RuleValidator([]); // Normalize rules read from JSON. const validationContext = { url: extension.baseURI.spec, principal: extension.principal, logError: logRuleValidationError, preprocessors: {}, manifestVersion: extension.manifestVersion, }; // TODO(Bug 1803369): consider to also include the rule id if one was available. const getInvalidRuleMessage = (ruleIndex, msg) => `Invalid rule at index ${ruleIndex} from ruleset "${rulesetId}", ${msg}`; for (const [rawIndex, rawRule] of rawRules.entries()) { try { const normalizedRule = lazy.Schemas.normalize( rawRule, "declarativeNetRequest.Rule", validationContext ); if (normalizedRule.value) { ruleValidator.addRules([normalizedRule.value]); } else { logRuleValidationError( getInvalidRuleMessage( rawIndex, normalizedRule.error ?? "Unexpected undefined rule" ) ); } } catch (err) { Cu.reportError(err); logRuleValidationError( getInvalidRuleMessage(rawIndex, "An unexpected error occurred") ); } } // TODO(Bug 1803369): consider including an index in the invalid rules warnings. if (ruleValidator.getFailures().length) { logRuleValidationError( `Invalid rules found in ruleset "${rulesetId}": ${ruleValidator .getFailures() .map(f => f.message) .join(", ")}` ); } return ruleValidator.getValidatedRules(); } finally { ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime }, `#getValidatedRules, addonId: ${extension.id}` ); Glean.extensionsApisDnr.validateRulesTime.stopAndAccumulate( validatedRulesTimerId ); } } #hasInstallOrUpdateStartupReason(extension) { switch (extension.startupReason) { case "ADDON_INSTALL": case "ADDON_UPGRADE": case "ADDON_DOWNGRADE": return true; } return false; } /** * Load and add the DNR stored rules to the RuleManager instance for the given * extension. * * @param {Extension} extension * @returns {Promise} */ async #initExtension(extension) { // - on new installs the stored rules should be recreated from scratch // (and any stale previously stored data to be ignored) // - on upgrades/downgrades: // - the dynamic rules are expected to be preserved // - the static rules are expected to be refreshed from the new // manifest data (also the enabled rulesets are expected to be // reset to the state described in the manifest) // // TODO(Bug 1803369): consider also setting to true if the extension is installed temporarily. if (this.#hasInstallOrUpdateStartupReason(extension)) { // Reset the stored static rules on addon updates. await StartupCache.delete(extension, "dnr", "hasEnabledStaticRules"); } const hasEnabledStaticRules = await StartupCache.get( extension, ["dnr", "hasEnabledStaticRules"], async () => { const staticRulesets = await this.getEnabledStaticRulesets(extension); return staticRulesets.size; } ); const hasDynamicRules = await StartupCache.get( extension, ["dnr", "hasDynamicRules"], async () => { const dynamicRuleset = await this.getDynamicRules(extension); return dynamicRuleset.length; } ); if (hasEnabledStaticRules || hasDynamicRules) { const data = await this.#getDataPromise(extension); if (!data.isFromStartupCache() && !data.isFromTemporarilyInstalled()) { this.scheduleCacheDataSave(); } if (extension.hasShutdown) { return; } this.updateRulesetManager(extension, { updateStaticRulesets: hasEnabledStaticRules, updateDynamicRuleset: hasDynamicRules, }); } } #promiseStartupCacheLoaded() { if (!this._ensureCacheLoaded) { if (this._data.size) { return Promise.reject( new Error( "Unexpected non-empty DNRStore data. DNR startupCache data load aborted." ) ); } const startTime = Cu.now(); const timerId = Glean.extensionsApisDnr.startupCacheReadTime.start(); this._ensureCacheLoaded = (async () => { const cacheFilePath = this.#getCacheFilePath(); const { buffer, byteLength } = await IOUtils.read(cacheFilePath); Glean.extensionsApisDnr.startupCacheReadSize.accumulate(byteLength); const decodedData = lazy.aomStartup.decodeBlob(buffer); const emptyOrCorruptedCache = !(decodedData?.cacheData instanceof Map); if (emptyOrCorruptedCache) { Cu.reportError( `Unexpected corrupted DNRStore startupCache data. DNR startupCache data load dropped.` ); // Remove the cache file right away on corrupted (unexpected empty) // or obsolete cache content. await IOUtils.remove(cacheFilePath, { ignoreAbsent: true }); return; } if (this._data.size) { Cu.reportError( `Unexpected non-empty DNRStore data. DNR startupCache data load dropped.` ); return; } for (const [ extUUID, cacheStoreData, ] of decodedData.cacheData.entries()) { if (StoreData.isStaleCacheEntry(extUUID, cacheStoreData)) { StoreData.clearLastUpdateTagPref(extUUID); continue; } // TODO(Bug 1825510): schedule a task long enough after startup to detect and // remove unused entries in the _startupCacheData Map sooner. this._startupCacheData.set(extUUID, { extUUID: extUUID, ...cacheStoreData, }); } })() .catch(err => { // TODO: collect telemetry on unexpected cache load failures. if (!DOMException.isInstance(err) || err.name !== "NotFoundError") { Cu.reportError(err); } }) .finally(() => { ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime }, "_ensureCacheLoaded" ); Glean.extensionsApisDnr.startupCacheReadTime.stopAndAccumulate( timerId ); }); } return this._ensureCacheLoaded; } /** * Read the stored data for the given extension, either from: * - store file (if available and not detected as a data schema downgrade) * - manifest file and packaged ruleset JSON files (if there was no valid stored data found) * * This private method is only called from #getDataPromise, which caches the return value * in memory. * * @param {Extension} extension * * @returns {Promise} */ async #readData(extension) { const startTime = Cu.now(); try { let result; // Try to load data from the startupCache. if (extension.startupReason === "APP_STARTUP") { result = await this.#readStoreDataFromStartupCache(extension); } // Fallback to load the data stored in the json file. result ??= await this.#readStoreData(extension); // Reset the stored data if a data schema version downgrade has been // detected (this should only be hit on downgrades if the user have // also explicitly passed --allow-downgrade CLI option). if (result && result.version > StoreData.VERSION) { Cu.reportError( `Unsupport DNR store schema version downgrade: resetting stored data for ${extension.id}` ); result = null; } // Use defaults and extension manifest if no data stored was found // (or it got reset due to an unsupported profile downgrade being detected). if (!result) { // We don't have any data stored, load the static rules from the manifest. result = this.#getDefaults(extension); // Initialize the staticRules data from the manifest. result.updateRulesets({ staticRulesets: await this.#getManifestStaticRulesets(extension), }); } // TODO: handle DNR store schema changes here when the StoreData.VERSION is being bumped. // if (result && result.version < StoreData.VERSION) { // result = this.upgradeStoreDataSchema(result); // } // The extension has already shutting down and we may already got past // the unloadData cleanup (given that there is still a promise in // the _dataPromises Map). if (extension.hasShutdown && !this._dataPromises.has(extension.uuid)) { throw new Error( `DNR store data loading aborted, the extension is already shutting down: ${extension.id}` ); } this._data.set(extension.uuid, result); return result; } finally { ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime }, `readData, addonId: ${extension.id}` ); } } // Convert extension entries in the startCache map back to StoreData instances // (because the StoreData instances get converted into plain objects when // serialized into the startupCache structured clone blobs). async #readStoreDataFromStartupCache(extension) { await this.#promiseStartupCacheLoaded(); if (!this._startupCacheData.has(extension.uuid)) { Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); return; } const extCacheData = this._startupCacheData.get(extension.uuid); this._startupCacheData.delete(extension.uuid); if (extCacheData.extVersion != extension.version) { StoreData.clearLastUpdateTagPref(extension.uuid); Glean.extensionsApisDnr.startupCacheEntries.miss.add(1); return; } Glean.extensionsApisDnr.startupCacheEntries.hit.add(1); for (const ruleset of extCacheData.staticRulesets.values()) { ruleset.rules = ruleset.rules.map(rule => lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) ); } extCacheData.dynamicRuleset = extCacheData.dynamicRuleset.map(rule => lazy.ExtensionDNR.RuleValidator.deserializeRule(rule) ); return new StoreData(extension, extCacheData); } /** * Reads the store file for the given extensions and all rules * for the enabled static ruleset ids listed in the store file. * * @param {Extension} extension * * @returns {Promise} */ async #readStoreData(extension) { // TODO(Bug 1803363): record into Glean telemetry DNR RulesetsStore store load time. let file = this.#getStoreFilePath(extension.uuid); let data; let isCorrupted = false; let storeFileFound = false; try { data = await IOUtils.readJSON(file, { decompress: true }); storeFileFound = true; } catch (e) { if (!(DOMException.isInstance(e) && e.name === "NotFoundError")) { Cu.reportError(e); isCorrupted = true; storeFileFound = true; } // TODO(Bug 1803363) record store read errors in telemetry scalar. } // Reset data read from disk if its type isn't the expected one. isCorrupted ||= !data || !Array.isArray(data.staticRulesets) || // DNR data stored in 109 would not have any dynamicRuleset // property and so don't consider the data corrupted if // there isn't any dynamicRuleset property at all. ("dynamicRuleset" in data && !Array.isArray(data.dynamicRuleset)); if (isCorrupted && storeFileFound) { // Wipe the corrupted data and backup the corrupted file. data = null; try { let uniquePath = await IOUtils.createUniqueFile( PathUtils.parent(file), PathUtils.filename(file) + ".corrupt", 0o600 ); Cu.reportError( `Detected corrupted DNR store data for ${extension.id}, renaming store data file to ${uniquePath}` ); await IOUtils.move(file, uniquePath); } catch (err) { Cu.reportError(err); } } if (!data) { return null; } const resetStaticRulesets = // Reset the static rulesets on install or updating the extension. // // NOTE: this method is called only once and its return value cached in // memory for the entire lifetime of the extension and so we don't need // to store any flag to avoid resetting the static rulesets more than // once for the same Extension instance. this.#hasInstallOrUpdateStartupReason(extension) || // Ignore the stored enabled ruleset ids if the current extension version // mismatches the version the store data was generated from. data.extVersion !== extension.version; if (resetStaticRulesets) { data.staticRulesets = undefined; data.extVersion = extension.version; } // If the data is being loaded for a new addon install, make sure to clear // any potential stale dynamic rules stored on disk. // // NOTE: this is expected to only be hit if there was a failure to cleanup // state data upon uninstall (e.g. in case the machine shutdowns or // Firefox crashes before we got to update the data stored on disk). if (extension.startupReason === "ADDON_INSTALL") { data.dynamicRuleset = []; } // In the JSON stored data we only store the enabled rulestore_id and // the actual rules have to be loaded. data.staticRulesets = await this.#getManifestStaticRulesets( extension, // Only load the rules from rulesets that are enabled in the stored DNR data, // if the array (eventually empty) of the enabled static rules isn't in the // stored data, then load all the ones enabled in the manifest. { enabledRulesetIds: data.staticRulesets } ); if (data.dynamicRuleset?.length) { // Make sure all dynamic rules loaded from disk as validated and normalized // (in case they may have been tempered, but also for when we are loading // data stored by a different Firefox version from the one that stored the // data on disk, e.g. in case validation or normalization logic may have been // different in the two Firefox version). const validatedDynamicRules = this.#getValidatedRules( extension, "_dynamic" /* rulesetId */, data.dynamicRuleset ); let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); try { ruleQuotaCounter.tryAddRules("_dynamic", validatedDynamicRules); data.dynamicRuleset = validatedDynamicRules; } catch (e) { // This should not happen in practice, because updateDynamicRules // rejects quota errors. If we get here, the data on disk may have been // tampered with, or the limit was lowered in a browser update. Cu.reportError( `Ignoring dynamic ruleset in extension "${extension.id}" because: ${e.message}` ); data.dynamicRuleset = []; } } // We use StoreData.fromJSON here to prevent properties that are not expected to // be stored in the JSON file from overriding other StoreData constructor properties // that are not included in the JSON data returned by StoreData toJSON. return StoreData.fromJSON(data, extension); } async scheduleCacheDataSave() { this.#ensureCacheDirectory(); if (!this._saveCacheTask) { this._saveCacheTask = new lazy.DeferredTask( () => this.#saveCacheDataNow(), 5000 ); IOUtils.profileBeforeChange.addBlocker( "Flush WebExtensions DNR RulesetsStore startupCache", async () => { await this._saveCacheTask.finalize(); this._saveCacheTask = null; } ); } return this._saveCacheTask.arm(); } getStartupCacheData() { const filteredData = new Map(); const seenLastUpdateTags = new Set(); for (const [extUUID, dataEntry] of this._data) { // Only store in the startup cache extensions that are permanently // installed (the temporarilyInstalled extension are removed // automatically either on shutdown or startup, and so the data // stored and then loaded back from the startup cache file // would never be used). if (dataEntry.isFromTemporarilyInstalled()) { continue; } filteredData.set(extUUID, dataEntry); seenLastUpdateTags.add(dataEntry.lastUpdateTag); } return { seenLastUpdateTags, filteredData, }; } detectStartupCacheDataChanged(seenLastUpdateTags) { // Detect if there are changes to the stored data applied while we // have been writing the cache data on disk, and reschedule a new // cache data save if that is the case. // TODO(Bug 1825510): detect also obsoleted entries to make sure // they are removed from the startup cache data stored on disk // sooner. for (const dataEntry of this._data.values()) { if (dataEntry.isFromTemporarilyInstalled()) { continue; } if (!seenLastUpdateTags.has(dataEntry.lastUpdateTag)) { return true; } } return false; } async #saveCacheDataNow() { const startTime = Cu.now(); const timerId = Glean.extensionsApisDnr.startupCacheWriteTime.start(); try { const cacheFilePath = this.#getCacheFilePath(); const { filteredData, seenLastUpdateTags } = this.getStartupCacheData(); const data = new Uint8Array( lazy.aomStartup.encodeBlob({ cacheData: filteredData, }) ); await this._ensureCacheDirectoryPromise; await IOUtils.write(cacheFilePath, data, { tmpPath: `${cacheFilePath}.tmp`, }); Glean.extensionsApisDnr.startupCacheWriteSize.accumulate(data.byteLength); if (this.detectStartupCacheDataChanged(seenLastUpdateTags)) { this.scheduleCacheDataSave(); } } finally { ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime }, "#saveCacheDataNow" ); Glean.extensionsApisDnr.startupCacheWriteTime.stopAndAccumulate(timerId); } } /** * Save the data for the given extension on disk. * * @param {string} extensionUUID * @param {string} extensionId * @returns {Promise} */ async #saveNow(extensionUUID, extensionId) { const startTime = Cu.now(); try { if ( !this._dataPromises.has(extensionUUID) || !this._data.has(extensionUUID) ) { throw new Error( `Unexpected uninitialized DNR store on saving data for extension uuid "${extensionUUID}"` ); } const storeFile = this.#getStoreFilePath(extensionUUID); const data = this._data.get(extensionUUID); await this.#ensureStoreDirectory(extensionUUID); await IOUtils.writeJSON(storeFile, data, { tmpPath: `${storeFile}.tmp`, compress: true, }); this.scheduleCacheDataSave(); // TODO(Bug 1803363): report jsonData lengths into a telemetry scalar. // TODO(Bug 1803363): report jsonData time to write into a telemetry scalar. } catch (err) { Cu.reportError(err); throw err; } finally { this._savePromises.delete(extensionUUID); ChromeUtils.addProfilerMarker( "ExtensionDNRStore", { startTime }, `#saveNow, addonId: ${extensionId}` ); } } /** * Unload data for the given extension UUID from memory (e.g. when the extension is disabled or uninstalled), * waits for a pending save promise to be settled if any. * * NOTE: this method clear the data cached in memory and close the update queue * and so it should only be called from the extension shutdown handler and * by the initExtension method before pushing into the update queue for the * for the extension the initExtension task. * * @param {string} extensionUUID * @returns {Promise} */ async #unloadData(extensionUUID) { // Wait for the update tasks to have been executed, then // wait for the data to have been saved and finally unload // the data cached in memory. const dataUpdateQueue = this._dataUpdateQueues.has(extensionUUID) ? this._dataUpdateQueues.get(extensionUUID) : undefined; if (dataUpdateQueue) { try { await dataUpdateQueue.close(); } catch (err) { // Unexpected error on closing the update queue. Cu.reportError(err); } this._dataUpdateQueues.delete(extensionUUID); } const savePromise = this._savePromises.get(extensionUUID); if (savePromise) { await savePromise; this._savePromises.delete(extensionUUID); } this._dataPromises.delete(extensionUUID); this._data.delete(extensionUUID); } /** * Internal implementation for updating the dynamic ruleset and enforcing * dynamic rules count limits. * * Callers ensure that there is never a concurrent call of #updateDynamicRules * for a given extension, so we can safely modify ruleManager.dynamicRules * from inside this method, even asynchronously. * * @param {Extension} extension * @param {object} params * @param {Array} [params.removeRuleIds=[]] * @param {Array} [params.addRules=[]] */ async #updateDynamicRules(extension, { removeRuleIds, addRules }) { const ruleManager = lazy.ExtensionDNR.getRuleManager(extension); const ruleValidator = new lazy.ExtensionDNR.RuleValidator( ruleManager.getDynamicRules() ); if (removeRuleIds) { ruleValidator.removeRuleIds(removeRuleIds); } if (addRules) { ruleValidator.addRules(addRules); } let failures = ruleValidator.getFailures(); if (failures.length) { throw new ExtensionError(failures[0].message); } const validatedRules = ruleValidator.getValidatedRules(); let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter(); ruleQuotaCounter.tryAddRules("_dynamic", validatedRules); this._data.get(extension.uuid).updateRulesets({ dynamicRuleset: validatedRules, }); await this.save(extension); // updateRulesetManager calls ruleManager.setDynamicRules using the // validated rules assigned above to this._data. this.updateRulesetManager(extension, { updateDynamicRuleset: true, updateStaticRulesets: false, }); } /** * Internal implementation for updating the enabled rulesets and enforcing * static rulesets and rules count limits. * * @param {Extension} extension * @param {object} params * @param {Array} [params.disableRulesetIds=[]] * @param {Array} [params.enableRulesetIds=[]] */ async #updateEnabledStaticRulesets( extension, { disableRulesetIds, enableRulesetIds } ) { const ruleResources = extension.manifest.declarative_net_request?.rule_resources; if (!Array.isArray(ruleResources)) { return; } const enabledRulesets = await this.getEnabledStaticRulesets(extension); const updatedEnabledRulesets = new Map(); let disableIds = new Set(disableRulesetIds); let enableIds = new Set(enableRulesetIds); // valiate the ruleset ids for existence (which will also reject calls // including the reserved _session and _dynamic, because static rulesets // id are validated as part of the manifest validation and they are not // allowed to start with '_'). const existingIds = new Set(ruleResources.map(rs => rs.id)); const errorOnInvalidRulesetIds = rsIdSet => { for (const rsId of rsIdSet) { if (!existingIds.has(rsId)) { throw new ExtensionError(`Invalid ruleset id: "${rsId}"`); } } }; errorOnInvalidRulesetIds(disableIds); errorOnInvalidRulesetIds(enableIds); // Copy into the updatedEnabledRulesets Map any ruleset that is not // requested to be disabled or is enabled back in the same request. for (const [rulesetId, ruleset] of enabledRulesets) { if (!disableIds.has(rulesetId) || enableIds.has(rulesetId)) { updatedEnabledRulesets.set(rulesetId, ruleset); enableIds.delete(rulesetId); } } const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNRLimits; const maxNewRulesetsCount = MAX_NUMBER_OF_ENABLED_STATIC_RULESETS - updatedEnabledRulesets.size; if (enableIds.size > maxNewRulesetsCount) { // Log an error for the developer. throw new ExtensionError( `updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS` ); } // At this point, every item in |updatedEnabledRulesets| is an enabled // ruleset with already-valid rules. In order to not exceed the rule quota // when previously-disabled rulesets are enabled, we need to count what we // already have. let ruleQuotaCounter = new lazy.ExtensionDNR.RuleQuotaCounter( /* isStaticRulesets */ true ); for (let [rulesetId, ruleset] of updatedEnabledRulesets) { ruleQuotaCounter.tryAddRules(rulesetId, ruleset.rules); } const newRulesets = await this.#getManifestStaticRulesets(extension, { enabledRulesetIds: Array.from(enableIds), ruleQuotaCounter, isUpdateEnabledRulesets: true, }); for (const [rulesetId, ruleset] of newRulesets.entries()) { updatedEnabledRulesets.set(rulesetId, ruleset); } this._data.get(extension.uuid).updateRulesets({ staticRulesets: updatedEnabledRulesets, }); await this.save(extension); this.updateRulesetManager(extension, { updateDynamicRuleset: false, updateStaticRulesets: true, }); } } let store = new RulesetsStore(); export const ExtensionDNRStore = { async clearOnUninstall(extensionUUID) { return store.clearOnUninstall(extensionUUID); }, async initExtension(extension) { await store.initExtension(extension); }, async updateDynamicRules(extension, updateRuleOptions) { await store.updateDynamicRules(extension, updateRuleOptions); }, async updateEnabledStaticRulesets(extension, updateRulesetOptions) { await store.updateEnabledStaticRulesets(extension, updateRulesetOptions); }, // Test-only helpers _getLastUpdateTag(extensionUUID) { requireTestOnlyCallers(); return StoreData.getLastUpdateTag(extensionUUID); }, _getStoreForTesting() { requireTestOnlyCallers(); return store; }, _getStoreDataClassForTesting() { requireTestOnlyCallers(); return StoreData; }, _recreateStoreForTesting() { requireTestOnlyCallers(); store = new RulesetsStore(); return store; }, _storeLastUpdateTag(extensionUUID, lastUpdateTag) { requireTestOnlyCallers(); return StoreData.storeLastUpdateTag(extensionUUID, lastUpdateTag); }, };