Migrate to Manifest V3: Replace blocking web request listeners

We are following this migration guide:
https://developer.chrome.com/docs/extensions/migrating/blocking-web-requests/

In a nutshell Chrome does not allow request/response manipulation
anymore programmatically: You cannot hook up a listener and do whatever
you like adhoc. Instead you have to register declarative manipulation
rules. This suits us quite well, because we already have declarative
rules defined by the user.

So we are removing all the listeners and the adhoc manipulation, and
instead we are adding conversion logic from our own `Rule` interface
to Chrome's `chrome.declarativeNetRequest.Rule`.

Then we only have to listen to `chrome.storage` updates and call
`updateRules()`, which will do the conversion and make sure that
Chrome's rules are in sync with the user's rules.

We are also removing default rules being fetched from a URL. Instead we
believe that using the default rules that were shipped with the current
version of the extension is more robust, and simpler to maintain.

Google-Bug-Id: b/287605771
Change-Id: I92548878dd82067ed6e14cf9102d6dc51379c56d
diff --git a/src/content_script.ts b/src/content_script.ts
index 06c6bdc..335ae4a 100644
--- a/src/content_script.ts
+++ b/src/content_script.ts
@@ -1,4 +1,4 @@
-import {isInjectRule, Operator, Rule, getUrlParameter} from './utils';
+import {isInjectRule, Operator, getUrlParameter} from './utils';
 import {StorageUtil} from './storage';
 
 declare global {
diff --git a/src/manifest.json b/src/manifest.json
index c196223..6e713d2 100644
--- a/src/manifest.json
+++ b/src/manifest.json
@@ -37,8 +37,8 @@
     "activeTab",
     "storage",
     "tabs",
-    "webRequest",
-    "webRequestBlocking"
+    "declarativeNetRequest",
+    "declarativeNetRequestWithHostAccess"
   ],
   "host_permissions": [
     "https://*.git.corp.google.com/*",
diff --git a/src/popup.ts b/src/popup.ts
index ce4bc62..63db7d9 100644
--- a/src/popup.ts
+++ b/src/popup.ts
@@ -80,12 +80,11 @@
   }
 
   resetRules() {
-    getDefaultRules().then(rules => {
-      this.rules = [...rules];
-      this.rulesStr = JSON.stringify(this.rules, null, 2);
-      window.localStorage.removeItem('helper-announcement');
-      this.requestUpdate();
-    });
+    const rules = getDefaultRules();
+    this.rules = [...rules];
+    this.rulesStr = JSON.stringify(this.rules, null, 2);
+    window.localStorage.removeItem('helper-announcement');
+    this.requestUpdate();
   }
 
   onRuleDeletion(event: CustomEvent<Rule>) {
diff --git a/src/service_worker.ts b/src/service_worker.ts
index ae0e30f..7afa885 100644
--- a/src/service_worker.ts
+++ b/src/service_worker.ts
@@ -1,5 +1,5 @@
-import {isInjectRule, isValidRule, Operator, getActiveTab} from './utils';
-import {StorageUtil} from './storage';
+import {isValidRule, toChromeRule, getStaticRules, getActiveTab} from './utils';
+import {StorageUtil, StorageKey} from './storage';
 
 const storage = new StorageUtil();
 
@@ -28,8 +28,13 @@
 });
 
 chrome.storage.onChanged.addListener((changes, namespace) => {
+  const ruleUpdateRequired = Object.keys(changes).some(
+    key => key === StorageKey.RULES || key === StorageKey.TABS_ENABLED
+  );
+  if (ruleUpdateRequired) updateRules();
+
   const iconUpdateRequired = Object.keys(changes).some(
-    key => key === 'tabsEnabled'
+    key => key === StorageKey.TABS_ENABLED
   );
   if (iconUpdateRequired) updateIconPopup();
 });
@@ -49,163 +54,6 @@
   }
 });
 
-async function onHeadersReceived(
-  resp: chrome.webRequest.WebResponseHeadersDetails
-) {
-  if (!resp || !resp.responseHeaders) return {};
-  const isEnabled = await storage.isTabEnabled(resp.tabId);
-  if (!isEnabled) {
-    return {responseHeaders: resp.responseHeaders};
-  }
-
-  const matches = (await storage.getRules())
-    .filter(isValidRule)
-    .filter(
-      rule =>
-        rule.operator === Operator.REMOVE_RESPONSE_HEADER &&
-        !rule.disabled &&
-        new RegExp(rule.target).test(resp.url)
-    );
-  matches.forEach(rule => {
-    const removedHeaders = rule.destination
-      .split(',')
-      .map(name => name.toLowerCase());
-    resp.responseHeaders = resp.responseHeaders.filter(
-      h => !removedHeaders.includes(h.name.toLowerCase())
-    );
-  });
-  const addMatches = (await storage.getRules())
-    .filter(isValidRule)
-    .filter(
-      rule =>
-        rule.operator === Operator.ADD_RESPONSE_HEADER &&
-        !rule.disabled &&
-        new RegExp(rule.target).test(resp.url)
-    );
-  addMatches.forEach(rule => {
-    const addedHeaders = rule.destination.split('|');
-    addedHeaders.forEach(addedHeader => {
-      const partial = addedHeader.split('=');
-      if (partial.length === 2) {
-        resp.responseHeaders.push({
-          name: partial[0],
-          value: partial[1],
-        });
-      }
-    });
-  });
-  return {responseHeaders: resp.responseHeaders};
-}
-
-async function onBeforeRequest(
-  details: chrome.webRequest.WebRequestBodyDetails
-) {
-  const isEnabled = await storage.isTabEnabled(details.tabId);
-  if (!isEnabled) {
-    return {cancel: false};
-  }
-
-  const matches = (await storage.getRules())
-    .filter(isValidRule)
-    .filter(
-      rule =>
-        !isInjectRule(rule) &&
-        !rule.disabled &&
-        new RegExp(rule.target).test(details.url)
-    );
-
-  const blockMatch = matches.find(rule => rule.operator === Operator.BLOCK);
-  const redirectMatch = matches.find(
-    rule => rule.operator === Operator.REDIRECT
-  );
-
-  // block match takes highest priority
-  if (blockMatch) {
-    return {cancel: true};
-  }
-
-  // then redirect
-  if (redirectMatch) {
-    return {
-      redirectUrl: details.url.replace(
-        new RegExp(redirectMatch.target),
-        redirectMatch.destination
-      ),
-    };
-  }
-
-  // otherwise, don't do anything
-  return {cancel: false};
-}
-
-async function onBeforeSendHeaders(
-  details: chrome.webRequest.WebRequestHeadersDetails
-) {
-  if (!details || !details.requestHeaders) return {};
-  const isEnabled = await storage.isTabEnabled(details.tabId);
-  if (!isEnabled) {
-    return {requestHeaders: details.requestHeaders};
-  }
-
-  let len = details.requestHeaders.length;
-  let added = false;
-  while (--len) {
-    const header = details.requestHeaders[len];
-    if (
-      header.name.toLowerCase() === 'cache-control' ||
-      header.name.toLowerCase() === 'x-google-cache-control'
-    ) {
-      header.value = 'max-age=0, no-cache, no-store, must-revalidate';
-      added = true;
-    }
-  }
-  if (!added) {
-    details.requestHeaders.push({
-      name: 'Cache-Control',
-      value: 'max-age=0, no-cache, no-store, must-revalidate',
-    });
-  }
-
-  const matches = (await storage.getRules())
-    .filter(isValidRule)
-    .filter(
-      rule =>
-        rule.operator === Operator.ADD_REQUEST_HEADER &&
-        !rule.disabled &&
-        new RegExp(rule.target).test(details.url)
-    );
-  matches.forEach(rule => {
-    const addedHeaders = rule.destination.split(',');
-    addedHeaders.forEach(addedHeader => {
-      const partial = addedHeader.split('=');
-      if (partial.length === 2) {
-        details.requestHeaders.push({
-          name: partial[0],
-          value: partial[1],
-        });
-      }
-    });
-  });
-
-  return {requestHeaders: details.requestHeaders};
-}
-
-chrome.webRequest.onHeadersReceived.addListener(
-  onHeadersReceived,
-  {urls: ['<all_urls>']},
-  ['blocking', 'responseHeaders']
-);
-chrome.webRequest.onBeforeRequest.addListener(
-  onBeforeRequest,
-  {urls: ['<all_urls>']},
-  ['blocking', 'extraHeaders']
-);
-chrome.webRequest.onBeforeSendHeaders.addListener(
-  onBeforeSendHeaders,
-  {urls: ['<all_urls>']},
-  ['blocking', 'requestHeaders']
-);
-
 // This is a click on the extension icon.
 chrome.action.onClicked.addListener(async (tab: chrome.tabs.Tab) => {
   const isEnabled = await storage.isTabEnabled(tab.id);
@@ -218,6 +66,34 @@
 });
 
 /**
+ * Fetches the user defined rules from the extension storage, translates them
+ * to `declarativeNetRequest` rules and registers them. Must be called whenever
+ * the rules or the active tabs change.
+ */
+async function updateRules() {
+  const tabIds: number[] = await storage.getTabsEnabledIds();
+
+  // Start with `staticRules` and then add all `storedRules`.
+  const addRules = [...getStaticRules(tabIds)];
+  // We are replacing all existing rules. So we can start at 1 every time.
+  let ruleId = 1;
+  for (const rule of await storage.getRules()) {
+    if (rule.disabled || !isValidRule(rule)) continue;
+    const chromeRule = toChromeRule(rule, tabIds, ruleId++);
+    if (chromeRule) addRules.push(chromeRule);
+  }
+
+  // Replacing all existing rules is much easier than updating specific ones.
+  const removeRuleIds = (
+    await chrome.declarativeNetRequest.getSessionRules()
+  ).map(r => r.id);
+  await chrome.declarativeNetRequest.updateSessionRules({
+    addRules,
+    removeRuleIds,
+  });
+}
+
+/**
  * Update the icon and the popup of the extension depending on whether the
  * extension is enabled for the given tab id. If not tab id is provided, then
  * the active tab is updated.
diff --git a/src/storage.ts b/src/storage.ts
index 242b923..9dd9bda 100644
--- a/src/storage.ts
+++ b/src/storage.ts
@@ -2,6 +2,11 @@
 
 type TabsEnabled = {[tabId: string]: boolean};
 
+export enum StorageKey {
+  RULES = 'rules',
+  TABS_ENABLED = 'tabsEnabled',
+}
+
 /**
  * A utility that wraps all calls to `chrome.storage`.
  *
@@ -18,8 +23,8 @@
   // RULES
 
   async getRules(): Promise<Rule[]> {
-    const data = await chrome.storage.sync.get('rules');
-    return (data?.['rules'] as Rule[]) ?? [...DEFAULT_RULES];
+    const data = await chrome.storage.sync.get(StorageKey.RULES);
+    return (data?.[StorageKey.RULES] as Rule[]) ?? [...DEFAULT_RULES];
   }
 
   async setRules(rules: Rule[]) {
@@ -39,8 +44,8 @@
   // TABS ENABLED
 
   private async getTabsEnabled(): Promise<TabsEnabled> {
-    const data = await chrome.storage.session.get('tabsEnabled');
-    return (data?.['tabsEnabled'] as TabsEnabled) ?? {};
+    const data = await chrome.storage.session.get(StorageKey.TABS_ENABLED);
+    return (data?.[StorageKey.TABS_ENABLED] as TabsEnabled) ?? {};
   }
 
   private async setTabsEnabled(tabsEnabled: TabsEnabled) {
@@ -64,6 +69,11 @@
     return tabsEnabled[`${tabId}`] === true;
   }
 
+  async getTabsEnabledIds(): Promise<number[]> {
+    const tabs = await this.getTabsEnabled();
+    return Object.keys(tabs).map(id => Number(id));
+  }
+
   async initTabsEnabled() {
     await this.setTabsEnabled({});
   }
diff --git a/src/utils.ts b/src/utils.ts
index 8226393..29dffa1 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -1,32 +1,12 @@
 import * as _DEFAULT_RULES from '../data/rules.json';
 
-/**
- * Default rules.
- */
 export const DEFAULT_RULES: Rule[] = _DEFAULT_RULES as Rule[];
 
-/**
- * Retrieves default rules from remote rules file, fallback to existing DEFAULT_RULES
- */
-export async function getDefaultRules() {
-  // try fetch from remote
-  const remoteRulesUrl =
-    'https://gerrit.googlesource.com/gerrit-fe-dev-helper/+/refs/heads/master/data/rules.json?format=TEXT';
-  try {
-    const response = await fetch(remoteRulesUrl);
-    const encodedText = await response.text();
-    return JSON.parse(atob(encodedText));
-  } catch (e) {
-    console.log(e);
-  }
-
-  // fallback to existing default rules
-  return DEFAULT_RULES;
+// Returns the content of the file `data/rules.json` as an object.
+export function getDefaultRules() {
+  return [...DEFAULT_RULES];
 }
 
-/**
- * Returns if it's a valid rule (syntax only).
- */
 export function isValidRule(rule: Rule) {
   return (
     Object.values(Operator).includes(rule.operator) &&
@@ -38,9 +18,6 @@
   );
 }
 
-/**
- * Returns if it's a inject rule.
- */
 export function isInjectRule(rule: Rule) {
   return [
     Operator.INJECT_JS_MODULE_PLUGIN,
@@ -52,6 +29,107 @@
   ].some(op => op === rule.operator);
 }
 
+export function getStaticRules(
+  tabIds: number[]
+): chrome.declarativeNetRequest.Rule[] {
+  if (tabIds.length === 0) return [];
+  return [
+    {
+      action: {
+        requestHeaders: [
+          {
+            header: 'cache-control',
+            value: 'max-age=0, no-cache, no-store, must-revalidate',
+            operation: chrome.declarativeNetRequest.HeaderOperation.SET,
+          },
+          {
+            header: 'x-google-cache-control',
+            value: 'max-age=0, no-cache, no-store, must-revalidate',
+            operation: chrome.declarativeNetRequest.HeaderOperation.SET,
+          },
+        ],
+        type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
+      },
+      condition: {
+        urlFilter: '*',
+        tabIds,
+      },
+      // We just don't want to conflict with dynamic rule ids. They start counting from 1.
+      id: 314159,
+    },
+  ];
+}
+
+export function toChromeRule(
+  rule: Rule,
+  tabIds: number[],
+  ruleId: number
+): chrome.declarativeNetRequest.Rule | undefined {
+  if (rule.disabled) return undefined;
+  if (tabIds.length === 0) return undefined;
+
+  const action = convertRuleToAction(rule);
+  if (!action) return undefined;
+  return {
+    action,
+    condition: {
+      regexFilter: rule.target,
+      tabIds,
+    },
+    id: ruleId,
+  };
+}
+
+function convertRuleToAction(
+  rule: Rule
+): chrome.declarativeNetRequest.RuleAction | undefined {
+  switch (rule.operator) {
+    case Operator.BLOCK:
+      return {
+        type: chrome.declarativeNetRequest.RuleActionType.BLOCK,
+      };
+    case Operator.ADD_RESPONSE_HEADER:
+    case Operator.REMOVE_RESPONSE_HEADER:
+      return {
+        responseHeaders: headerInfos(rule),
+        type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
+      };
+    case Operator.ADD_REQUEST_HEADER:
+      return {
+        requestHeaders: headerInfos(rule),
+        type: chrome.declarativeNetRequest.RuleActionType.MODIFY_HEADERS,
+      };
+    case Operator.REDIRECT:
+      return {
+        type: chrome.declarativeNetRequest.RuleActionType.REDIRECT,
+        redirect: {regexSubstitution: rule.destination},
+      };
+    default:
+      return undefined;
+  }
+}
+
+function headerInfos(
+  rule: Rule
+): chrome.declarativeNetRequest.ModifyHeaderInfo[] | undefined {
+  const operation =
+    rule.operator === Operator.REMOVE_RESPONSE_HEADER
+      ? chrome.declarativeNetRequest.HeaderOperation.REMOVE
+      : chrome.declarativeNetRequest.HeaderOperation.SET;
+
+  const headerInfos: chrome.declarativeNetRequest.ModifyHeaderInfo[] = [];
+  const ruleHeaders = rule.destination.split('|');
+  for (const header of ruleHeaders) {
+    const partial = header.split('=');
+    headerInfos.push({
+      header: partial[0],
+      operation,
+      value: partial[1],
+    });
+  }
+  return headerInfos;
+}
+
 /**
  * Supported operators.
  */