tor-browser

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

Attachments.sys.mjs (21673B)


      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 const lazy = {};
      6 
      7 ChromeUtils.defineESModuleGetters(lazy, {
      8  RemoteSettingsWorker:
      9    "resource://services-settings/RemoteSettingsWorker.sys.mjs",
     10  Utils: "resource://services-settings/Utils.sys.mjs",
     11 });
     12 
     13 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
     14 
     15 class DownloadError extends Error {
     16  constructor(url, resp) {
     17    super(`Could not download ${url}`);
     18    this.name = "DownloadError";
     19    this.resp = resp;
     20  }
     21 }
     22 
     23 class DownloadBundleError extends Error {
     24  constructor(url, resp) {
     25    super(`Could not download bundle ${url}`);
     26    this.name = "DownloadBundleError";
     27    this.resp = resp;
     28  }
     29 }
     30 
     31 class BadContentError extends Error {
     32  constructor(path) {
     33    super(`${path} content does not match server hash`);
     34    this.name = "BadContentError";
     35  }
     36 }
     37 
     38 class ServerInfoError extends Error {
     39  constructor(error) {
     40    super(`Server response is invalid ${error}`);
     41    this.name = "ServerInfoError";
     42    this.original = error;
     43  }
     44 }
     45 
     46 class NotFoundError extends Error {
     47  constructor(url, resp) {
     48    super(`Could not find ${url} in cache or dump`);
     49    this.name = "NotFoundError";
     50    this.resp = resp;
     51  }
     52 }
     53 
     54 // Helper for the `download` method for commonly used methods, to help with
     55 // lazily accessing the record and attachment content.
     56 class LazyRecordAndBuffer {
     57  constructor(getRecordAndLazyBuffer) {
     58    this.getRecordAndLazyBuffer = getRecordAndLazyBuffer;
     59  }
     60 
     61  async _ensureRecordAndLazyBuffer() {
     62    if (!this.recordAndLazyBufferPromise) {
     63      this.recordAndLazyBufferPromise = this.getRecordAndLazyBuffer();
     64    }
     65    return this.recordAndLazyBufferPromise;
     66  }
     67 
     68  /**
     69   * @returns {object} The attachment record, if found. null otherwise.
     70   */
     71  async getRecord() {
     72    try {
     73      return (await this._ensureRecordAndLazyBuffer()).record;
     74    } catch (e) {
     75      return null;
     76    }
     77  }
     78 
     79  /**
     80   * @param {object} requestedRecord An attachment record
     81   * @returns {boolean} Whether the requested record matches this record.
     82   */
     83  async isMatchingRequestedRecord(requestedRecord) {
     84    const record = await this.getRecord();
     85    return (
     86      record &&
     87      record.last_modified === requestedRecord.last_modified &&
     88      record.attachment.size === requestedRecord.attachment.size &&
     89      record.attachment.hash === requestedRecord.attachment.hash
     90    );
     91  }
     92 
     93  /**
     94   * Generate the return value for the "download" method.
     95   *
     96   * @throws {*} if the record or attachment content is unavailable.
     97   * @returns {object} An object with two properties:
     98   *   buffer: ArrayBuffer with the file content.
     99   *   record: Record associated with the bytes.
    100   */
    101  async getResult() {
    102    const { record, readBuffer } = await this._ensureRecordAndLazyBuffer();
    103    if (!this.bufferPromise) {
    104      this.bufferPromise = readBuffer();
    105    }
    106    return { record, buffer: await this.bufferPromise };
    107  }
    108 }
    109 
    110 export class Downloader {
    111  static get DownloadError() {
    112    return DownloadError;
    113  }
    114  static get DownloadBundleError() {
    115    return DownloadBundleError;
    116  }
    117  static get BadContentError() {
    118    return BadContentError;
    119  }
    120  static get ServerInfoError() {
    121    return ServerInfoError;
    122  }
    123  static get NotFoundError() {
    124    return NotFoundError;
    125  }
    126 
    127  constructor(bucketName, collectionName, ...subFolders) {
    128    this.folders = ["settings", bucketName, collectionName, ...subFolders];
    129    this.bucketName = bucketName;
    130    this.collectionName = collectionName;
    131  }
    132 
    133  /**
    134   * @returns {object} An object with async "get", "set" and "delete" methods.
    135   *                   The keys are strings, the values may be any object that
    136   *                   can be stored in IndexedDB (including Blob).
    137   */
    138  get cacheImpl() {
    139    throw new Error("This Downloader does not support caching");
    140  }
    141 
    142  /**
    143   * Download attachment and return the result together with the record.
    144   * If the requested record cannot be downloaded and fallbacks are enabled, the
    145   * returned attachment may have a different record than the input record.
    146   *
    147   * @param {object} record A Remote Settings entry with attachment.
    148   *                        If omitted, the attachmentId option must be set.
    149   * @param {object} options Some download options.
    150   * @param {number} [options.retries] Number of times download should be retried (default: `3`)
    151   * @param {boolean} [options.checkHash] Check content integrity (default: `true`)
    152   * @param {string} [options.attachmentId] The attachment identifier to use for
    153   *                                      caching and accessing the attachment.
    154   *                                      (default: `record.id`)
    155   * @param {boolean} [options.cacheResult] if the client should cache a copy of the attachment.
    156   *                                          (default: `true`)
    157   * @param {boolean} [options.fallbackToCache] Return the cached attachment when the
    158   *                                          input record cannot be fetched.
    159   *                                          (default: `false`)
    160   * @param {boolean} [options.fallbackToDump] Use the remote settings dump as a
    161   *                                         potential source of the attachment.
    162   *                                         (default: `false`)
    163   * @throws {Downloader.DownloadError} if the file could not be fetched.
    164   * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid.
    165   * @throws {Downloader.ServerInfoError} if the server response is not valid.
    166   * @throws {NetworkError} if fetching the server infos and fetching the attachment fails.
    167   * @returns {object} An object with two properties:
    168   *   `buffer` `ArrayBuffer`: the file content.
    169   *   `record` `Object`: record associated with the attachment.
    170   *   `_source` `String`: identifies the source of the result. Used for testing.
    171   */
    172  async download(record, options) {
    173    return this.#fetchAttachment(record, options);
    174  }
    175 
    176  /**
    177   * Downloads an attachment bundle for a given collection, if one exists. Fills in the cache
    178   * for all attachments provided by the bundle.
    179   *
    180   * @param {boolean} force Set to true to force a sync even when local data exists
    181   * @returns {boolean} True if all attachments were processed successfully, false if failed, null if skipped.
    182   */
    183  async cacheAll(force = false) {
    184    // If we're offline, don't try
    185    if (lazy.Utils.isOffline) {
    186      return null;
    187    }
    188 
    189    // Do nothing if local cache has some data and force is not true
    190    if (!force && (await this.cacheImpl.hasData())) {
    191      return null;
    192    }
    193 
    194    // Save attachments in bulks.
    195    const BULK_SAVE_COUNT = 50;
    196 
    197    const url =
    198      (await lazy.Utils.baseAttachmentsURL()) +
    199      `bundles/${this.bucketName}--${this.collectionName}.zip`;
    200    const tmpZipFilePath = PathUtils.join(
    201      PathUtils.tempDir,
    202      `${Services.uuid.generateUUID().toString().slice(1, -1)}.zip`
    203    );
    204    let allSuccess = true;
    205 
    206    try {
    207      // 1. Download the zip archive to disk
    208      const resp = await lazy.Utils.fetch(url);
    209      if (!resp.ok) {
    210        throw new Downloader.DownloadBundleError(url, resp);
    211      }
    212 
    213      const downloaded = await resp.arrayBuffer();
    214      await IOUtils.write(tmpZipFilePath, new Uint8Array(downloaded), {
    215        tmpPath: `${tmpZipFilePath}.tmp`,
    216      });
    217 
    218      // 2. Read the zipped content
    219      const zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(
    220        Ci.nsIZipReader
    221      );
    222 
    223      const tmpZipFile = await IOUtils.getFile(tmpZipFilePath);
    224      zipReader.open(tmpZipFile);
    225 
    226      const cacheEntries = [];
    227      const zipFiles = Array.from(zipReader.findEntries("*.meta.json"));
    228      allSuccess = !!zipFiles.length;
    229 
    230      for (let i = 0; i < zipFiles.length; i++) {
    231        const lastLoop = i == zipFiles.length - 1;
    232        const entryName = zipFiles[i];
    233        try {
    234          // 3. Read the meta.json entry
    235          const recordZStream = zipReader.getInputStream(entryName);
    236          const recordDataLength = recordZStream.available();
    237          const recordStream = Cc[
    238            "@mozilla.org/scriptableinputstream;1"
    239          ].createInstance(Ci.nsIScriptableInputStream);
    240          recordStream.init(recordZStream);
    241          const recordBytes = recordStream.readBytes(recordDataLength);
    242          const recordBlob = new Blob([recordBytes], {
    243            type: "application/json",
    244          });
    245          const record = JSON.parse(await recordBlob.text());
    246          recordZStream.close();
    247          recordStream.close();
    248 
    249          // 4. Read the attachment entry
    250          const zStream = zipReader.getInputStream(record.id);
    251          const dataLength = zStream.available();
    252          const stream = Cc[
    253            "@mozilla.org/scriptableinputstream;1"
    254          ].createInstance(Ci.nsIScriptableInputStream);
    255          stream.init(zStream);
    256          const fileBytes = stream.readBytes(dataLength);
    257          const blob = new Blob([fileBytes]);
    258 
    259          cacheEntries.push([record.id, { record, blob }]);
    260 
    261          stream.close();
    262          zStream.close();
    263        } catch (ex) {
    264          lazy.console.warn(
    265            `${this.bucketName}/${this.collectionName}: Unable to extract attachment of ${entryName}.`,
    266            ex
    267          );
    268          allSuccess = false;
    269        }
    270 
    271        // 5. Save bulk to cache (last loop or reached count)
    272        if (lastLoop || cacheEntries.length == BULK_SAVE_COUNT) {
    273          try {
    274            await this.cacheImpl.setMultiple(cacheEntries);
    275          } catch (ex) {
    276            lazy.console.warn(
    277              `${this.bucketName}/${this.collectionName}: Unable to save attachments in cache`,
    278              ex
    279            );
    280            allSuccess = false;
    281          }
    282          cacheEntries.splice(0); // start new bulk.
    283        }
    284      }
    285    } catch (ex) {
    286      lazy.console.warn(
    287        `${this.bucketName}/${this.collectionName}: Unable to retrieve remote-settings attachment bundle.`,
    288        ex
    289      );
    290      return false;
    291    }
    292 
    293    return allSuccess;
    294  }
    295 
    296  /**
    297   * Gets an attachment from the cache or local dump, avoiding requesting it
    298   * from the server.
    299   * If the only found attachment hash does not match the requested record, the
    300   * returned attachment may have a different record, e.g. packaged in binary
    301   * resources or one that is outdated.
    302   *
    303   * @param {object} record A Remote Settings entry with attachment.
    304   *                        If omitted, the attachmentId option must be set.
    305   * @param {object} options Some download options.
    306   * @param {number} [options.retries] Number of times download should be retried (default: `3`)
    307   * @param {boolean} [options.checkHash] Check content integrity (default: `true`)
    308   * @param {string} [options.attachmentId] The attachment identifier to use for
    309   *                                      caching and accessing the attachment.
    310   *                                      (default: `record.id`)
    311   * @throws {Downloader.DownloadError} if the file could not be fetched.
    312   * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid.
    313   * @throws {Downloader.ServerInfoError} if the server response is not valid.
    314   * @throws {NetworkError} if fetching the server infos and fetching the attachment fails.
    315   * @returns {object} An object with two properties:
    316   *   `buffer` `ArrayBuffer`: the file content.
    317   *   `record` `Object`: record associated with the attachment.
    318   *   `_source` `String`: identifies the source of the result. Used for testing.
    319   */
    320  async get(
    321    record,
    322    options = {
    323      attachmentId: record?.id,
    324    }
    325  ) {
    326    return this.#fetchAttachment(record, {
    327      ...options,
    328      avoidDownload: true,
    329      fallbackToCache: true,
    330      fallbackToDump: true,
    331    });
    332  }
    333 
    334  // eslint-disable-next-line complexity
    335  async #fetchAttachment(record, options) {
    336    let {
    337      retries,
    338      checkHash,
    339      attachmentId = record?.id,
    340      fallbackToCache = false,
    341      fallbackToDump = false,
    342      avoidDownload = false,
    343      cacheResult = true,
    344    } = options || {};
    345    if (!attachmentId) {
    346      // Check for pre-condition. This should not happen, but it is explicitly
    347      // checked to avoid mixing up attachments, which could be dangerous.
    348      throw new Error(
    349        "download() was called without attachmentId or `record.id`"
    350      );
    351    }
    352 
    353    if (!lazy.Utils.LOAD_DUMPS) {
    354      if (fallbackToDump) {
    355        lazy.console.warn(
    356          "#fetchAttachment: Forcing fallbackToDump to false due to Utils.LOAD_DUMPS being false"
    357        );
    358      }
    359      fallbackToDump = false;
    360    }
    361 
    362    avoidDownload = true;
    363    fallbackToCache = true;
    364    fallbackToDump = true;
    365 
    366    const dumpInfo = new LazyRecordAndBuffer(() =>
    367      this._readAttachmentDump(attachmentId)
    368    );
    369    const cacheInfo = new LazyRecordAndBuffer(() =>
    370      this._readAttachmentCache(attachmentId)
    371    );
    372 
    373    // Check if an attachment dump has been packaged with the client.
    374    // The dump is checked before the cache because dumps are expected to match
    375    // the requested record, at least shortly after the release of the client.
    376    if (fallbackToDump && record) {
    377      if (await dumpInfo.isMatchingRequestedRecord(record)) {
    378        try {
    379          return { ...(await dumpInfo.getResult()), _source: "dump_match" };
    380        } catch (e) {
    381          // Failed to read dump: record found but attachment file is missing.
    382          console.error(e);
    383        }
    384      }
    385    }
    386 
    387    // Check if the requested attachment has already been cached.
    388    if (record) {
    389      if (await cacheInfo.isMatchingRequestedRecord(record)) {
    390        try {
    391          return { ...(await cacheInfo.getResult()), _source: "cache_match" };
    392        } catch (e) {
    393          // Failed to read cache, e.g. IndexedDB unusable.
    394          console.error(e);
    395        }
    396      }
    397    }
    398 
    399    let errorIfAllFails;
    400 
    401    // There is no local version that matches the requested record.
    402    // Try to download the attachment specified in record.
    403    if (!avoidDownload && record && record.attachment) {
    404      try {
    405        const newBuffer = await this.downloadAsBytes(record, {
    406          retries,
    407          checkHash,
    408        });
    409        if (cacheResult) {
    410          const blob = new Blob([newBuffer]);
    411          // Store in cache but don't wait for it before returning.
    412          this.cacheImpl
    413            .set(attachmentId, { record, blob })
    414            .catch(e => console.error(e));
    415        }
    416        return { buffer: newBuffer, record, _source: "remote_match" };
    417      } catch (e) {
    418        // No network, corrupted content, etc.
    419        errorIfAllFails = e;
    420      }
    421    }
    422 
    423    // Unable to find an attachment that matches the record. Consider falling
    424    // back to local versions, even if their attachment hash do not match the
    425    // one from the requested record.
    426 
    427    // Unable to find a valid attachment, fall back to the cached attachment.
    428    const cacheRecord = fallbackToCache && (await cacheInfo.getRecord());
    429    if (cacheRecord) {
    430      const dumpRecord = fallbackToDump && (await dumpInfo.getRecord());
    431      if (dumpRecord?.last_modified >= cacheRecord.last_modified) {
    432        // The dump can be more recent than the cache when the client (and its
    433        // packaged dump) is updated.
    434        try {
    435          return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
    436        } catch (e) {
    437          // Failed to read dump: record found but attachment file is missing.
    438          console.error(e);
    439        }
    440      }
    441 
    442      try {
    443        return { ...(await cacheInfo.getResult()), _source: "cache_fallback" };
    444      } catch (e) {
    445        // Failed to read from cache, e.g. IndexedDB unusable.
    446        console.error(e);
    447      }
    448    }
    449 
    450    // Unable to find a valid attachment, fall back to the packaged dump.
    451    if (fallbackToDump && (await dumpInfo.getRecord())) {
    452      try {
    453        return { ...(await dumpInfo.getResult()), _source: "dump_fallback" };
    454      } catch (e) {
    455        errorIfAllFails = e;
    456      }
    457    }
    458 
    459    if (errorIfAllFails) {
    460      throw errorIfAllFails;
    461    }
    462 
    463    if (avoidDownload) {
    464      throw new Downloader.NotFoundError(attachmentId);
    465    }
    466    throw new Downloader.DownloadError(attachmentId);
    467  }
    468 
    469  /**
    470   * Is the record downloaded? This does not check if it was bundled.
    471   *
    472   * @param record A Remote Settings entry with attachment.
    473   * @returns {Promise<boolean>}
    474   */
    475  isDownloaded(record) {
    476    const cacheInfo = new LazyRecordAndBuffer(() =>
    477      this._readAttachmentCache(record.id)
    478    );
    479    return cacheInfo.isMatchingRequestedRecord(record);
    480  }
    481 
    482  /**
    483   * Delete the record attachment downloaded locally.
    484   * No-op if the attachment does not exist.
    485   *
    486   * @param record A Remote Settings entry with attachment.
    487   * @param {object} [options] Some options.
    488   * @param {string} [options.attachmentId] The attachment identifier to use for
    489   *                                      accessing and deleting the attachment.
    490   *                                      (default: `record.id`)
    491   */
    492  async deleteDownloaded(record, options) {
    493    let { attachmentId = record?.id } = options || {};
    494    if (!attachmentId) {
    495      // Check for pre-condition. This should not happen, but it is explicitly
    496      // checked to avoid mixing up attachments, which could be dangerous.
    497      throw new Error(
    498        "deleteDownloaded() was called without attachmentId or `record.id`"
    499      );
    500    }
    501    return this.cacheImpl.delete(attachmentId);
    502  }
    503 
    504  /**
    505   * Clear the cache from obsolete downloaded attachments.
    506   *
    507   * @param {Array<string>} excludeIds List of attachments IDs to exclude from pruning.
    508   */
    509  async prune(excludeIds) {
    510    return this.cacheImpl.prune(excludeIds);
    511  }
    512  /**
    513   * Download the record attachment and return its content as bytes.
    514   *
    515   * @param {object} record A Remote Settings entry with attachment.
    516   * @param {object} options Some download options.
    517   * @param {number} options.retries Number of times download should be retried (default: `3`)
    518   * @param {boolean} options.checkHash Check content integrity (default: `true`)
    519   * @throws {Downloader.DownloadError} if the file could not be fetched.
    520   * @throws {Downloader.BadContentError} if the downloaded content integrity is not valid.
    521   * @returns {ArrayBuffer} the file content.
    522   */
    523  async downloadAsBytes(record, options = {}) {
    524    const {
    525      attachment: { location, hash, size },
    526    } = record;
    527 
    528    return (await this.#fetchAttachment(record)).buffer;
    529    // eslint-disable-next-line no-unreachable
    530    let baseURL;
    531    try {
    532      baseURL = await lazy.Utils.baseAttachmentsURL();
    533    } catch (error) {
    534      throw new Downloader.ServerInfoError(error);
    535    }
    536 
    537    const remoteFileUrl = baseURL + location;
    538 
    539    const { retries = 3, checkHash = true } = options;
    540    let retried = 0;
    541    while (true) {
    542      try {
    543        const buffer = await this._fetchAttachment(remoteFileUrl);
    544        if (!checkHash) {
    545          return buffer;
    546        }
    547        if (
    548          await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
    549        ) {
    550          return buffer;
    551        }
    552        // Content is corrupted.
    553        throw new Downloader.BadContentError(location);
    554      } catch (e) {
    555        if (retried >= retries) {
    556          throw e;
    557        }
    558      }
    559      retried++;
    560    }
    561  }
    562 
    563  async _fetchAttachment(url) {
    564    const headers = new Headers();
    565    headers.set("Accept-Encoding", "gzip");
    566    const resp = await lazy.Utils.fetch(url, { headers });
    567    if (!resp.ok) {
    568      throw new Downloader.DownloadError(url, resp);
    569    }
    570    return resp.arrayBuffer();
    571  }
    572 
    573  async _readAttachmentCache(attachmentId) {
    574    const cached = await this.cacheImpl.get(attachmentId);
    575    if (!cached) {
    576      throw new Downloader.DownloadError(attachmentId);
    577    }
    578    return {
    579      record: cached.record,
    580      async readBuffer() {
    581        const buffer = await cached.blob.arrayBuffer();
    582        const { size, hash } = cached.record.attachment;
    583        if (
    584          await lazy.RemoteSettingsWorker.checkContentHash(buffer, size, hash)
    585        ) {
    586          return buffer;
    587        }
    588        // Really unexpected, could indicate corruption in IndexedDB.
    589        throw new Downloader.BadContentError(attachmentId);
    590      },
    591    };
    592  }
    593 
    594  async _readAttachmentDump(attachmentId) {
    595    async function fetchResource(resourceUrl) {
    596      try {
    597        return await fetch(resourceUrl);
    598      } catch (e) {
    599        throw new Downloader.DownloadError(resourceUrl);
    600      }
    601    }
    602    const resourceUrlPrefix =
    603      Downloader._RESOURCE_BASE_URL + "/" + this.folders.join("/") + "/";
    604    const recordUrl = `${resourceUrlPrefix}${attachmentId}.meta.json`;
    605    const attachmentUrl = `${resourceUrlPrefix}${attachmentId}`;
    606    const record = await (await fetchResource(recordUrl)).json();
    607    return {
    608      record,
    609      async readBuffer() {
    610        return (await fetchResource(attachmentUrl)).arrayBuffer();
    611      },
    612    };
    613  }
    614 
    615  // Separate variable to allow tests to override this.
    616  static _RESOURCE_BASE_URL = "resource://app/defaults";
    617 }
    618 
    619 /**
    620 * A bare downloader that does not store anything in cache.
    621 */
    622 export class UnstoredDownloader extends Downloader {
    623  get cacheImpl() {
    624    const cacheImpl = {
    625      get: async () => {},
    626      set: async () => {},
    627      setMultiple: async () => {},
    628      delete: async () => {},
    629      prune: async () => {},
    630      hasData: async () => false,
    631    };
    632    Object.defineProperty(this, "cacheImpl", { value: cacheImpl });
    633    return cacheImpl;
    634  }
    635 }