diff options
Diffstat (limited to 'toolkit/components/extensions/ExtensionDNRStore.sys.mjs')
-rw-r--r-- | toolkit/components/extensions/ExtensionDNRStore.sys.mjs | 1701 |
1 files changed, 1701 insertions, 0 deletions
diff --git a/toolkit/components/extensions/ExtensionDNRStore.sys.mjs b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs new file mode 100644 index 0000000000..11148224d9 --- /dev/null +++ b/toolkit/components/extensions/ExtensionDNRStore.sys.mjs @@ -0,0 +1,1701 @@ +/* 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<Rule>} 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<string, EnabledStaticRuleset>} [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<Rule>} [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<string, EnabledStaticRuleset>} [params.staticRulesets] + * optional new updated Map of static rulesets + * (static rulesets are unchanged if not passed). + * @param {Array<Rule>} [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<extensionUUID, StoreData> + this._data = new Map(); + // Map<extensionUUID, Promise<StoreData>> + this._dataPromises = new Map(); + // Map<extensionUUID, Promise<void>> + this._savePromises = new Map(); + // Map<extensionUUID, Queue> + 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<extensionUUID, Object> + 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<void>} + */ + 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<void>} + */ + 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<Array<Rule>>} + * 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<Map<ruleset_id, EnabledStaticRuleset>>} + * 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<void>} 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<string>} [params.removeRuleIds=[]] + * @param {Array<Rule>} [params.addRules=[]] + * + * @returns {Promise<void>} 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<string>} [params.disableRulesetIds=[]] + * @param {Array<string>} [params.enableRulesetIds=[]] + * + * @returns {Promise<void>} 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<string>} [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<ruleset_id, EnabledStaticRuleset>>} + * map of the enabled static rulesets by ruleset_id. + */ + async #getManifestStaticRulesets( + extension, + { + enabledRulesetIds = null, + isUpdateEnabledRulesets = false, + ruleQuotaCounter, + } = {} + ) { + // Map<ruleset_id, EnabledStaticRuleset>} + 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<object>} 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<Rule>} + */ + #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<void>} + */ + 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<StoreData>} + */ + 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<StoreData|void>} + */ + 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<void>} + */ + 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<void>} + */ + 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<string>} [params.removeRuleIds=[]] + * @param {Array<Rule>} [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<string>} [params.disableRulesetIds=[]] + * @param {Array<string>} [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); + }, +}; |