diff options
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.js | 549 |
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); +}); |