1949 lines
68 KiB
JavaScript
1949 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/. */
|
|
/* eslint-disable mozilla/valid-lazy */
|
|
|
|
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);
|
|
},
|
|
};
|