mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-01 00:38:50 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			1948 lines
		
	
	
	
		
			68 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1948 lines
		
	
	
	
		
			68 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /* 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 { StartupCache } 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";
 | |
| import { ExtensionDNRLimits } from "./ExtensionDNRLimits.sys.mjs";
 | |
| 
 | |
| const lazy = XPCOMUtils.declareLazy({
 | |
|   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",
 | |
|   Schemas: "resource://gre/modules/Schemas.sys.mjs",
 | |
|   aomStartup: {
 | |
|     service: "@mozilla.org/addons/addon-manager-startup;1",
 | |
|     iid: Ci.amIAddonManagerStartup,
 | |
|   },
 | |
| });
 | |
| 
 | |
| const LAST_UPDATE_TAG_PREF_PREFIX = "extensions.dnr.lastStoreUpdateTag.";
 | |
| 
 | |
| const { DefaultMap, ExtensionError } = ExtensionUtils;
 | |
| 
 | |
| // 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 `StoreData.fromJSON`
 | |
|   // along with bumps to the schema version here.
 | |
|   //
 | |
|   // Changelog:
 | |
|   // - 1: Initial DNR store schema:
 | |
|   //      Initial implementation officially release in Firefox 113.
 | |
|   //      Support for disableStaticRuleIds added in Firefox 128 (Bug 1810762).
 | |
|   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 {object} [params.disabledStaticRuleIds={}]
 | |
|    *        map of the disabled static rule ids by ruleset_id. This map is updated by the extension
 | |
|    *        calls to the updateStaticRules API method and persisted across browser session,
 | |
|    *        and browser and extension updates. Disabled rule ids for a disabled ruleset are going
 | |
|    *        to become effective when the disabled ruleset is enabled (e.g. through updateEnabledRulesets
 | |
|    *        API calls or through manifest in extension updates).
 | |
|    * @param {Array<Rule>} [params.dynamicRuleset=[]]
 | |
|    *        array of dynamic rules stored by the extension.
 | |
|    */
 | |
|   constructor(
 | |
|     extension,
 | |
|     {
 | |
|       extVersion,
 | |
|       lastUpdateTag,
 | |
|       dynamicRuleset,
 | |
|       disabledStaticRuleIds,
 | |
|       staticRulesets,
 | |
|       schemaVersion,
 | |
|     } = {}
 | |
|   ) {
 | |
|     if (!(extension instanceof lazy.Extension)) {
 | |
|       throw new Error("Missing mandatory extension parameter");
 | |
|     }
 | |
|     this.schemaVersion = schemaVersion || StoreData.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(),
 | |
|       disabledStaticRuleIds: disabledStaticRuleIds ?? {},
 | |
|       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 {object} [params.disabledStaticRuleIds]
 | |
|    *        optional new updated Map of static rules ids disabled individually.
 | |
|    * @param {Array<Rule>} [params.dynamicRuleset=[]]
 | |
|    *        optional array of updated dynamic rules
 | |
|    *        (dynamic rules are unchanged if not passed).
 | |
|    */
 | |
|   updateRulesets({
 | |
|     staticRulesets,
 | |
|     disabledStaticRuleIds,
 | |
|     dynamicRuleset,
 | |
|   } = {}) {
 | |
|     let currentUpdateTag = this.lastUpdateTag;
 | |
|     let lastUpdateTag = this.#updateRulesets({
 | |
|       staticRulesets,
 | |
|       disabledStaticRuleIds,
 | |
|       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 = null,
 | |
|     disabledStaticRuleIds = null,
 | |
|     dynamicRuleset = null,
 | |
|     lastUpdateTag = Services.uuid.generateUUID().toString(),
 | |
|   } = {}) {
 | |
|     if (staticRulesets) {
 | |
|       this.staticRulesets = staticRulesets;
 | |
|     }
 | |
| 
 | |
|     if (disabledStaticRuleIds) {
 | |
|       this.disabledStaticRuleIds = disabledStaticRuleIds;
 | |
|     }
 | |
| 
 | |
|     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,
 | |
|       disabledStaticRuleIds:
 | |
|         this.disabledStaticRuleIds &&
 | |
|         Object.keys(this.disabledStaticRuleIds).length
 | |
|           ? this.disabledStaticRuleIds
 | |
|           : 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) {
 | |
|     // TODO: Add schema versions migrations here if necessary.
 | |
|     // if (paramsFromJSON.version < StoreData.VERSION) {
 | |
|     //   paramsFromJSON = this.upgradeStoreDataSchema(paramsFromJSON);
 | |
|     // }
 | |
| 
 | |
|     let {
 | |
|       schemaVersion,
 | |
|       extVersion,
 | |
|       staticRulesets,
 | |
|       disabledStaticRuleIds,
 | |
|       dynamicRuleset,
 | |
|     } = paramsFromJSON;
 | |
| 
 | |
|     return new StoreData(extension, {
 | |
|       schemaVersion,
 | |
|       extVersion,
 | |
|       staticRulesets,
 | |
|       disabledStaticRuleIds,
 | |
|       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 = Promise.withResolvers();
 | |
|     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;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns the number of static rules still available to the given extension.
 | |
|    *
 | |
|    * @param {Extension} extension
 | |
|    *
 | |
|    * @returns {Promise<number>}
 | |
|    *          Resolves to the number of static rules available.
 | |
|    */
 | |
|   async getAvailableStaticRuleCount(extension) {
 | |
|     const { GUARANTEED_MINIMUM_STATIC_RULES } = lazy.ExtensionDNRLimits;
 | |
| 
 | |
|     const existingRulesetIds = this.#getExistingStaticRulesetIds(extension);
 | |
|     if (!existingRulesetIds.length) {
 | |
|       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;
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Returns the static rule ids disabled individually for the given extension
 | |
|    * and static ruleset id.
 | |
|    *
 | |
|    * @param {Extension} extension
 | |
|    * @param {string} rulesetId
 | |
|    *
 | |
|    * @returns {Promise<Array<number>>}
 | |
|    *          Resolves to the array of rule ids disabled.
 | |
|    */
 | |
|   async getDisabledRuleIds(extension, rulesetId) {
 | |
|     const existingRulesetIds = this.#getExistingStaticRulesetIds(extension);
 | |
|     if (!existingRulesetIds.includes(rulesetId)) {
 | |
|       throw new ExtensionError(`Invalid ruleset id: "${rulesetId}"`);
 | |
|     }
 | |
| 
 | |
|     let data = await this.#getDataPromise(extension);
 | |
|     return data.disabledStaticRuleIds[rulesetId] ?? [];
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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<number>} [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 static rules ids disabled individually on a given static ruleset id,
 | |
|    * 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 {string}        [params.rulesetId]
 | |
|    * @param {Array<number>} [params.disableRuleIds]
 | |
|    * @param {Array<number>} [params.enableRuleIds]
 | |
|    *
 | |
|    * @returns {Promise<void>} A promise resolved when the disabled rules async update has
 | |
|    *                          been completed.
 | |
|    */
 | |
|   async updateStaticRules(
 | |
|     extension,
 | |
|     { rulesetId, disableRuleIds, enableRuleIds }
 | |
|   ) {
 | |
|     return this._dataUpdateQueues.get(extension.uuid).queueTask(() => {
 | |
|       return this.#updateStaticRules(extension, {
 | |
|         rulesetId,
 | |
|         disableRuleIds,
 | |
|         enableRuleIds,
 | |
|       });
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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,
 | |
|           disabledRuleIds: data.disabledStaticRuleIds[rulesetId]
 | |
|             ? new Set(data.disabledStaticRuleIds[rulesetId])
 | |
|             : null,
 | |
|         });
 | |
|       }
 | |
|       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, { extVersion: extension.version });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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}`
 | |
|         );
 | |
|       }
 | |
| 
 | |
|       // Note: when dataPromise resolves, this._data and this._dataPromises are
 | |
|       // set. Keep this logic in sync with the end of #initExtension().
 | |
| 
 | |
|       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.
 | |
|    *
 | |
|    * @typedef {string} ruleset_id
 | |
|    *
 | |
|    * @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 {import("ExtensionDNR.sys.mjs").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(
 | |
|         "GUARANTEED_MINIMUM_STATIC_RULES"
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     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).
 | |
|    *
 | |
|    * @typedef {import("ExtensionDNR.sys.mjs").Rule} Rule
 | |
|    *
 | |
|    * @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,
 | |
|         ignoreUnrecognizedProperties: true,
 | |
|       };
 | |
| 
 | |
|       // 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
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   #getExistingStaticRulesetIds(extension) {
 | |
|     const ruleResources =
 | |
|       extension.manifest.declarative_net_request?.rule_resources;
 | |
|     if (!Array.isArray(ruleResources)) {
 | |
|       return [];
 | |
|     }
 | |
| 
 | |
|     return ruleResources.map(rs => rs.id);
 | |
|   }
 | |
| 
 | |
|   #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);
 | |
| 
 | |
|         // Note: if the outcome changes, call #setStartupFlag to update this!
 | |
|         return staticRulesets.size;
 | |
|       }
 | |
|     );
 | |
|     const hasDynamicRules = await StartupCache.get(
 | |
|       extension,
 | |
|       ["dnr", "hasDynamicRules"],
 | |
|       async () => {
 | |
|         const dynamicRuleset = await this.getDynamicRules(extension);
 | |
| 
 | |
|         // Note: if the outcome changes, call #setStartupFlag to update this!
 | |
|         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,
 | |
|       });
 | |
|     } else if (
 | |
|       !extension.hasShutdown &&
 | |
|       !this._dataPromises.has(extension.uuid)
 | |
|     ) {
 | |
|       // #getDataPromise() initializes _dataPromises and _data (via #readData).
 | |
|       // This may be called when the StartupCache is not populated, but if they
 | |
|       // were, then these methods are not called. All other logic expects these
 | |
|       // to be initialized when #initExtension() returns, see e.g. bug 1921353.
 | |
|       let storeData = this.#getDefaults(extension);
 | |
|       this._data.set(extension.uuid, storeData);
 | |
|       this._dataPromises.set(extension.uuid, Promise.resolve(storeData));
 | |
|       this.unloadOnShutdown(extension);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * Update the flags that record the (non-)existence of static/dynamic rules.
 | |
|    * These flags are used by #initExtension.
 | |
|    * "StartupCache" here refers to the general StartupCache, NOT the one from
 | |
|    * #getCacheFilePath().
 | |
|    */
 | |
|   #setStartupFlag(extension, name, value) {
 | |
|     // The StartupCache.set method is async, but we do not wait because in
 | |
|     // practice the "async" part of it completes very quickly because the
 | |
|     // underlying StartupCache data has already been read when an extension is
 | |
|     // starting.
 | |
|     // And any writes is scheduled with an AsyncShutdown blocker, which ensures
 | |
|     // that the writes complete before the browser shuts down.
 | |
|     StartupCache.general.set(
 | |
|       [extension.id, extension.version, "dnr", name],
 | |
|       value
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   #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>}
 | |
|    */
 | |
|   #readData(extension) {
 | |
|     // This just forwards to the actual implementation.
 | |
|     return this._readData(extension);
 | |
|   }
 | |
|   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.schemaVersion > StoreData.VERSION) {
 | |
|         Cu.reportError(
 | |
|           `Unsupport DNR store schema version downgrade: resetting stored data for ${extension.id}`
 | |
|         );
 | |
|         result = null;
 | |
|       }
 | |
| 
 | |
|       // If the number of disabled rules exceeds the limit when loaded from the store
 | |
|       // (e.g. if the limit has been customized through prefs, and so not expected to
 | |
|       // be a common case), then we drop the entire list of disabled rules.
 | |
|       if (result?.disabledStaticRuleIds) {
 | |
|         for (const [rulesetId, disabledRuleIds] of Object.entries(
 | |
|           result.disabledStaticRuleIds
 | |
|         )) {
 | |
|           if (
 | |
|             Array.isArray(disabledRuleIds) &&
 | |
|             disabledRuleIds.length <=
 | |
|               ExtensionDNRLimits.MAX_NUMBER_OF_DISABLED_STATIC_RULES
 | |
|           ) {
 | |
|             continue;
 | |
|           }
 | |
| 
 | |
|           Cu.reportError(
 | |
|             `Discard "${extension.id}" static ruleset "${rulesetId}" disabled rules` +
 | |
|               ` for exceeding the MAX_NUMBER_OF_DISABLED_STATIC_RULES (${ExtensionDNRLimits.MAX_NUMBER_OF_DISABLED_STATIC_RULES})`
 | |
|           );
 | |
|           result.disabledStaticRuleIds[rulesetId] = [];
 | |
|         }
 | |
|       }
 | |
| 
 | |
|       // 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),
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       // 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|null>}
 | |
|    */
 | |
|   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.disabledStaticRuleIds = {};
 | |
|       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(
 | |
|         "MAX_NUMBER_OF_DYNAMIC_RULES"
 | |
|       );
 | |
|       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<number>} [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(
 | |
|       "MAX_NUMBER_OF_DYNAMIC_RULES"
 | |
|     );
 | |
|     ruleQuotaCounter.tryAddRules("_dynamic", validatedRules);
 | |
| 
 | |
|     this._data.get(extension.uuid).updateRulesets({
 | |
|       dynamicRuleset: validatedRules,
 | |
|     });
 | |
|     this.#setStartupFlag(extension, "hasDynamicRules", validatedRules.length);
 | |
|     await this.save(extension);
 | |
|     // updateRulesetManager calls ruleManager.setDynamicRules using the
 | |
|     // validated rules assigned above to this._data.
 | |
|     this.updateRulesetManager(extension, {
 | |
|       updateDynamicRuleset: true,
 | |
|       updateStaticRulesets: false,
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   async #updateStaticRules(
 | |
|     extension,
 | |
|     { rulesetId, disableRuleIds, enableRuleIds }
 | |
|   ) {
 | |
|     const existingRulesetIds = this.#getExistingStaticRulesetIds(extension);
 | |
|     if (!existingRulesetIds.includes(rulesetId)) {
 | |
|       throw new ExtensionError(`Invalid ruleset id: "${rulesetId}"`);
 | |
|     }
 | |
| 
 | |
|     const data = this._data.get(extension.uuid);
 | |
|     const disabledRuleIdsSet = new Set(data.disabledStaticRuleIds[rulesetId]);
 | |
|     const enableSet = new Set(enableRuleIds);
 | |
|     const disableSet = new Set(disableRuleIds);
 | |
| 
 | |
|     let changed = false;
 | |
|     for (const ruleId of disableSet) {
 | |
|       // Skip rule ids that are disabled and enabled in the same call.
 | |
|       if (enableSet.delete(ruleId)) {
 | |
|         continue;
 | |
|       }
 | |
|       if (!disabledRuleIdsSet.has(ruleId)) {
 | |
|         changed = true;
 | |
|       }
 | |
|       disabledRuleIdsSet.add(ruleId);
 | |
|     }
 | |
|     for (const ruleId of enableSet) {
 | |
|       if (disabledRuleIdsSet.delete(ruleId)) {
 | |
|         changed = true;
 | |
|       }
 | |
|     }
 | |
| 
 | |
|     if (!changed) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     if (
 | |
|       disabledRuleIdsSet.size >
 | |
|       ExtensionDNRLimits.MAX_NUMBER_OF_DISABLED_STATIC_RULES
 | |
|     ) {
 | |
|       throw new ExtensionError(
 | |
|         `Number of individually disabled static rules exceeds MAX_NUMBER_OF_DISABLED_STATIC_RULES limit`
 | |
|       );
 | |
|     }
 | |
| 
 | |
|     // Chrome doesn't seem to validate if the rule id actually exists in the ruleset,
 | |
|     // and so set the resulting updated array of disabled rule ids right away.
 | |
|     //
 | |
|     // For more details, see the "Invalid rules" and "Error handling in updateStaticRules"
 | |
|     // section of https://github.com/w3c/webextensions/issues/162#issuecomment-2101003746
 | |
|     data.disabledStaticRuleIds[rulesetId] = Array.from(disabledRuleIdsSet);
 | |
| 
 | |
|     await this.save(extension);
 | |
| 
 | |
|     // If the ruleset isn't currently enabled, after saving the updated
 | |
|     // disabledRuleIdsSet we are done.
 | |
|     if (!data.staticRulesets.has(rulesetId)) {
 | |
|       return;
 | |
|     }
 | |
|     //
 | |
|     // updateRulesetManager calls ruleManager.setStaticRules to
 | |
|     // update the list of disabled ruleIds.
 | |
|     this.updateRulesetManager(extension, {
 | |
|       updateDynamicRuleset: false,
 | |
|       updateStaticRulesets: true,
 | |
|     });
 | |
|   }
 | |
| 
 | |
|   /**
 | |
|    * 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 existingIds = new Set(this.#getExistingStaticRulesetIds(extension));
 | |
|     if (!existingIds.size) {
 | |
|       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 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(
 | |
|       "GUARANTEED_MINIMUM_STATIC_RULES"
 | |
|     );
 | |
|     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,
 | |
|     });
 | |
|     this.#setStartupFlag(
 | |
|       extension,
 | |
|       "hasEnabledStaticRules",
 | |
|       updatedEnabledRulesets.size
 | |
|     );
 | |
|     await this.save(extension);
 | |
|     this.updateRulesetManager(extension, {
 | |
|       updateDynamicRuleset: false,
 | |
|       updateStaticRulesets: true,
 | |
|     });
 | |
|   }
 | |
| }
 | |
| 
 | |
| let store = new RulesetsStore();
 | |
| 
 | |
| export const ExtensionDNRStore = {
 | |
|   SCHEMA_VERSION: StoreData.VERSION,
 | |
|   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);
 | |
|   },
 | |
|   async updateStaticRules(extension, updateStaticRulesOptions) {
 | |
|     await store.updateStaticRules(extension, updateStaticRulesOptions);
 | |
|   },
 | |
|   getDisabledRuleIds(extension, rulesetId) {
 | |
|     return store.getDisabledRuleIds(extension, rulesetId);
 | |
|   },
 | |
|   // 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);
 | |
|   },
 | |
| };
 | 
