summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js')
-rw-r--r--toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js549
1 files changed, 549 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js
new file mode 100644
index 0000000000..443f69c2d1
--- /dev/null
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_regexFilter_limits.js
@@ -0,0 +1,549 @@
+"use strict";
+
+// This file verifies the quota on regexFilter rules for all ruleset.
+//
+// The generic rule limits (not specific to regexFilter) are covered elsewhere:
+// - session_rules_total_rule_limit in test_ext_dnr_session_rules.js
+// - test_dynamic_rules_count_limits in test_ext_dnr_dynamic_rules.js
+// (also checks that the quota of session and dynamic rules are separate.)
+// - test_getAvailableStaticRulesCountAndLimits and test_static_rulesets_limits
+// in test_ext_dnr_static_rules.js.
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionDNRLimits: "resource://gre/modules/ExtensionDNRLimits.sys.mjs",
+});
+
+AddonTestUtils.init(this);
+AddonTestUtils.overrideCertDB();
+
+add_setup(async () => {
+ Services.prefs.setBoolPref("extensions.manifestV3.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.enabled", true);
+ Services.prefs.setBoolPref("extensions.dnr.feedback", true);
+
+ // We want to install add-ons through the add-on manager in order to be able
+ // to disable / re-enable the add-on.
+ await ExtensionTestUtils.startAddonManager();
+});
+
+const _origDescs = {};
+function restoreDefaultDnrLimit(key) {
+ info(`Restoring original value of ExtensionDNRLimits.${key}`);
+ Object.defineProperty(ExtensionDNRLimits, key, _origDescs[key]);
+}
+function overrideDefaultDnrLimit(key, value) {
+ // Until DNR limits can be customized through prefs (bug 1803370), we need to
+ // overwrite the internals here in the parent process. That is sufficient to
+ // control the limits. Notably, this does NOT affect the values of the
+ // constants exposed through the declarativeNetRequest keyspace, because
+ // their values are directly read from the extension (child) process.
+ if (!_origDescs[key]) {
+ _origDescs[key] = Object.getOwnPropertyDescriptor(ExtensionDNRLimits, key);
+ registerCleanupFunction(() => restoreDefaultDnrLimit(key));
+ }
+ Assert.ok(
+ typeof value === "number" && Number.isInteger(value),
+ `Setting ExtensionDNRLimits.${key} = ${value} (was: ${ExtensionDNRLimits[key]})`
+ );
+ Object.defineProperty(ExtensionDNRLimits, key, {
+ configurable: true,
+ writable: true,
+ enumerable: true,
+ value,
+ });
+}
+
+// Create an extension composed of the given test cases, and start or reload
+// the extension before each test case.
+//
+// testCases is an array of:
+// - name - unique name describing purpose of test
+// - setup - optional function run before (re-)enabling the extension.
+// - backgroundFn - logic to run in the extension's background.
+// - checkConsoleMessages - function to run to verify the console messages
+// collected between extension (re)startup and the execution of backgroundFn.
+//
+// extensionDataTemplate should be a value for ExtensionTestUtils.loadExtension,
+// without the background key.
+async function startOrReloadExtensionForEach(testCases, extensionDataTemplate) {
+ for (let testCase of testCases) {
+ // Verify that the keys are supported, so that the test does not pass
+ // trivially because of a typo or something.
+ let okKeys = ["name", "setup", "backgroundFn", "checkConsoleMessages"];
+ let keys = Object.keys(testCase).filter(k => !okKeys.includes(k));
+ if (keys.length) {
+ throw new Error(`Unexpected key in testCase ${testCase.name}: ${keys}`);
+ }
+ }
+ if (extensionDataTemplate.background) {
+ // background is generated here, so the template should not specify it.
+ throw new Error("extensionDataTemplate.background should not be set");
+ }
+ function background(testCases) {
+ browser.test.onMessage.addListener(async testName => {
+ try {
+ browser.test.log(`Starting backgroundFn for ${testName}`);
+ await testCases.find(({ name }) => name === testName).backgroundFn();
+ } catch (e) {
+ browser.test.fail(`Unexpected error for ${testName}: ${e}`);
+ }
+ browser.test.log(`Completed backgroundFn for ${testName}`);
+ browser.test.sendMessage(`${testName}:done`);
+ });
+ browser.test.sendMessage("background_started");
+ }
+
+ const serializedTestCases = testCases.map(
+ ({ name, backgroundFn }) => `{name:"${name}",backgroundFn:${backgroundFn}}`
+ );
+ let extension = ExtensionTestUtils.loadExtension({
+ ...extensionDataTemplate,
+ background: `(${background})([${serializedTestCases.join(",")}])`,
+ });
+
+ for (let [i, { name, setup, checkConsoleMessages }] of testCases.entries()) {
+ info(`Running test case: ${name}`);
+ await setup?.();
+
+ let { messages } = await promiseConsoleOutput(async () => {
+ if (i === 0) {
+ await extension.startup();
+ } else {
+ await extension.addon.enable();
+ }
+ await extension.awaitMessage("background_started");
+
+ // DNR rule loading errors should be emitted at startup. But since the
+ // rule loading is async and not blocking background startup, we need to
+ // roundtrip through the DNR API before we can verify the error message.
+ extension.sendMessage(name);
+ await extension.awaitMessage(`${name}:done`);
+ });
+
+ checkConsoleMessages(name, messages);
+
+ if (i === testCases.length - 1) {
+ await extension.unload();
+ } else {
+ await extension.addon.disable();
+ }
+ info(`Completed test case: ${name}`);
+ }
+}
+
+// Create the extensionDataTemplate value (without "background" key!) for use
+// with ExtensionTestUtils.loadExtension, through startOrReloadExtensionForEach.
+function makeExtensionDataTemplate({ manifest, files }) {
+ return {
+ // Note: no "background" key because startOrReloadExtensionForEach adds it.
+ useAddonManager: "permanent",
+ manifest: {
+ manifest_version: 3,
+ permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"],
+ browser_specific_settings: { gecko: { id: "dnr@ext" } },
+ ...manifest,
+ },
+ files,
+ };
+}
+
+function genStaticRules(count) {
+ let rules = [];
+ for (let i = 1; i <= count; ++i) {
+ rules.push({
+ id: i,
+ condition: { regexFilter: `prefix_${i}_suffix` },
+ action: { type: "block" },
+ });
+ }
+ return JSON.stringify(rules);
+}
+
+add_task(async function session_and_dynamic_regexFilter_limit() {
+ let extensionDataTemplate = makeExtensionDataTemplate({});
+
+ // Note: Every testPart* function will be serialized and be part of the test
+ // extension's background script.
+
+ async function testPart1_session_and_dynamic_quota() {
+ let rules = [];
+ const { MAX_NUMBER_OF_REGEX_RULES } = browser.declarativeNetRequest;
+ for (let i = 1; i <= MAX_NUMBER_OF_REGEX_RULES; ++i) {
+ rules.push({
+ id: i,
+ condition: { regexFilter: `prefix_${i}_suffix` },
+ action: { type: "block" },
+ });
+ }
+ const lastRuleId = rules[rules.length - 1].id;
+
+ browser.test.log(`Adding ${rules.length} regex rules (dynamic)`);
+ await browser.declarativeNetRequest.updateDynamicRules({
+ addRules: rules,
+ });
+
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_dynamic" }] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: `http://example.com/prefix_${lastRuleId}_suffix`,
+ type: "other",
+ }),
+ "Expected last regexFilter to match the request"
+ );
+
+ // Dynamic and session rules should have a separate quota for regexFilter.
+ browser.test.log(`Adding ${rules.length} regex rules (session)`);
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: rules,
+ });
+
+ browser.test.assertDeepEq(
+ { matchedRules: [{ ruleId: lastRuleId, rulesetId: "_session" }] },
+ await browser.declarativeNetRequest.testMatchOutcome({
+ url: `http://example.com/prefix_${lastRuleId}_suffix`,
+ type: "other",
+ }),
+ "Expected registered regexFilter to match"
+ );
+
+ // Now we should not be able to add another one.
+ const newRule = {
+ id: lastRuleId + 1,
+ condition: { regexFilter: "." },
+ action: { type: "block" },
+ };
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateSessionRules({ addRules: [newRule] }),
+ `Number of regexFilter rules in ruleset "_session" exceeds MAX_NUMBER_OF_REGEX_RULES.`,
+ "Should not allow regexFilter over quota for session ruleset"
+ );
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateDynamicRules({ addRules: [newRule] }),
+ `Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`,
+ "Should not allow regexFilter over quota for dynamic ruleset"
+ );
+ }
+
+ async function testPart2_after_reload() {
+ browser.test.assertEq(
+ 0,
+ (await browser.declarativeNetRequest.getSessionRules()).length,
+ "Session rules gone after restart"
+ );
+ let rules = await browser.declarativeNetRequest.getDynamicRules();
+ browser.test.assertEq(
+ browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES,
+ rules.length,
+ "Dynamic regexFilter rules still there after restart"
+ );
+
+ // This confirms that the quota for session rules is not somehow persisted
+ // somewhere else.
+ browser.test.log(`Verifying that we can add ${rules.length} rules again.`);
+ await browser.declarativeNetRequest.updateSessionRules({
+ addRules: rules,
+ });
+ }
+
+ async function testPart3_too_many_regexFilters_stored_after_lowering_quota() {
+ browser.test.assertEq(
+ 0,
+ (await browser.declarativeNetRequest.getDynamicRules()).length,
+ "Ignore all stored dynamic rules when regexFilter quota is exceeded"
+ );
+ }
+
+ async function testPart4_reload_after_quota_back() {
+ // Implementation detail: while the in-memory representation of the
+ // dynamic rules has been wiped at the previous extension load, the disk
+ // representation did not change because we only read the dynamic rules
+ // without anything else triggering a save request.
+ //
+ // Therefore, when the limit was somehow restored, the on-disk data is
+ // now considered valid again.
+ browser.test.assertEq(
+ browser.declarativeNetRequest.MAX_NUMBER_OF_REGEX_RULES,
+ (await browser.declarativeNetRequest.getDynamicRules()).length,
+ "On-disk dynamic rules accepted when regexFilter quota is not exceeded"
+ );
+ }
+
+ // Expected warning in console when there are too many regexFilter rules in
+ // the dynamic ruleset data on disk.
+ const errorMsg = `Ignoring dynamic ruleset in extension "dnr@ext" because: Number of regexFilter rules in ruleset "_dynamic" exceeds MAX_NUMBER_OF_REGEX_RULES.`;
+ function expectError(testName, messages) {
+ Assert.equal(
+ messages.filter(m => m.message.includes(errorMsg)).length,
+ 1,
+ `${testName} should trigger the following error in the log: ${errorMsg}`
+ );
+ }
+ function noErrors(testName, messages) {
+ Assert.equal(
+ messages.filter(m => m.message.includes(errorMsg)).length,
+ 0,
+ `${testName} should not trigger any logged errors`
+ );
+ }
+
+ const testCases = [
+ {
+ name: "testPart1_session_and_dynamic_quota",
+ backgroundFn: testPart1_session_and_dynamic_quota,
+ checkConsoleMessages: noErrors,
+ },
+ {
+ name: "testPart2_after_reload",
+ backgroundFn: testPart2_after_reload,
+ checkConsoleMessages: noErrors,
+ },
+ {
+ name: "testPart3_too_many_regexFilters_stored_after_lowering_quota",
+ setup() {
+ // Artificially decrease the max number of allowed regexFilter rules,
+ // so that whatever that was stored on disk is no longer within quota.
+ overrideDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES", 1);
+ },
+ backgroundFn: testPart3_too_many_regexFilters_stored_after_lowering_quota,
+ checkConsoleMessages: expectError,
+ },
+ {
+ name: "testPart4_reload_after_quota_back",
+ setup() {
+ // Restore the original quota after it was lowered in testPart3.
+ restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES");
+ },
+ backgroundFn: testPart4_reload_after_quota_back,
+ checkConsoleMessages: noErrors,
+ },
+ ];
+
+ await startOrReloadExtensionForEach(testCases, extensionDataTemplate);
+});
+
+add_task(async function static_regexFilter_limit() {
+ const { MAX_NUMBER_OF_REGEX_RULES } = ExtensionDNRLimits;
+
+ let extensionDataTemplate = makeExtensionDataTemplate({
+ manifest: {
+ declarative_net_request: {
+ rule_resources: [
+ // limit_plus_1 is over quota, but the other rules should be loaded
+ // if possible.
+ { id: "limit_plus_1", path: "limit_plus_1.json", enabled: true },
+ { id: "just_one", path: "just_one.json", enabled: true },
+ { id: "just_two", path: "just_two.json", enabled: true },
+ { id: "limit_minus_2", path: "limit_minus_2.json", enabled: true },
+ { id: "limit_minus_1", path: "limit_minus_1.json", enabled: false },
+ ],
+ },
+ },
+ files: {
+ "limit_plus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES + 1),
+ "just_one.json": genStaticRules(1),
+ "just_two.json": genStaticRules(2),
+ "limit_minus_2.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 2),
+ "limit_minus_1.json": genStaticRules(MAX_NUMBER_OF_REGEX_RULES - 1),
+ },
+ });
+
+ // Note: Every testPart* function will be serialized and be part of the test
+ // extension's background script.
+
+ async function testPart1_start_over_static_quota() {
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should only load rules that fit in the quota for static rules"
+ );
+ }
+
+ async function testPart2_after_reload() {
+ // Should still be the same.
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should only load rules that fit in the quota for static rules (again)"
+ );
+
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_one"],
+ });
+
+ browser.test.assertDeepEq(
+ ["just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "After disabling 'just_one', there should only be one enabled ruleset"
+ );
+ }
+
+ async function testPart3_after_updateEnabledRulesets_within_limit() {
+ browser.test.assertDeepEq(
+ ["just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "limit_minus_2 should still be disabled because of a previous updateEnabledRulesets call"
+ );
+
+ // This should succeed, as there is now enough space.
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: ["limit_minus_2"],
+ });
+ browser.test.assertDeepEq(
+ ["just_two", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "limit_minus_2 should be enabled by updateEnabledRulesets"
+ );
+
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: ["just_one"],
+ }),
+ `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_one" were to be enabled.`,
+ "Should not be able to enable just_one because limit was reached"
+ );
+ }
+
+ async function testPart4_toggling_rulesets_at_quota() {
+ browser.test.assertDeepEq(
+ ["just_two", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should have the two rulesets occupying all quota from the previous run"
+ );
+
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_two"],
+ enableRulesetIds: ["just_one"],
+ });
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should be able to replace a ruleset as long as the result is within quota"
+ );
+
+ // Try to enable just_one + just_two (+existing limit_minus_2).
+ await browser.test.assertRejects(
+ browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["just_one"],
+ enableRulesetIds: ["just_one", "just_two"],
+ }),
+ `Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "just_two" were to be enabled.`,
+ "Should reject updateEnabledRulesets that would exceed the quota by 1"
+ );
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Rulesets should not have changed due to rejection"
+ );
+ }
+
+ async function testPart5_after_doubling_quota() {
+ browser.test.assertDeepEq(
+ ["just_one", "limit_minus_2"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Initial ruleset not changed after bumping the quota"
+ );
+ // Explicitly disable before re-enabling them all to see whether the order
+ // of passed-in rulesets has any impact on the evaluation order at startup.
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ disableRulesetIds: ["limit_minus_2", "just_one"],
+ });
+ await browser.declarativeNetRequest.updateEnabledRulesets({
+ enableRulesetIds: [
+ "limit_minus_2",
+ "just_two",
+ "just_one",
+ "limit_minus_1",
+ ],
+ });
+ browser.test.assertDeepEq(
+ ["just_one", "just_two", "limit_minus_2", "limit_minus_1"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "All rulesets within new quota - ruleset order should match manifest order"
+ );
+ }
+
+ async function testPart6_after_restoring_original_quota_half() {
+ browser.test.assertDeepEq(
+ ["just_one", "just_two"],
+ await browser.declarativeNetRequest.getEnabledRulesets(),
+ "Should have trimmed excess rules in the manifest order"
+ );
+ }
+
+ const errorMsgPattern =
+ /Ignoring static ruleset "([^"]+)" in extension "dnr@ext" because: Number of regexFilter rules across all enabled static rulesets exceeds MAX_NUMBER_OF_REGEX_RULES if ruleset "\1" were to be enabled./;
+ function checkFailedRulesets(testName, messages, rulesetIds) {
+ let actualRulesetIds = messages
+ .map(m => errorMsgPattern.exec(m.message)?.[1])
+ .filter(Boolean);
+ Assert.deepEqual(
+ rulesetIds,
+ actualRulesetIds,
+ `${testName} should only trigger errors for rejected rulesets at start`
+ );
+ }
+
+ const testCases = [
+ {
+ name: "testPart1_start_over_static_quota",
+ backgroundFn: testPart1_start_over_static_quota,
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]),
+ },
+ {
+ name: "testPart2_after_reload",
+ backgroundFn: testPart2_after_reload,
+ // The extension has not called updateEnabledRulesets, so the "enabled"
+ // state of limit_minus_2 from manifest.json is still the extension's
+ // desired state for the ruleset. When the browser thus tries to enable
+ // the ruleset, it should encounter the same error as before.
+ //
+ // An alternative would be for the latest understanding of "enabled" to
+ // be persisted to disk and used when we load the persisted ruleset state.
+ // But if we do that, then we would not be able to distinguish "disabled
+ // because of a browser limit" from "disabled by extension". And if we
+ // cannot do that, then we would not be able to enable rulesets from
+ // already-installed extensions if we were to bump the limits in a browser
+ // update.
+ //
+ // Note: even if caching is implemented (bug 1803365), the observed
+ // behavior should happen, because the cache is cleared when we disable
+ // the extension.
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_plus_1", "limit_minus_2"]),
+ },
+ {
+ name: "testPart3_after_updateEnabledRulesets_within_limit",
+ backgroundFn: testPart3_after_updateEnabledRulesets_within_limit,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart4_toggling_rulesets_at_quota",
+ backgroundFn: testPart4_toggling_rulesets_at_quota,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart5_after_doubling_quota",
+ setup() {
+ overrideDefaultDnrLimit(
+ "MAX_NUMBER_OF_REGEX_RULES",
+ 2 * MAX_NUMBER_OF_REGEX_RULES
+ );
+ },
+ backgroundFn: testPart5_after_doubling_quota,
+ checkConsoleMessages: (n, m) => checkFailedRulesets(n, m, []),
+ },
+ {
+ name: "testPart6_after_restoring_original_quota_half",
+ setup() {
+ // Restore the original quota after it was raised in testPart5.
+ restoreDefaultDnrLimit("MAX_NUMBER_OF_REGEX_RULES");
+ },
+ backgroundFn: testPart6_after_restoring_original_quota_half,
+ checkConsoleMessages: (n, m) =>
+ checkFailedRulesets(n, m, ["limit_minus_2", "limit_minus_1"]),
+ },
+ ];
+
+ await startOrReloadExtensionForEach(testCases, extensionDataTemplate);
+});