Commit 6129e6c6 authored by Luca Greco's avatar Luca Greco
Browse files

Bug 1745763 - Implement DNR getAvailableStaticRuleCount. r=robwu

parent a18ad0a8
Loading
Loading
Loading
Loading
+11 −0
Original line number Diff line number Diff line
@@ -1140,6 +1140,17 @@ class RuleManager {
    this.hasRulesWithTabIds = false;
  }

  get availableStaticRuleCount() {
    return Math.max(
      GUARANTEED_MINIMUM_STATIC_RULES -
        this.enabledStaticRules.reduce(
          (acc, ruleset) => acc + ruleset.rules.length,
          0
        ),
      0
    );
  }

  get enabledStaticRulesetIds() {
    return this.enabledStaticRules.map(ruleset => ruleset.id);
  }
+74 −12
Original line number Diff line number Diff line
@@ -246,6 +246,25 @@ class RulesetsStore {
    return data?.staticRulesets;
  }

  async getAvailableStaticRuleCount(extension) {
    const { GUARANTEED_MINIMUM_STATIC_RULES } = lazy.ExtensionDNR.limits;

    const ruleResources =
      extension.manifest.declarative_net_request?.rule_resources;
    // TODO: return maximum rules count when no static rules is listed in the manifest?
    if (!Array.isArray(ruleResources)) {
      return GUARANTEED_MINIMUM_STATIC_RULES;
    }

    const enabledRulesets = await this.getEnabledStaticRulesets(extension);
    const enabledRulesCount = Array.from(enabledRulesets.values()).reduce(
      (acc, ruleset) => acc + ruleset.rules.length,
      0
    );

    return GUARANTEED_MINIMUM_STATIC_RULES - enabledRulesCount;
  }

  /**
   * Update the enabled rulesets, queue changes to prevent races between calls
   * that may be triggered while an update is still in process.
@@ -428,7 +447,15 @@ class RulesetsStore {
   *        API method).
   * @returns {Promise<Map<ruleset_id, object>> | void}
   */
  async #getManifestStaticRulesets(extension, enabledRulesetIds = null) {
  async #getManifestStaticRulesets(
    extension,
    {
      enabledRulesetIds = null,
      availableStaticRuleCount = lazy.ExtensionDNR.limits
        .GUARANTEED_MINIMUM_STATIC_RULES,
      isUpdateEnabledRulesets = false,
    } = {}
  ) {
    const ruleResources =
      extension.manifest.declarative_net_request?.rule_resources;
    if (!Array.isArray(ruleResources)) {
@@ -554,11 +581,31 @@ class RulesetsStore {
        );
      }

      const ruleset = {
        idx,
        rules: ruleValidator.getValidatedRules(),
      };
      rulesets.set(id, ruleset);
      const validatedRules = ruleValidator.getValidatedRules();

      // 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.
      if (availableStaticRuleCount - validatedRules.length < 0) {
        if (isUpdateEnabledRulesets) {
          throw new ExtensionError(
            "updateEnabledRulesets request is exceeding the available static rule count"
          );
        }

        // TODO(Bug 1803363): consider collect telemetry.
        Cu.reportError(
          `Ignoring static ruleset exceeding the available static rule count: ruleset_id "${id}" (extension: "${extension.id}")`
        );
        // TODO: currently ignoring the current ruleset but would load the one that follows if it
        // fits in the available rule count when loading the rule on extension startup,
        // should it stop loading additional rules instead?
        continue;
      }
      availableStaticRuleCount -= validatedRules.length;

      rulesets.set(id, { idx, rules: validatedRules });
    }

    return rulesets;
@@ -645,7 +692,11 @@ class RulesetsStore {
        // 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.
        Array.isArray(data.staticRulesets) ? data.staticRulesets : null
        {
          enabledRulesetIds: Array.isArray(data.staticRulesets)
            ? data.staticRulesets
            : null,
        }
      );
      return new StoreData(data);
    } catch (e) {
@@ -740,7 +791,10 @@ class RulesetsStore {
      }
    }

    const { MAX_NUMBER_OF_ENABLED_STATIC_RULESETS } = lazy.ExtensionDNR.limits;
    const {
      MAX_NUMBER_OF_ENABLED_STATIC_RULESETS,
      GUARANTEED_MINIMUM_STATIC_RULES,
    } = lazy.ExtensionDNR.limits;

    const maxNewRulesetsCount =
      MAX_NUMBER_OF_ENABLED_STATIC_RULESETS - updatedEnabledRulesets.size;
@@ -752,11 +806,19 @@ class RulesetsStore {
      );
    }

    const newRulesets = await this.#getManifestStaticRulesets(
      extension,
      Array.from(enableIds)
    const availableStaticRuleCount =
      GUARANTEED_MINIMUM_STATIC_RULES -
      Array.from(updatedEnabledRulesets.values()).reduce(
        (acc, ruleset) => acc + ruleset.rules.length,
        0
      );

    const newRulesets = await this.#getManifestStaticRulesets(extension, {
      enabledRulesetIds: Array.from(enableIds),
      availableStaticRuleCount,
      isUpdateEnabledRulesets: true,
    });

    for (const [rulesetId, ruleset] of newRulesets.entries()) {
      updatedEnabledRulesets.set(rulesetId, ruleset);
    }
+6 −0
Original line number Diff line number Diff line
@@ -52,6 +52,12 @@ this.declarativeNetRequest = class extends ExtensionAPI {
          return ruleManager.enabledStaticRulesetIds;
        },

        async getAvailableStaticRuleCount() {
          await ExtensionDNR.ensureInitialized(extension);
          const ruleManager = ExtensionDNR.getRuleManager(extension);
          return ruleManager.availableStaticRuleCount;
        },

        updateEnabledRulesets({ disableRulesetIds, enableRulesetIds }) {
          return ExtensionDNR.updateEnabledStaticRulesets(extension, {
            disableRulesetIds,
+18 −0
Original line number Diff line number Diff line
@@ -506,6 +506,24 @@
          }
        ]
      },
      {
        "name": "getAvailableStaticRuleCount",
        "type": "function",
        "description": "Returns the remaining number of static rules an extension can enable",
        "async": "callback",
        "parameters": [
          {
            "name": "callback",
            "type": "function",
            "parameters": [
              {
                "name": "count",
                "type": "integer"
              }
            ]
          }
        ]
      },
      {
        "name": "getSessionRules",
        "type": "function",
+283 −11
Original line number Diff line number Diff line
@@ -18,6 +18,9 @@ function backgroundWithDNRAPICallHandlers() {
      case "getEnabledRulesets":
        result = await browser.declarativeNetRequest.getEnabledRulesets();
        break;
      case "getAvailableStaticRuleCount":
        result = await browser.declarativeNetRequest.getAvailableStaticRuleCount();
        break;
      case "testMatchOutcome":
        result = await browser.declarativeNetRequest
          .testMatchOutcome(...args)
@@ -155,6 +158,20 @@ const assertDNRTestMatchOutcome = async (
  );
};

const assertDNRGetAvailableStaticRuleCount = async (
  extensionTestWrapper,
  expectedCount,
  assertMessage
) => {
  extensionTestWrapper.sendMessage("getAvailableStaticRuleCount");
  Assert.deepEqual(
    await extensionTestWrapper.awaitMessage("getAvailableStaticRuleCount:done"),
    expectedCount,
    assertMessage ??
      "Got the expected count value from dnr.getAvailableStaticRuleCount API method"
  );
};

const assertDNRGetEnabledRulesets = async (
  extensionTestWrapper,
  expectedRulesetIds
@@ -170,7 +187,8 @@ const assertDNRGetEnabledRulesets = async (
const assertDNRStoreData = async (
  dnrStore,
  extensionTestWrapper,
  expectedRulesets
  expectedRulesets,
  { assertIndividualRules = true } = {}
) => {
  const extUUID = extensionTestWrapper.uuid;
  const rule_resources =
@@ -213,16 +231,65 @@ const assertDNRStoreData = async (
  );

  for (const rulesetId of expectedRulesetIds) {
    Assert.deepEqual(
      dnrExtData.staticRulesets.get(rulesetId),
      {
        idx: expectedRulesetIndexesMap.get(rulesetId),
        rules: getSchemaNormalizedRules(
    const expectedRulesetIdx = expectedRulesetIndexesMap.get(rulesetId);
    const expectedRulesetRules = getSchemaNormalizedRules(
      extensionTestWrapper,
      expectedRulesets[rulesetId]
        ),
      },
      `Got the expected rules for the enabled ruleset ${rulesetId}`
    );
    const actualData = dnrExtData.staticRulesets.get(rulesetId);
    equal(
      actualData.idx,
      expectedRulesetIdx,
      `Got the expected ruleset index for ruleset id ${rulesetId}`
    );

    // Asserting an entire array of rules all at once will produce
    // a big enough output to don't be immediately useful to investigate
    // failures, asserting each rule individually would produce more
    // readable assertion failure logs.
    const assertRuleAtIdx = ruleIdx =>
      Assert.deepEqual(
        actualData.rules[ruleIdx],
        expectedRulesetRules[ruleIdx],
        `Got the expected rule at index ${ruleIdx} for ruleset id "${rulesetId}"`
      );

    // Some tests may be using a big enough number of rules that
    // the assertiongs would be producing a huge amount of log spam,
    // and so for those tests we only explicitly assert the first
    // and last rule and that the total amount of rules matches the
    // expected number of rules (there are still other tests explicitly
    // asserting all loaded rules).
    if (assertIndividualRules) {
      info(
        `Verify the each individual rule loaded for ruleset id "${rulesetId}"`
      );
      for (let ruleIdx = 0; ruleIdx < expectedRulesetRules.length; ruleIdx++) {
        assertRuleAtIdx(ruleIdx);
      }
    } else {
      // NOTE: Only asserting the first and last rule also helps to speed up
      // the test is some slower builds when the number of expected rules is
      // big enough (e.g. the test task verifying the enforced rule count limits
      // was timing out in tsan build because asserting all indidual rules was
      // taking long enough and the event page was being suspended on the idle
      // timeout by the time we did run all these assertion and proceeding with
      // the rest of the test task assertions), we still confirm that the total
      // number of expected vs actual rules also matches right after these
      // assertions.
      info(
        `Verify the first and last rules loaded for ruleset id "${rulesetId}"`
      );
      const lastExpectedRuleIdx = expectedRulesetRules.length - 1;
      for (const ruleIdx of [0, lastExpectedRuleIdx]) {
        assertRuleAtIdx(ruleIdx);
      }
    }

    equal(
      actualData.rules.length,
      expectedRulesetRules.length,
      `Got the expected number of rules loaded for ruleset id "${rulesetId}"`
    );
  }
};
@@ -828,6 +895,211 @@ add_task(async function test_updateEnabledRuleset_id_validation() {
  await extension.unload();
});

add_task(async function test_getAvailableStaticRulesCountAndLimits() {
  const dnrStore = ExtensionDNRStore._getStoreForTesting();
  const { GUARANTEED_MINIMUM_STATIC_RULES } = ExtensionDNR.limits;
  equal(
    typeof GUARANTEED_MINIMUM_STATIC_RULES,
    "number",
    "Expect GUARANTEED_MINIMUM_STATIC_RULES to be a number"
  );

  const availableStaticRulesCount = GUARANTEED_MINIMUM_STATIC_RULES;

  const rule_resources = [
    {
      id: "ruleset_0",
      path: "/ruleset_0.json",
      enabled: true,
    },
    {
      id: "ruleset_1",
      path: "/ruleset_1.json",
      enabled: true,
    },
    // A ruleset initially disabled (to make sure it doesn't count for the
    // rules count limit).
    {
      id: "ruleset_disabled",
      path: "/ruleset_disabled.json",
      enabled: false,
    },
    // A ruleset including an invalid rule and valid rule.
    {
      id: "ruleset_withInvalid",
      path: "/ruleset_withInvalid.json",
      enabled: false,
    },
    // An empty ruleset (to make sure it can still be enabled/disabled just fine,
    // e.g. in case on some browser version all rules are technically invalid).
    {
      id: "ruleset_empty",
      path: "/ruleset_empty.json",
      enabled: false,
    },
  ];

  const files = {};
  const rules = {};

  const rulesetDisabledData = [getDNRRule({ id: 1 })];
  const ruleValid = getDNRRule({ id: 2, action: { type: "allow" } });
  const rulesetWithInvalidData = [
    getDNRRule({ id: 1, action: { type: "invalid_action" } }),
    ruleValid,
  ];

  rules.ruleset_0 = [getDNRRule({ id: 1 }), getDNRRule({ id: 2 })];

  rules.ruleset_1 = [];
  for (let i = 0; i < availableStaticRulesCount; i++) {
    rules.ruleset_1.push(getDNRRule({ id: i + 1 }));
  }

  for (const [k, v] of Object.entries(rules)) {
    files[`${k}.json`] = JSON.stringify(v);
  }
  files[`ruleset_disabled.json`] = JSON.stringify(rulesetDisabledData);
  files[`ruleset_withInvalid.json`] = JSON.stringify(rulesetWithInvalidData);
  files[`ruleset_empty.json`] = JSON.stringify([]);

  const extension = ExtensionTestUtils.loadExtension(
    getDNRExtension({
      id: "dnr-getAvailable-count-@mochitest",
      rule_resources,
      files,
    })
  );

  await extension.startup();
  await extension.awaitMessage("bgpage:ready");

  const expectedEnabledRulesets = {};
  expectedEnabledRulesets.ruleset_0 = getSchemaNormalizedRules(
    extension,
    rules.ruleset_0
  );

  info(
    "Expect ruleset_1 to not be enabled because along with ruleset_0 exceeded the static rules count limit"
  );
  await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);

  await assertDNRGetAvailableStaticRuleCount(
    extension,
    availableStaticRulesCount - rules.ruleset_0.length,
    "Got the available static rule count on ruleset_0 initially enabled"
  );

  // Try to enable ruleset_1 again from the API method.
  extension.sendMessage("updateEnabledRulesets", {
    enableRulesetIds: ["ruleset_1"],
  });
  await extension.awaitMessage("updateEnabledRulesets:done");

  info(
    "Expect ruleset_1 to not be enabled because still exceeded the static rules count limit"
  );
  await assertDNRGetEnabledRulesets(extension, ["ruleset_0"]);
  await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets);

  await assertDNRGetAvailableStaticRuleCount(
    extension,
    availableStaticRulesCount - rules.ruleset_0.length,
    "Got the available static rule count on ruleset_0 still the only one enabled"
  );

  extension.sendMessage("updateEnabledRulesets", {
    disableRulesetIds: ["ruleset_0"],
    enableRulesetIds: ["ruleset_1"],
  });
  await extension.awaitMessage("updateEnabledRulesets:done");

  info("Expect ruleset_1 to be enabled along with disabling ruleset_0");
  await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
  delete expectedEnabledRulesets.ruleset_0;
  expectedEnabledRulesets.ruleset_1 = getSchemaNormalizedRules(
    extension,
    rules.ruleset_1
  );
  await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, {
    // Assert total amount of expected rules and only the first and last rule
    // individually, to avoid generating a huge amount of logs and potential
    // timeout failures on slower builds.
    assertIndividualRules: false,
  });

  await assertDNRGetAvailableStaticRuleCount(
    extension,
    0,
    "Expect no additional static rules count available when ruleset_1 is enabled"
  );

  info(
    "Expect ruleset_disabled to stay disabled because along with ruleset_1 exceeeds the limits"
  );
  extension.sendMessage("updateEnabledRulesets", {
    enableRulesetIds: ["ruleset_disabled"],
  });
  await extension.awaitMessage("updateEnabledRulesets:done");
  await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]);
  await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, {
    // Assert total amount of expected rules and only the first and last rule
    // individually, to avoid generating a huge amount of logs and potential
    // timeout failures on slower builds.
    assertIndividualRules: false,
  });
  await assertDNRGetAvailableStaticRuleCount(
    extension,
    0,
    "Expect no additional static rules count available"
  );

  info("Expect ruleset_empty to be enabled despite having reached the limit");
  extension.sendMessage("updateEnabledRulesets", {
    enableRulesetIds: ["ruleset_empty"],
  });
  await extension.awaitMessage("updateEnabledRulesets:done");
  await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_empty"]);
  await assertDNRStoreData(
    dnrStore,
    extension,
    {
      ...expectedEnabledRulesets,
      ruleset_empty: [],
    },
    // Assert total amount of expected rules and only the first and last rule
    // individually, to avoid generating a huge amount of logs and potential
    // timeout failures on slower builds.
    { assertIndividualRules: false }
  );
  await assertDNRGetAvailableStaticRuleCount(
    extension,
    0,
    "Expect no additional static rules count available"
  );

  info("Expect invalid rules to not be counted towards the limits");
  extension.sendMessage("updateEnabledRulesets", {
    disableRulesetIds: ["ruleset_1", "ruleset_empty"],
    enableRulesetIds: ["ruleset_withInvalid"],
  });
  await extension.awaitMessage("updateEnabledRulesets:done");
  await assertDNRGetEnabledRulesets(extension, ["ruleset_withInvalid"]);
  await assertDNRStoreData(dnrStore, extension, {
    // Only the valid rule has been actually loaded, and the invalid one
    // ignored.
    ruleset_withInvalid: [ruleValid],
  });
  await assertDNRGetAvailableStaticRuleCount(
    extension,
    availableStaticRulesCount - 1,
    "Expect only valid rules to be counted"
  );

  await extension.unload();
});

add_task(async function test_static_rulesets_limits() {
  const dnrStore = ExtensionDNRStore._getStoreForTesting();