tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

extension-storage.js (14155B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const {
      8  BaseStorageActor,
      9 } = require("resource://devtools/server/actors/resources/storage/index.js");
     10 const {
     11  parseItemValue,
     12 } = require("resource://devtools/shared/storage/utils.js");
     13 const {
     14  LongStringActor,
     15 } = require("resource://devtools/server/actors/string.js");
     16 // Use global: "shared" for these extension modules, because these
     17 // are singletons with shared state, and we must not create a new instance if a
     18 // dedicated loader was used to load this module.
     19 loader.lazyGetter(this, "ExtensionParent", () => {
     20  return ChromeUtils.importESModule(
     21    "resource://gre/modules/ExtensionParent.sys.mjs",
     22    { global: "shared" }
     23  ).ExtensionParent;
     24 });
     25 loader.lazyGetter(this, "ExtensionProcessScript", () => {
     26  return ChromeUtils.importESModule(
     27    "resource://gre/modules/ExtensionProcessScript.sys.mjs",
     28    { global: "shared" }
     29  ).ExtensionProcessScript;
     30 });
     31 loader.lazyGetter(this, "ExtensionStorageIDB", () => {
     32  return ChromeUtils.importESModule(
     33    "resource://gre/modules/ExtensionStorageIDB.sys.mjs",
     34    { global: "shared" }
     35  ).ExtensionStorageIDB;
     36 });
     37 
     38 /**
     39 * The Extension Storage actor.
     40 */
     41 class ExtensionStorageActor extends BaseStorageActor {
     42  constructor(storageActor) {
     43    super(storageActor, "extensionStorage");
     44 
     45    this.addonId = this.storageActor.parentActor.addonId;
     46 
     47    // Retrieve the base moz-extension url for the extension
     48    // (and also remove the final '/' from it).
     49    this.extensionHostURL = this.getExtensionPolicy().getURL().slice(0, -1);
     50 
     51    // Map<host, ExtensionStorageIDB db connection>
     52    // Bug 1542038, 1542039: Each storage area will need its own
     53    // dbConnectionForHost, as they each have different storage backends.
     54    // Anywhere dbConnectionForHost is used, we need to know the storage
     55    // area to access the correct database.
     56    this.dbConnectionForHost = new Map();
     57 
     58    this.onExtensionStartup = this.onExtensionStartup.bind(this);
     59 
     60    this.onStorageChange = this.onStorageChange.bind(this);
     61  }
     62 
     63  getExtensionPolicy() {
     64    return WebExtensionPolicy.getByID(this.addonId);
     65  }
     66 
     67  destroy() {
     68    ExtensionStorageIDB.removeOnChangedListener(
     69      this.addonId,
     70      this.onStorageChange
     71    );
     72    ExtensionParent.apiManager.off("startup", this.onExtensionStartup);
     73 
     74    super.destroy();
     75  }
     76 
     77  /**
     78   * We need to override this method as we ignore BaseStorageActor's hosts
     79   * and only care about the extension host.
     80   */
     81  async populateStoresForHosts() {
     82    // Ensure the actor's target is an extension and it is enabled
     83    if (!this.addonId || !this.getExtensionPolicy()) {
     84      return;
     85    }
     86 
     87    // Subscribe a listener for event notifications from the WE storage API when
     88    // storage local data has been changed by the extension, and keep track of the
     89    // listener to remove it when the debugger is being disconnected.
     90    ExtensionStorageIDB.addOnChangedListener(
     91      this.addonId,
     92      this.onStorageChange
     93    );
     94 
     95    try {
     96      // Make sure the extension storage APIs have been loaded,
     97      // otherwise the DevTools storage panel would not be updated
     98      // automatically when the extension storage data is being changed
     99      // if the parent ext-storage.js module wasn't already loaded
    100      // (See Bug 1802929).
    101      const { extension } = WebExtensionPolicy.getByID(this.addonId);
    102      await extension.apiManager.asyncGetAPI("storage", extension);
    103      // Also watch for addon reload in order to also do that
    104      // on next addon startup, otherwise we may also miss updates
    105      ExtensionParent.apiManager.on("startup", this.onExtensionStartup);
    106    } catch (e) {
    107      console.error(
    108        "Exception while trying to initialize webext storage API",
    109        e
    110      );
    111    }
    112 
    113    await this.populateStoresForHost(this.extensionHostURL);
    114  }
    115 
    116  /**
    117   * AddonManager listener used to force instantiating storage API
    118   * implementation in the parent process so that it forward content process
    119   * messages to ExtensionStorageIDB.
    120   *
    121   * Without this, we may miss storage updated after the addon reload.
    122   */
    123  async onExtensionStartup(_evtName, extension) {
    124    if (extension.id != this.addonId) {
    125      return;
    126    }
    127    await extension.apiManager.asyncGetAPI("storage", extension);
    128  }
    129 
    130  /**
    131   * This method asynchronously reads the storage data for the target extension
    132   * and caches this data into this.hostVsStores.
    133   *
    134   * @param {string} host - the hostname for the extension
    135   */
    136  async populateStoresForHost(host) {
    137    if (host !== this.extensionHostURL) {
    138      return;
    139    }
    140 
    141    const extension = ExtensionProcessScript.getExtensionChild(this.addonId);
    142    if (!extension || !extension.hasPermission("storage")) {
    143      return;
    144    }
    145 
    146    // Make sure storeMap is defined and set in this.hostVsStores before subscribing
    147    // a storage onChanged listener in the parent process
    148    const storeMap = new Map();
    149    this.hostVsStores.set(host, storeMap);
    150 
    151    const storagePrincipal = await this.getStoragePrincipal();
    152 
    153    if (!storagePrincipal) {
    154      // This could happen if the extension fails to be migrated to the
    155      // IndexedDB backend
    156      return;
    157    }
    158 
    159    const db = await ExtensionStorageIDB.open(storagePrincipal);
    160    this.dbConnectionForHost.set(host, db);
    161    const data = await db.get();
    162 
    163    for (const [key, value] of Object.entries(data)) {
    164      storeMap.set(key, value);
    165    }
    166  }
    167  /**
    168   * This fires when the extension changes storage data while the storage
    169   * inspector is open. Ensures this.hostVsStores stays up-to-date and
    170   * passes the changes on to update the client.
    171   */
    172  onStorageChange(changes) {
    173    const host = this.extensionHostURL;
    174    const storeMap = this.hostVsStores.get(host);
    175 
    176    function isStructuredCloneHolder(value) {
    177      return (
    178        value &&
    179        typeof value === "object" &&
    180        Cu.getClassName(value, true) === "StructuredCloneHolder"
    181      );
    182    }
    183 
    184    for (const key in changes) {
    185      const storageChange = changes[key];
    186      let { newValue, oldValue } = storageChange;
    187      if (isStructuredCloneHolder(newValue)) {
    188        newValue = newValue.deserialize(this);
    189      }
    190      if (isStructuredCloneHolder(oldValue)) {
    191        oldValue = oldValue.deserialize(this);
    192      }
    193 
    194      let action;
    195      if (typeof newValue === "undefined") {
    196        action = "deleted";
    197        storeMap.delete(key);
    198      } else if (typeof oldValue === "undefined") {
    199        action = "added";
    200        storeMap.set(key, newValue);
    201      } else {
    202        action = "changed";
    203        storeMap.set(key, newValue);
    204      }
    205 
    206      this.storageActor.update(action, this.typeName, { [host]: [key] });
    207    }
    208  }
    209 
    210  async getStoragePrincipal() {
    211    const { extension } = this.getExtensionPolicy();
    212    const { backendEnabled, storagePrincipal } =
    213      await ExtensionStorageIDB.selectBackend({ extension });
    214 
    215    if (!backendEnabled) {
    216      // IDB backend disabled; give up.
    217      return null;
    218    }
    219 
    220    // Received as a StructuredCloneHolder, so we need to deserialize
    221    return storagePrincipal.deserialize(this, true);
    222  }
    223 
    224  getValuesForHost(host, name) {
    225    const result = [];
    226 
    227    if (!this.hostVsStores.has(host)) {
    228      return result;
    229    }
    230 
    231    if (name) {
    232      return [{ name, value: this.hostVsStores.get(host).get(name) }];
    233    }
    234 
    235    for (const [key, value] of Array.from(
    236      this.hostVsStores.get(host).entries()
    237    )) {
    238      result.push({ name: key, value });
    239    }
    240    return result;
    241  }
    242 
    243  /**
    244   * Converts a storage item to an "extensionobject" as defined in
    245   * devtools/shared/specs/storage.js. Behavior largely mirrors the "indexedDB" storage actor,
    246   * except where it would throw an unhandled error (i.e. for a `BigInt` or `undefined`
    247   * `item.value`).
    248   *
    249   * @param {object} item - The storage item to convert
    250   * @param {string} item.name - The storage item key
    251   * @param {*} item.value - The storage item value
    252   * @return {extensionobject}
    253   */
    254  toStoreObject(item) {
    255    if (!item) {
    256      return null;
    257    }
    258 
    259    let { name, value } = item;
    260    const isValueEditable = extensionStorageHelpers.isEditable(value);
    261 
    262    // `JSON.stringify()` throws for `BigInt`, adds extra quotes to strings and `Date` strings,
    263    // and doesn't modify `undefined`.
    264    switch (typeof value) {
    265      case "bigint":
    266        value = `${value.toString()}n`;
    267        break;
    268      case "string":
    269        break;
    270      case "undefined":
    271        value = "undefined";
    272        break;
    273      default:
    274        value = JSON.stringify(value);
    275        if (
    276          // can't use `instanceof` across frame boundaries
    277          Object.prototype.toString.call(item.value) === "[object Date]"
    278        ) {
    279          value = JSON.parse(value);
    280        }
    281    }
    282 
    283    return {
    284      name,
    285      value: new LongStringActor(this.conn, value),
    286      area: "local", // Bug 1542038, 1542039: set the correct storage area
    287      isValueEditable,
    288    };
    289  }
    290 
    291  getFields() {
    292    return [
    293      { name: "name", editable: false },
    294      { name: "value", editable: true },
    295      { name: "area", editable: false },
    296      { name: "isValueEditable", editable: false, private: true },
    297    ];
    298  }
    299 
    300  onItemUpdated(action, host, names) {
    301    this.storageActor.update(action, this.typeName, {
    302      [host]: names,
    303    });
    304  }
    305 
    306  async editItem({ host, items }) {
    307    const db = this.dbConnectionForHost.get(host);
    308    if (!db) {
    309      return;
    310    }
    311 
    312    const { name, value } = items;
    313 
    314    let parsedValue = parseItemValue(value);
    315    if (parsedValue === value) {
    316      const { typesFromString } = extensionStorageHelpers;
    317      for (const { test, parse } of Object.values(typesFromString)) {
    318        if (test(value)) {
    319          parsedValue = parse(value);
    320          break;
    321        }
    322      }
    323    }
    324    const changes = await db.set({ [name]: parsedValue });
    325    this.fireOnChangedExtensionEvent(host, changes);
    326 
    327    this.onItemUpdated("changed", host, [name]);
    328  }
    329 
    330  async removeItem(host, name) {
    331    const db = this.dbConnectionForHost.get(host);
    332    if (!db) {
    333      return;
    334    }
    335 
    336    const changes = await db.remove(name);
    337    this.fireOnChangedExtensionEvent(host, changes);
    338 
    339    this.onItemUpdated("deleted", host, [name]);
    340  }
    341 
    342  async removeAll(host) {
    343    const db = this.dbConnectionForHost.get(host);
    344    if (!db) {
    345      return;
    346    }
    347 
    348    const changes = await db.clear();
    349    this.fireOnChangedExtensionEvent(host, changes);
    350 
    351    this.onItemUpdated("cleared", host, []);
    352  }
    353 
    354  /**
    355   * Let the extension know that storage data has been changed by the user from
    356   * the storage inspector.
    357   */
    358  fireOnChangedExtensionEvent(host, changes) {
    359    // Bug 1542038, 1542039: Which message to send depends on the storage area
    360    const uuid = new URL(host).host;
    361    Services.cpmm.sendAsyncMessage(
    362      `Extension:StorageLocalOnChanged:${uuid}`,
    363      changes
    364    );
    365  }
    366 }
    367 exports.ExtensionStorageActor = ExtensionStorageActor;
    368 
    369 const extensionStorageHelpers = {
    370  /**
    371   * Editing is supported only for serializable types. Examples of unserializable
    372   * types include Map, Set and ArrayBuffer.
    373   */
    374  isEditable(value) {
    375    // Bug 1542038: the managed storage area is never editable
    376    for (const { test } of Object.values(this.supportedTypes)) {
    377      if (test(value)) {
    378        return true;
    379      }
    380    }
    381    return false;
    382  },
    383  isPrimitive(value) {
    384    const primitiveValueTypes = ["string", "number", "boolean"];
    385    return primitiveValueTypes.includes(typeof value) || value === null;
    386  },
    387  isObjectLiteral(value) {
    388    return (
    389      value &&
    390      typeof value === "object" &&
    391      Cu.getClassName(value, true) === "Object"
    392    );
    393  },
    394  // Nested arrays or object literals are only editable 2 levels deep
    395  isArrayOrObjectLiteralEditable(obj) {
    396    const topLevelValuesArr = Array.isArray(obj) ? obj : Object.values(obj);
    397    if (
    398      topLevelValuesArr.some(
    399        value =>
    400          !this.isPrimitive(value) &&
    401          !Array.isArray(value) &&
    402          !this.isObjectLiteral(value)
    403      )
    404    ) {
    405      // At least one value is too complex to parse
    406      return false;
    407    }
    408    const arrayOrObjects = topLevelValuesArr.filter(
    409      value => Array.isArray(value) || this.isObjectLiteral(value)
    410    );
    411    if (arrayOrObjects.length === 0) {
    412      // All top level values are primitives
    413      return true;
    414    }
    415 
    416    // One or more top level values was an array or object literal.
    417    // All of these top level values must themselves have only primitive values
    418    // for the object to be editable
    419    for (const nestedObj of arrayOrObjects) {
    420      const secondLevelValuesArr = Array.isArray(nestedObj)
    421        ? nestedObj
    422        : Object.values(nestedObj);
    423      if (secondLevelValuesArr.some(value => !this.isPrimitive(value))) {
    424        return false;
    425      }
    426    }
    427    return true;
    428  },
    429  typesFromString: {
    430    // Helper methods to parse string values in editItem
    431    jsonifiable: {
    432      test(str) {
    433        try {
    434          JSON.parse(str);
    435        } catch (e) {
    436          return false;
    437        }
    438        return true;
    439      },
    440      parse(str) {
    441        return JSON.parse(str);
    442      },
    443    },
    444  },
    445  supportedTypes: {
    446    // Helper methods to determine the value type of an item in isEditable
    447    array: {
    448      test(value) {
    449        if (Array.isArray(value)) {
    450          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
    451        }
    452        return false;
    453      },
    454    },
    455    boolean: {
    456      test(value) {
    457        return typeof value === "boolean";
    458      },
    459    },
    460    null: {
    461      test(value) {
    462        return value === null;
    463      },
    464    },
    465    number: {
    466      test(value) {
    467        return typeof value === "number";
    468      },
    469    },
    470    object: {
    471      test(value) {
    472        if (extensionStorageHelpers.isObjectLiteral(value)) {
    473          return extensionStorageHelpers.isArrayOrObjectLiteralEditable(value);
    474        }
    475        return false;
    476      },
    477    },
    478    string: {
    479      test(value) {
    480        return typeof value === "string";
    481      },
    482    },
    483  },
    484 };