tor-browser

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

RemoteSettingsClient.sys.mjs (49004B)


      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 import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
      6 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
      7 
      8 import { Downloader } from "resource://services-settings/Attachments.sys.mjs";
      9 
     10 const lazy = {};
     11 
     12 ChromeUtils.defineESModuleGetters(lazy, {
     13  ClientEnvironmentBase:
     14    "resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
     15  Database: "resource://services-settings/Database.sys.mjs",
     16  IDBHelpers: "resource://services-settings/IDBHelpers.sys.mjs",
     17  KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs",
     18  ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
     19  RemoteSettingsWorker:
     20    "resource://services-settings/RemoteSettingsWorker.sys.mjs",
     21  RemoteSettings: "resource://services-settings/remote-settings.sys.mjs",
     22  SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
     23  UptakeTelemetry: "resource://services-common/uptake-telemetry.sys.mjs",
     24  Utils: "resource://services-settings/Utils.sys.mjs",
     25 });
     26 
     27 const TELEMETRY_COMPONENT = "Remotesettings";
     28 
     29 ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
     30 
     31 /**
     32 * Minimalist event emitter.
     33 *
     34 * Note: we don't use `toolkit/modules/EventEmitter` because **we want** to throw
     35 * an error when a listener fails to execute.
     36 */
     37 class EventEmitter {
     38  constructor(events) {
     39    this._listeners = new Map();
     40    for (const event of events) {
     41      this._listeners.set(event, []);
     42    }
     43  }
     44 
     45  /**
     46   * Event emitter: will execute the registered listeners in the order and
     47   * sequentially.
     48   *
     49   * @param {string} event    the event name
     50   * @param {object} payload  the event payload to call the listeners with
     51   */
     52  async emit(event, payload) {
     53    const callbacks = this._listeners.get(event);
     54    let lastError;
     55    for (const cb of callbacks) {
     56      try {
     57        await cb(payload);
     58      } catch (e) {
     59        lastError = e;
     60      }
     61    }
     62    if (lastError) {
     63      throw lastError;
     64    }
     65  }
     66 
     67  hasListeners(event) {
     68    return this._listeners.has(event) && !!this._listeners.get(event).length;
     69  }
     70 
     71  on(event, callback) {
     72    if (!this._listeners.has(event)) {
     73      throw new Error(`Unknown event type ${event}`);
     74    }
     75    this._listeners.get(event).push(callback);
     76  }
     77 
     78  off(event, callback) {
     79    if (!this._listeners.has(event)) {
     80      throw new Error(`Unknown event type ${event}`);
     81    }
     82    const callbacks = this._listeners.get(event);
     83    const i = callbacks.indexOf(callback);
     84    if (i < 0) {
     85      throw new Error(`Unknown callback`);
     86    } else {
     87      callbacks.splice(i, 1);
     88    }
     89  }
     90 }
     91 
     92 class APIError extends Error {}
     93 
     94 class NetworkError extends APIError {
     95  constructor(e) {
     96    super(`Network error: ${e}`, { cause: e });
     97    this.name = "NetworkError";
     98  }
     99 }
    100 
    101 class NetworkOfflineError extends APIError {
    102  constructor() {
    103    super("Network is offline");
    104    this.name = "NetworkOfflineError";
    105  }
    106 }
    107 
    108 class ServerContentParseError extends APIError {
    109  constructor(e) {
    110    super(`Cannot parse server content: ${e}`, { cause: e });
    111    this.name = "ServerContentParseError";
    112  }
    113 }
    114 
    115 class BackendError extends APIError {
    116  constructor(e) {
    117    super(`Backend error: ${e}`, { cause: e });
    118    this.name = "BackendError";
    119  }
    120 }
    121 
    122 class BackoffError extends APIError {
    123  constructor(e) {
    124    super(`Server backoff: ${e}`, { cause: e });
    125    this.name = "BackoffError";
    126  }
    127 }
    128 
    129 class TimeoutError extends APIError {
    130  constructor(e) {
    131    super(`API timeout: ${e}`, { cause: e });
    132    this.name = "TimeoutError";
    133  }
    134 }
    135 
    136 class StorageError extends Error {
    137  constructor(e) {
    138    super(`Storage error: ${e}`, { cause: e });
    139    this.name = "StorageError";
    140  }
    141 }
    142 
    143 class InvalidSignatureError extends Error {
    144  constructor(cid, x5u, signerName) {
    145    let message = `Invalid content signature (${cid})`;
    146    if (x5u) {
    147      const chain = x5u.split("/").pop();
    148      message += ` using '${chain}' and signer ${signerName}`;
    149    }
    150    super(message);
    151    this.name = "InvalidSignatureError";
    152  }
    153 }
    154 
    155 class MissingSignatureError extends InvalidSignatureError {
    156  constructor(cid) {
    157    super(cid);
    158    this.message = `Missing signature (${cid})`;
    159    this.name = "MissingSignatureError";
    160  }
    161 }
    162 
    163 class CorruptedDataError extends InvalidSignatureError {
    164  constructor(cid) {
    165    super(cid);
    166    this.message = `Corrupted local data (${cid})`;
    167    this.name = "CorruptedDataError";
    168  }
    169 }
    170 
    171 class UnknownCollectionError extends Error {
    172  constructor(cid) {
    173    super(`Unknown Collection "${cid}"`);
    174    this.name = "UnknownCollectionError";
    175  }
    176 }
    177 
    178 class AttachmentDownloader extends Downloader {
    179  constructor(client) {
    180    super(client.bucketName, client.collectionName);
    181    this._client = client;
    182  }
    183 
    184  get cacheImpl() {
    185    const cacheImpl = {
    186      get: async attachmentId => {
    187        return this._client.db.getAttachment(attachmentId);
    188      },
    189      set: async (attachmentId, attachment) => {
    190        return this._client.db.saveAttachment(attachmentId, attachment);
    191      },
    192      setMultiple: async attachmentsIdsBlobs => {
    193        return this._client.db.saveAttachments(attachmentsIdsBlobs);
    194      },
    195      delete: async attachmentId => {
    196        return this._client.db.saveAttachment(attachmentId, null);
    197      },
    198      prune: async excludeIds => {
    199        return this._client.db.pruneAttachments(excludeIds);
    200      },
    201      hasData: async () => {
    202        return this._client.db.hasAttachments();
    203      },
    204    };
    205    Object.defineProperty(this, "cacheImpl", { value: cacheImpl });
    206    return cacheImpl;
    207  }
    208 
    209  /**
    210   * Download attachment and report Telemetry on failure.
    211   *
    212   * @see Downloader.download
    213   */
    214  async download(record, options) {
    215    await lazy.UptakeTelemetry.report(
    216      TELEMETRY_COMPONENT,
    217      lazy.UptakeTelemetry.STATUS.DOWNLOAD_START,
    218      {
    219        source: this._client.identifier,
    220      }
    221    );
    222    try {
    223      // Explicitly await here to ensure we catch a network error.
    224      return await super.download(record, options);
    225    } catch (err) {
    226      // Report download error.
    227      let status = lazy.UptakeTelemetry.STATUS.DOWNLOAD_ERROR;
    228      if (lazy.Utils.isOffline) {
    229        status = lazy.UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
    230      } else if (/NetworkError/.test(err.message)) {
    231        status = lazy.UptakeTelemetry.STATUS.NETWORK_ERROR;
    232      }
    233      // If the file failed to be downloaded, report it as such in Telemetry.
    234      await lazy.UptakeTelemetry.report(TELEMETRY_COMPONENT, status, {
    235        source: this._client.identifier,
    236        errorName: err.name,
    237      });
    238      throw err;
    239    }
    240  }
    241 
    242  /**
    243   * Delete all downloaded records attachments.
    244   *
    245   * Note: the list of attachments to be deleted is based on the
    246   * current list of records.
    247   */
    248  async deleteAll() {
    249    let allRecords = await this._client.db.list();
    250    return Promise.all(
    251      allRecords.filter(r => !!r.attachment).map(r => this.deleteDownloaded(r))
    252    );
    253  }
    254 }
    255 
    256 export class RemoteSettingsClient extends EventEmitter {
    257  static get APIError() {
    258    return APIError;
    259  }
    260  static get NetworkError() {
    261    return NetworkError;
    262  }
    263  static get NetworkOfflineError() {
    264    return NetworkOfflineError;
    265  }
    266  static get ServerContentParseError() {
    267    return ServerContentParseError;
    268  }
    269  static get BackendError() {
    270    return BackendError;
    271  }
    272  static get BackoffError() {
    273    return BackoffError;
    274  }
    275  static get TimeoutError() {
    276    return TimeoutError;
    277  }
    278  static get StorageError() {
    279    return StorageError;
    280  }
    281  static get InvalidSignatureError() {
    282    return InvalidSignatureError;
    283  }
    284  static get MissingSignatureError() {
    285    return MissingSignatureError;
    286  }
    287  static get CorruptedDataError() {
    288    return CorruptedDataError;
    289  }
    290  static get UnknownCollectionError() {
    291    return UnknownCollectionError;
    292  }
    293  static get EmptyDatabaseError() {
    294    return lazy.Database.EmptyDatabaseError;
    295  }
    296 
    297  /**
    298   * RemoteSettingsClient constructor.
    299   *
    300   * options.filterCreator is an optional function returning a filter object
    301   * which can map and exclude the entries returned from `.get()`. You often
    302   * want to set this to the default filter creator `jexlFilterCreator`.
    303   * The function needs to have the shape
    304   * `async (environment, collectionName) => RemoteSettingsEntryFilter`, where
    305   * `RemoteSettingsEntryFilter` refers to an interface with a single method:
    306   * `async filterEntry(entry)`. This method should return either the (mapped)
    307   * entry or a falsy value if the entry should be filtered out.
    308   */
    309  constructor(
    310    collectionName,
    311    {
    312      bucketName = AppConstants.REMOTE_SETTINGS_DEFAULT_BUCKET,
    313      signerName,
    314      filterCreator,
    315      localFields = [],
    316      keepAttachmentsIds = [],
    317      lastCheckTimePref,
    318    } = {}
    319  ) {
    320    // Remote Settings cannot be used in child processes (no access to disk,
    321    // easily killed, isolated observer notifications etc.).
    322    // Since our goal here is to prevent consumers to instantiate while developing their
    323    // feature, throwing in Nightly only is enough, and prevents unexpected crashes
    324    // in release or beta.
    325    if (
    326      !AppConstants.RELEASE_OR_BETA &&
    327      Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT
    328    ) {
    329      throw new Error(
    330        "Cannot instantiate Remote Settings client in child processes."
    331      );
    332    }
    333 
    334    super(["sync"]); // emitted events
    335 
    336    this.collectionName = collectionName;
    337    // Client is constructed with the raw bucket name (eg. "main", "security-state", "blocklist")
    338    // The `bucketName` will contain the `-preview` suffix if the preview mode is enabled.
    339    this.bucketName = lazy.Utils.actualBucketName(bucketName);
    340    this.signerName = signerName;
    341    this.filterCreator = filterCreator;
    342    this.localFields = localFields;
    343    this.keepAttachmentsIds = keepAttachmentsIds;
    344    this._lastCheckTimePref = lastCheckTimePref;
    345    this._verifier = null;
    346    this._syncRunning = false;
    347 
    348    // This attribute allows signature verification to be disabled, when running tests
    349    // or when pulling data from a dev server.
    350    this.verifySignature = AppConstants.REMOTE_SETTINGS_VERIFY_SIGNATURE;
    351  }
    352 
    353  #lazy = XPCOMUtils.declareLazy({
    354    db: () => new lazy.Database(this.identifier),
    355    attachments: () => new AttachmentDownloader(this),
    356  });
    357 
    358  get db() {
    359    return this.#lazy.db;
    360  }
    361 
    362  get attachments() {
    363    return this.#lazy.attachments;
    364  }
    365 
    366  /**
    367   * Internal method to refresh the client bucket name after the preview mode
    368   * was toggled.
    369   *
    370   * See `RemoteSettings.enabledPreviewMode()`.
    371   */
    372  refreshBucketName() {
    373    this.bucketName = lazy.Utils.actualBucketName(this.bucketName);
    374    this.db.identifier = this.identifier;
    375  }
    376 
    377  get identifier() {
    378    return `${this.bucketName}/${this.collectionName}`;
    379  }
    380 
    381  get lastCheckTimePref() {
    382    return (
    383      this._lastCheckTimePref ||
    384      `services.settings.${this.bucketName}.${this.collectionName}.last_check`
    385    );
    386  }
    387 
    388  httpClient() {
    389    const api = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL, {
    390      fetchFunc: lazy.Utils.fetch, // Use fetch() wrapper.
    391    });
    392    return api.bucket(this.bucketName).collection(this.collectionName);
    393  }
    394 
    395  /**
    396   * Retrieve the collection timestamp for the last synchronization.
    397   * This is an opaque and comparable value assigned automatically by
    398   * the server.
    399   *
    400   * @returns {Promise<number>}
    401   *          The timestamp in milliseconds, returns -1 if retrieving
    402   *          the timestamp from the kinto collection fails.
    403   */
    404  async getLastModified() {
    405    let timestamp = -1;
    406    try {
    407      timestamp = await this.db.getLastModified();
    408    } catch (err) {
    409      lazy.console.warn(
    410        `Error retrieving the getLastModified timestamp from ${this.identifier} RemoteSettingsClient`,
    411        err
    412      );
    413    }
    414 
    415    return timestamp;
    416  }
    417 
    418  /**
    419   * Lists settings.
    420   *
    421   * @param  {object} [options]
    422   *   The options object.
    423   * @param  {object} [options.filters]
    424   *   Filter the results (default: `{}`).
    425   * @param  {string} [options.order]
    426   *   The order to apply (eg. `"-last_modified"`).
    427   * @param  {boolean} [options.dumpFallback]
    428   *   Fallback to dump data if read of local DB fails (default: `true`).
    429   * @param  {boolean} [options.emptyListFallback]
    430   *   Fallback to empty list if no dump data and read of local DB fails (default: `true`).
    431   * @param  {boolean} [options.loadDumpIfNewer]
    432   *   Use dump data if it is newer than local data (default: `true`).
    433   * @param  {boolean} [options.forceSync]
    434   *   Always synchronize from server before returning results (default: `false`).
    435   * @param  {boolean} [options.syncIfEmpty]
    436   *   Synchronize from server if local data is empty (default: `true`).
    437   * @param  {boolean} [options.verifySignature]
    438   *   Verify the signature of the local data (default: `false`).
    439   * @return {Promise<object[]>}
    440   */
    441  // eslint-disable-next-line complexity
    442  async get(options = {}) {
    443    const {
    444      filters = {},
    445      order = "", // not sorted by default.
    446      dumpFallback = true,
    447      emptyListFallback = true,
    448      loadDumpIfNewer = true,
    449    } = options;
    450 
    451    const hasLocalDump = await lazy.Utils.hasLocalDump(
    452      this.bucketName,
    453      this.collectionName
    454    );
    455    if (!hasLocalDump) {
    456      return [];
    457    }
    458    const forceSync = false;
    459    const syncIfEmpty = true;
    460    let verifySignature = false;
    461 
    462    const hasParallelCall = !!this._importingPromise;
    463    let data;
    464    try {
    465      let lastModified = forceSync ? null : await this.db.getLastModified();
    466      let hasLocalData = lastModified !== null;
    467 
    468      if (forceSync) {
    469        if (!this._importingPromise) {
    470          this._importingPromise = (async () => {
    471            await this.sync({ sendEvents: false, trigger: "forced" });
    472            return true; // No need to re-verify signature after sync.
    473          })();
    474        }
    475      } else if (!syncIfEmpty && !hasLocalData && !emptyListFallback) {
    476        // The local database is empty, we neither want to sync nor fallback to an empty list.
    477        throw new RemoteSettingsClient.EmptyDatabaseError(this.identifier);
    478      } else if (!syncIfEmpty && !hasLocalData && verifySignature) {
    479        // The local database is empty and we want to verify the signature.
    480        throw new RemoteSettingsClient.MissingSignatureError(this.identifier);
    481      } else if (syncIfEmpty && !hasLocalData) {
    482        // .get() was called before we had the chance to synchronize the local database.
    483        // We'll try to avoid returning an empty list.
    484        if (!this._importingPromise) {
    485          // Prevent parallel loading when .get() is called multiple times.
    486          this._importingPromise = (async () => {
    487            const importedFromDump = lazy.Utils.LOAD_DUMPS
    488              ? await this._importJSONDump()
    489              : -1;
    490            if (importedFromDump < 0) {
    491              // There is no JSON dump to load, force a synchronization from the server.
    492              // We don't want the "sync" event to be sent, since some consumers use `.get()`
    493              // in "sync" callbacks. See Bug 1761953
    494              lazy.console.debug(
    495                `${this.identifier} Local DB is empty, pull data from server`
    496              );
    497              const waitedAt = ChromeUtils.now();
    498              const pulled = await lazy.RemoteSettings.pullStartupBundle();
    499              // If collection is not part of startup bundle, then sync it individually.
    500              if (!pulled.includes(this.identifier)) {
    501                lazy.console.debug(
    502                  `${this.identifier} was not part of startup bundle. Force a sync`
    503                );
    504                await this.sync({ loadDump: false, sendEvents: false });
    505              }
    506              ChromeUtils.addProfilerMarker(
    507                "remote-settings:get:sync",
    508                waitedAt,
    509                "get() with syncIfEmpty"
    510              );
    511 
    512              const durationMilliseconds = ChromeUtils.now() - waitedAt;
    513              lazy.console.debug(
    514                `${this.identifier} Waited ${durationMilliseconds}ms for 'syncIfEmpty' in 'get()'`
    515              );
    516            }
    517            // Return `true` to indicate we don't need to `verifySignature`,
    518            // since a trusted dump was loaded or a signature verification
    519            // happened during synchronization.
    520            return true;
    521          })();
    522        } else {
    523          lazy.console.debug(`${this.identifier} Awaiting existing import.`);
    524        }
    525      } else if (hasLocalData && loadDumpIfNewer && lazy.Utils.LOAD_DUMPS) {
    526        // Check whether the local data is older than the packaged dump.
    527        // If it is and we are on production, load the packaged dump (which
    528        // overwrites the local data).
    529        let lastModifiedDump = await lazy.Utils.getLocalDumpLastModified(
    530          this.bucketName,
    531          this.collectionName
    532        );
    533        if (lastModified < lastModifiedDump) {
    534          lazy.console.debug(
    535            `${this.identifier} Local DB is stale (${lastModified}), using dump instead (${lastModifiedDump})`
    536          );
    537          if (!this._importingPromise) {
    538            // As part of importing, any existing data is wiped.
    539            this._importingPromise = (async () => {
    540              const importedFromDump = await this._importJSONDump();
    541              // Return `true` to skip signature verification if a dump was found.
    542              // The dump can be missing if a collection is listed in the timestamps summary file,
    543              // because its dump is present in the source tree, but the dump was not
    544              // included in the `package-manifest.in` file. (eg. android, thunderbird)
    545              return importedFromDump >= 0;
    546            })();
    547          } else {
    548            lazy.console.debug(`${this.identifier} Awaiting existing import.`);
    549          }
    550        }
    551      }
    552 
    553      if (this._importingPromise) {
    554        try {
    555          if (await this._importingPromise) {
    556            // No need to verify signature, because either we've just loaded a trusted
    557            // dump (here or in a parallel call), or it was verified during sync.
    558            verifySignature = false;
    559          }
    560        } catch (e) {
    561          if (!hasParallelCall) {
    562            // Sync or load dump failed. Throw.
    563            throw e;
    564          }
    565          // Report error, but continue because there could have been data
    566          // loaded from a parallel call.
    567          lazy.console.error(e);
    568        } finally {
    569          // then delete this promise again, as now we should have local data:
    570          delete this._importingPromise;
    571        }
    572      }
    573 
    574      // If there is no data for this collection, it will throw an error.
    575      data = await this.db.list({ filters, order });
    576    } catch (e) {
    577      // If the local DB is empty or cannot be read (for unknown reasons, Bug 1649393)
    578      // or sync failed, we fallback to the packaged data, and filter/sort in memory.
    579      if (!dumpFallback) {
    580        throw e;
    581      }
    582      if (
    583        e instanceof RemoteSettingsClient.EmptyDatabaseError &&
    584        emptyListFallback
    585      ) {
    586        // If consumer requested an empty list fallback, no need to raise attention if no data in DB.
    587        lazy.console.debug(e);
    588      } else {
    589        // Report error, and continue with trying to load the binary dump.
    590        lazy.console.error(e);
    591      }
    592      ({ data } = await lazy.SharedUtils.loadJSONDump(
    593        this.bucketName,
    594        this.collectionName
    595      ));
    596      if (data !== null) {
    597        lazy.console.info(`${this.identifier} falling back to JSON dump`);
    598      } else if (emptyListFallback) {
    599        lazy.console.info(
    600          `${this.identifier} no dump fallback, return empty list`
    601        );
    602        data = [];
    603      } else {
    604        // Obtaining the records failed, there is no dump, and we don't fallback
    605        // to an empty list. Throw the original error.
    606        throw e;
    607      }
    608      if (!lazy.ObjectUtils.isEmpty(filters)) {
    609        data = data.filter(r => lazy.Utils.filterObject(filters, r));
    610      }
    611      if (order) {
    612        data = lazy.Utils.sortObjects(order, data);
    613      }
    614      // No need to verify signature on JSON dumps.
    615      // If local DB cannot be read, then we don't even try to do anything,
    616      // we return results early.
    617      return this._filterEntries(data);
    618    }
    619 
    620    if (this.verifySignature && verifySignature) {
    621      lazy.console.debug(
    622        `${this.identifier} verify signature of local data on read`
    623      );
    624      const allData = lazy.ObjectUtils.isEmpty(filters)
    625        ? data
    626        : await this.db.list();
    627      const localRecords = allData.map(r => this._cleanLocalFields(r));
    628      const timestamp = await this.db.getLastModified();
    629      let metadata = await this.db.getMetadata();
    630      if (syncIfEmpty && lazy.ObjectUtils.isEmpty(metadata)) {
    631        // No sync occured yet, may have records from dump but no metadata.
    632        // We don't want the "sync" event to be sent, since some consumers use `.get()`
    633        // in "sync" callbacks. See Bug 1761953
    634        await this.sync({ loadDump: false, sendEvents: false });
    635        metadata = await this.db.getMetadata();
    636      }
    637      // Will throw MissingSignatureError if no metadata and `syncIfEmpty` is false.
    638      await this.validateCollectionSignature(localRecords, timestamp, metadata);
    639    }
    640 
    641    // Filter the records based on `this.filterCreator` results.
    642    const final = await this._filterEntries(data);
    643    if (final.length != data.length) {
    644      lazy.console.debug(
    645        `${this.identifier} ${final.length}/${data.length} records after filtering.`
    646      );
    647    } else {
    648      lazy.console.debug(`${this.identifier} ${data.length} records.`);
    649    }
    650    return final;
    651  }
    652 
    653  /**
    654   * Synchronize the local database with the remote server.
    655   *
    656   * @param {object} options See #maybeSync() options.
    657   */
    658  async sync(options) {
    659    if (AppConstants.BASE_BROWSER_VERSION) {
    660      return;
    661    }
    662 
    663    if (lazy.Utils.shouldSkipRemoteActivityDueToTests) {
    664      lazy.console.debug(`${this.identifier} Skip sync() due to tests.`);
    665      return;
    666    }
    667 
    668    // We want to know which timestamp we are expected to obtain in order to leverage
    669    // cache busting. We don't provide ETag because we don't want a 304.
    670    const { changes } = await lazy.Utils.fetchLatestChanges(
    671      lazy.Utils.SERVER_URL,
    672      {
    673        filters: {
    674          collection: this.collectionName,
    675          bucket: this.bucketName,
    676        },
    677      }
    678    );
    679    if (changes.length === 0) {
    680      throw new RemoteSettingsClient.UnknownCollectionError(this.identifier);
    681    }
    682    // According to API, there will be one only (fail if not).
    683    const [{ last_modified: expectedTimestamp }] = changes;
    684 
    685    await this.maybeSync(expectedTimestamp, { ...options, trigger: "forced" });
    686  }
    687 
    688  /**
    689   * Synchronize the local database with the remote server, **only if necessary**.
    690   *
    691   * @param {number} expectedTimestamp
    692   *   The lastModified date (on the server) for the remote collection. This will
    693   *   be compared to the local timestamp, and will be used for cache busting if
    694   *   local data is out of date.
    695   * @param {object} [options]
    696   *   additional advanced options.
    697   * @param {boolean} [options.loadDump]
    698   *   load initial dump from disk on first sync (default: true if server is prod)
    699   * @param {boolean} [options.sendEvents]
    700   *   send `"sync"` events (default: `true`)
    701   * @param {string} [options.trigger]
    702   *   label to identify what triggered this sync (eg. ``"timer"``, default: `"manual"`)
    703   * @return {Promise<void>}
    704   *   which rejects on sync or process failure.
    705   */
    706  // eslint-disable-next-line complexity
    707  async maybeSync(expectedTimestamp, options = {}) {
    708    // Should the clients try to load JSON dump? (mainly disabled in tests)
    709    const {
    710      loadDump = lazy.Utils.LOAD_DUMPS,
    711      trigger = "manual",
    712      sendEvents = true,
    713    } = options;
    714 
    715    // Make sure we don't run several synchronizations in parallel, mainly
    716    // in order to avoid race conditions in "sync" events listeners.
    717    if (this._syncRunning) {
    718      lazy.console.warn(`${this.identifier} sync already running`);
    719      return;
    720    }
    721 
    722    // Prevent network requests and IndexedDB calls to be initiated
    723    // during shutdown.
    724    if (Services.startup.shuttingDown) {
    725      lazy.console.warn(`${this.identifier} sync interrupted by shutdown`);
    726      return;
    727    }
    728 
    729    this._syncRunning = true;
    730 
    731    await lazy.UptakeTelemetry.report(
    732      TELEMETRY_COMPONENT,
    733      lazy.UptakeTelemetry.STATUS.SYNC_START,
    734      {
    735        source: this.identifier,
    736        trigger,
    737      }
    738    );
    739 
    740    let importedFromDump = [];
    741    const startedAt = new Date();
    742    let reportStatus = null;
    743    let thrownError = null;
    744    try {
    745      // If network is offline, we can't synchronize.
    746      if (!AppConstants.BASE_BROWSER_VERSION && lazy.Utils.isOffline) {
    747        throw new RemoteSettingsClient.NetworkOfflineError();
    748      }
    749 
    750      // Read last timestamp and local data before sync.
    751      let collectionLastModified = await this.db.getLastModified();
    752      const hasLocalData = collectionLastModified !== null;
    753      // Local data can contain local fields, strip them.
    754      let localRecords = hasLocalData
    755        ? (await this.db.list()).map(r => this._cleanLocalFields(r))
    756        : null;
    757      const localMetadata = await this.db.getMetadata();
    758 
    759      // If there is no data currently in the collection, attempt to import
    760      // initial data from the application defaults.
    761      // This allows to avoid synchronizing the whole collection content on
    762      // cold start.
    763      if (!hasLocalData && loadDump) {
    764        try {
    765          const imported = await this._importJSONDump();
    766          // The worker only returns an integer. List the imported records to build the sync event.
    767          if (imported > 0) {
    768            lazy.console.debug(
    769              `${this.identifier} ${imported} records loaded from JSON dump`
    770            );
    771            importedFromDump = await this.db.list();
    772            // Local data is the data loaded from dump. We will need this later
    773            // to compute the sync result.
    774            localRecords = importedFromDump;
    775          }
    776          collectionLastModified = await this.db.getLastModified();
    777        } catch (e) {
    778          // Report but go-on.
    779          console.error(e);
    780        }
    781      }
    782      let syncResult;
    783      try {
    784        // Is local timestamp up to date with the server?
    785        if (expectedTimestamp == collectionLastModified) {
    786          lazy.console.debug(`${this.identifier} local data is up-to-date`);
    787          reportStatus = lazy.UptakeTelemetry.STATUS.UP_TO_DATE;
    788 
    789          // If the data is up-to-date but don't have metadata (records loaded from dump),
    790          // we fetch them and validate the signature immediately.
    791          if (this.verifySignature && lazy.ObjectUtils.isEmpty(localMetadata)) {
    792            lazy.console.debug(`${this.identifier} pull collection metadata`);
    793            const metadata = await this.httpClient().getData({
    794              query: { _expected: expectedTimestamp },
    795            });
    796            await this.db.importChanges(metadata);
    797            // We don't bother validating the signature if the dump was just loaded. We do
    798            // if the dump was loaded at some other point (eg. from .get()).
    799            if (this.verifySignature && !importedFromDump.length) {
    800              lazy.console.debug(
    801                `${this.identifier} verify signature of local data`
    802              );
    803              await this.validateCollectionSignature(
    804                localRecords,
    805                collectionLastModified,
    806                metadata
    807              );
    808            }
    809          }
    810 
    811          // Since the data is up-to-date, if we didn't load any dump then we're done here.
    812          if (!importedFromDump.length) {
    813            return;
    814          }
    815          // Otherwise we want to continue with sending the sync event to notify about the created records.
    816          syncResult = {
    817            current: importedFromDump,
    818            created: importedFromDump,
    819            updated: [],
    820            deleted: [],
    821          };
    822        } else {
    823          // Local data is either outdated or tampered.
    824          // In both cases we will fetch changes from server,
    825          // and make sure we overwrite local data.
    826          syncResult = await this._importChanges(
    827            localRecords,
    828            collectionLastModified,
    829            localMetadata,
    830            expectedTimestamp
    831          );
    832          if (sendEvents && this.hasListeners("sync")) {
    833            // If we have listeners for the "sync" event, then compute the lists of changes.
    834            // The records imported from the dump should be considered as "created" for the
    835            // listeners.
    836            const importedById = importedFromDump.reduce((acc, r) => {
    837              acc.set(r.id, r);
    838              return acc;
    839            }, new Map());
    840            // Deleted records should not appear as created.
    841            syncResult.deleted.forEach(r => importedById.delete(r.id));
    842            // Records from dump that were updated should appear in their newest form.
    843            syncResult.updated.forEach(u => {
    844              if (importedById.has(u.old.id)) {
    845                importedById.set(u.old.id, u.new);
    846              }
    847            });
    848            syncResult.created = syncResult.created.concat(
    849              Array.from(importedById.values())
    850            );
    851          }
    852 
    853          // When triggered from the daily timer, and if the sync was successful, and once
    854          // all sync listeners have been executed successfully, we prune potential
    855          // obsolete attachments that may have been left in the local cache.
    856          if (trigger == "timer") {
    857            const deleted = await this.attachments.prune(
    858              this.keepAttachmentsIds
    859            );
    860            if (deleted > 0) {
    861              lazy.console.warn(
    862                `${this.identifier} Pruned ${deleted} obsolete attachments`
    863              );
    864            }
    865          }
    866        }
    867      } catch (e) {
    868        if (e instanceof InvalidSignatureError) {
    869          // Signature verification failed during synchronization.
    870          reportStatus =
    871            e instanceof CorruptedDataError
    872              ? lazy.UptakeTelemetry.STATUS.CORRUPTION_ERROR
    873              : lazy.UptakeTelemetry.STATUS.SIGNATURE_ERROR;
    874          // If sync fails with a signature error, it's likely that our
    875          // local data has been modified in some way.
    876          // We will attempt to fix this by retrieving the whole
    877          // remote collection.
    878          try {
    879            lazy.console.warn(
    880              `${this.identifier} Signature verified failed. Retry from scratch`
    881            );
    882            syncResult = await this._importChanges(
    883              localRecords,
    884              collectionLastModified,
    885              localMetadata,
    886              expectedTimestamp,
    887              { retry: true }
    888            );
    889          } catch (ex) {
    890            // If the signature fails again, or if an error occured during wiping out the
    891            // local data, then we report it as a *signature retry* error.
    892            reportStatus = lazy.UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
    893            throw ex;
    894          }
    895        } else {
    896          // The sync has thrown for other reason than signature verification.
    897          // Obtain a more precise error than original one.
    898          const adjustedError = this._adjustedError(e);
    899          // Default status for errors at this step is SYNC_ERROR.
    900          reportStatus = this._telemetryFromError(adjustedError, {
    901            default: lazy.UptakeTelemetry.STATUS.SYNC_ERROR,
    902          });
    903          throw adjustedError;
    904        }
    905      }
    906      if (sendEvents) {
    907        // Filter the synchronization results using `filterCreator` (ie. JEXL).
    908        const filteredSyncResult = await this._filterSyncResult(syncResult);
    909        // If every changed entry is filtered, we don't even fire the event.
    910        if (filteredSyncResult) {
    911          try {
    912            await this.emit("sync", { data: filteredSyncResult });
    913          } catch (e) {
    914            reportStatus = lazy.UptakeTelemetry.STATUS.APPLY_ERROR;
    915            throw e;
    916          }
    917        } else {
    918          // Check if `syncResult` had changes before filtering to adjust logging message.
    919          const wasFiltered =
    920            syncResult.created.length +
    921              syncResult.updated.length +
    922              syncResult.deleted.length >
    923            0;
    924          if (wasFiltered) {
    925            lazy.console.info(
    926              `${this.identifier} All sync changes are filtered by JEXL expressions`
    927            );
    928          } else {
    929            lazy.console.info(`${this.identifier} No changes during sync`);
    930          }
    931        }
    932      }
    933    } catch (e) {
    934      thrownError = e;
    935      // Obtain a more precise error than original one.
    936      const adjustedError = this._adjustedError(e);
    937      // If browser is shutting down, then we can report a specific status.
    938      // (eg. IndexedDB will abort transactions)
    939      if (Services.startup.shuttingDown) {
    940        reportStatus = lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
    941      }
    942      // If no Telemetry status was determined yet (ie. outside sync step),
    943      // then introspect error, default status at this step is UNKNOWN.
    944      else if (reportStatus == null) {
    945        reportStatus = this._telemetryFromError(adjustedError, {
    946          default: lazy.UptakeTelemetry.STATUS.UNKNOWN_ERROR,
    947        });
    948      }
    949      throw e;
    950    } finally {
    951      const durationMilliseconds = new Date() - startedAt;
    952      // No error was reported, this is a success!
    953      if (reportStatus === null) {
    954        reportStatus = lazy.UptakeTelemetry.STATUS.SUCCESS;
    955      }
    956      // Report success/error status to Telemetry.
    957      let reportArgs = {
    958        source: this.identifier,
    959        trigger,
    960        duration: durationMilliseconds,
    961      };
    962      // In Bug 1617133, we will try to break down specific errors into
    963      // more precise statuses by reporting the JavaScript error name
    964      // ("TypeError", etc.) to Telemetry.
    965      if (
    966        thrownError !== null &&
    967        [
    968          lazy.UptakeTelemetry.STATUS.SYNC_ERROR,
    969          lazy.UptakeTelemetry.STATUS.CUSTOM_1_ERROR, // IndexedDB.
    970          lazy.UptakeTelemetry.STATUS.UNKNOWN_ERROR,
    971          lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR,
    972        ].includes(reportStatus)
    973      ) {
    974        // List of possible error names for IndexedDB:
    975        // https://searchfox.org/mozilla-central/rev/49ed791/dom/base/DOMException.cpp#28-53
    976        reportArgs = { ...reportArgs, errorName: thrownError.name };
    977      }
    978 
    979      await lazy.UptakeTelemetry.report(
    980        TELEMETRY_COMPONENT,
    981        reportStatus,
    982        reportArgs
    983      );
    984 
    985      lazy.console.debug(`${this.identifier} sync status is ${reportStatus}`);
    986      this._syncRunning = false;
    987    }
    988  }
    989 
    990  /**
    991   * Return a more precise error instance, based on the specified
    992   * error and its message.
    993   *
    994   * @param {Error} e the original error
    995   * @returns {Error}
    996   */
    997  _adjustedError(e) {
    998    if (/unparseable/.test(e.message)) {
    999      return new RemoteSettingsClient.ServerContentParseError(e);
   1000    }
   1001    if (/NetworkError/.test(e.message)) {
   1002      return new RemoteSettingsClient.NetworkError(e);
   1003    }
   1004    if (/Timeout/.test(e.message)) {
   1005      return new RemoteSettingsClient.TimeoutError(e);
   1006    }
   1007    if (/HTTP 5??/.test(e.message)) {
   1008      return new RemoteSettingsClient.BackendError(e);
   1009    }
   1010    if (/Backoff/.test(e.message)) {
   1011      return new RemoteSettingsClient.BackoffError(e);
   1012    }
   1013    if (
   1014      // Errors from kinto.js IDB adapter.
   1015      e instanceof lazy.IDBHelpers.IndexedDBError ||
   1016      // Other IndexedDB errors (eg. RemoteSettingsWorker).
   1017      /IndexedDB/.test(e.message)
   1018    ) {
   1019      return new RemoteSettingsClient.StorageError(e);
   1020    }
   1021    return e;
   1022  }
   1023 
   1024  /**
   1025   * Determine the Telemetry uptake status based on the specified
   1026   * error.
   1027   */
   1028  _telemetryFromError(e, options = { default: null }) {
   1029    let reportStatus = options.default;
   1030 
   1031    if (e instanceof RemoteSettingsClient.NetworkOfflineError) {
   1032      reportStatus = lazy.UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
   1033    } else if (e instanceof lazy.IDBHelpers.ShutdownError) {
   1034      reportStatus = lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
   1035    } else if (e instanceof RemoteSettingsClient.ServerContentParseError) {
   1036      reportStatus = lazy.UptakeTelemetry.STATUS.PARSE_ERROR;
   1037    } else if (e instanceof RemoteSettingsClient.NetworkError) {
   1038      reportStatus = lazy.UptakeTelemetry.STATUS.NETWORK_ERROR;
   1039    } else if (e instanceof RemoteSettingsClient.TimeoutError) {
   1040      reportStatus = lazy.UptakeTelemetry.STATUS.TIMEOUT_ERROR;
   1041    } else if (e instanceof RemoteSettingsClient.BackendError) {
   1042      reportStatus = lazy.UptakeTelemetry.STATUS.SERVER_ERROR;
   1043    } else if (e instanceof RemoteSettingsClient.BackoffError) {
   1044      reportStatus = lazy.UptakeTelemetry.STATUS.BACKOFF;
   1045    } else if (e instanceof RemoteSettingsClient.StorageError) {
   1046      reportStatus = lazy.UptakeTelemetry.STATUS.CUSTOM_1_ERROR;
   1047    }
   1048 
   1049    return reportStatus;
   1050  }
   1051 
   1052  /**
   1053   * Import the JSON files from services/settings/dump into the local DB.
   1054   */
   1055  async _importJSONDump() {
   1056    lazy.console.info(`${this.identifier} try to restore dump`);
   1057    const result = await lazy.RemoteSettingsWorker.importJSONDump(
   1058      this.bucketName,
   1059      this.collectionName
   1060    );
   1061    if (result < 0) {
   1062      lazy.console.debug(`${this.identifier} no dump available`);
   1063    } else {
   1064      lazy.console.info(
   1065        `${this.identifier} imported ${result} records from dump`
   1066      );
   1067    }
   1068    return result;
   1069  }
   1070 
   1071  /**
   1072   * Fetch the signature info from the collection metadata and verifies that the
   1073   * local set of records has the same.
   1074   *
   1075   * @param {object[]} records
   1076   *   The list of records to validate.
   1077   * @param {number} timestamp
   1078   *   The timestamp associated with the list of remote records.
   1079   * @param {object} metadata
   1080   *   The collection metadata, that contains the signature payload.
   1081   */
   1082  async validateCollectionSignature(records, timestamp, metadata) {
   1083    if (
   1084      !metadata?.signatures ||
   1085      !Array.isArray(metadata.signatures) ||
   1086      metadata.signatures.length === 0
   1087    ) {
   1088      throw new MissingSignatureError(this.identifier);
   1089    }
   1090 
   1091    if (!this._verifier) {
   1092      this._verifier = Cc[
   1093        "@mozilla.org/security/contentsignatureverifier;1"
   1094      ].createInstance(Ci.nsIContentSignatureVerifier);
   1095    }
   1096 
   1097    // Merge remote records with local ones and serialize as canonical JSON.
   1098    const serialized = await lazy.RemoteSettingsWorker.canonicalStringify(
   1099      records,
   1100      timestamp
   1101    );
   1102 
   1103    // Iterate over the list of signatures until we find a valid one.
   1104    const thrownErrors = [];
   1105    const { signatures } = metadata;
   1106    for (const sig of signatures) {
   1107      // This is a content-signature field from an autograph response.
   1108      const { x5u, signature, mode } = sig;
   1109      if (!x5u || !signature || (mode && mode !== "p384ecdsa")) {
   1110        lazy.console.warn(
   1111          `${this.identifier} ignore unsupported signature entry in metadata`
   1112        );
   1113        continue;
   1114      }
   1115      const certChain = await (await lazy.Utils.fetch(x5u)).text();
   1116      lazy.console.debug(`${this.identifier} verify signature using ${x5u}`);
   1117 
   1118      if (
   1119        await this._verifier.asyncVerifyContentSignature(
   1120          serialized,
   1121          "p384ecdsa=" + signature,
   1122          certChain,
   1123          this.signerName,
   1124          lazy.Utils.CERT_CHAIN_ROOT_IDENTIFIER
   1125        )
   1126      ) {
   1127        // Signature is valid, exit!
   1128        return;
   1129      }
   1130      // Try next signature.
   1131      thrownErrors.push(
   1132        new InvalidSignatureError(this.identifier, x5u, this.signerName)
   1133      );
   1134    }
   1135 
   1136    // We now that the list of signature is not empty, so if we are here
   1137    // it means that none was valid.
   1138    throw thrownErrors[0];
   1139  }
   1140 
   1141  /**
   1142   * This method is in charge of fetching data from server, applying the diff-based
   1143   * changes to the local DB, validating the signature, and computing a synchronization
   1144   * result with the list of creation, updates, and deletions.
   1145   *
   1146   * @param {object[]} localRecords
   1147   *   Current list of records in local DB.
   1148   * @param {number} localTimestamp
   1149   *   Current timestamp in local DB.
   1150   * @param {object} localMetadata
   1151   *   Current metadata in local DB.
   1152   * @param {number} expectedTimestamp
   1153   *   Cache busting of collection metadata
   1154   * @param {object} [options]
   1155   * @param {boolean} [options.retry]
   1156   *   Whether this method is called in the retry situation.
   1157   */
   1158  async _importChanges(
   1159    localRecords,
   1160    localTimestamp,
   1161    localMetadata,
   1162    expectedTimestamp,
   1163    options = {}
   1164  ) {
   1165    const hasLocalData = localTimestamp !== null;
   1166    const { retry = false } = options;
   1167 
   1168    // Define an executor that will verify the signature of the local data.
   1169    const verifySignatureLocalData = (resolve, reject) => {
   1170      if (!hasLocalData) {
   1171        resolve(false);
   1172        return;
   1173      }
   1174      lazy.console.debug(
   1175        `${this.identifier} verify local data before importing remote`
   1176      );
   1177      this.validateCollectionSignature(
   1178        localRecords,
   1179        localTimestamp,
   1180        localMetadata
   1181      )
   1182        .then(() => resolve(true))
   1183        .catch(err => {
   1184          if (err instanceof InvalidSignatureError) {
   1185            lazy.console.debug(`${this.identifier} previous data was invalid`);
   1186            resolve(false);
   1187          } else {
   1188            // If it fails for other reason, keep original error and give up.
   1189            reject(err);
   1190          }
   1191        });
   1192    };
   1193 
   1194    let metadata, remoteTimestamp;
   1195 
   1196    try {
   1197      await this._importJSONDump();
   1198    } catch (e) {
   1199      return {
   1200        current: localRecords,
   1201        created: [],
   1202        updated: [],
   1203        deleted: [],
   1204      };
   1205    }
   1206 
   1207    // Read the new local data, after updating.
   1208    const newLocal = await this.db.list();
   1209    const newRecords = newLocal.map(r => this._cleanLocalFields(r));
   1210    // And verify the signature on what is now stored.
   1211    if (metadata === undefined) {
   1212      // When working only with dumps, we do not have signatures.
   1213    } else if (this.verifySignature) {
   1214      try {
   1215        await this.validateCollectionSignature(
   1216          newRecords,
   1217          remoteTimestamp,
   1218          metadata
   1219        );
   1220      } catch (e) {
   1221        lazy.console.error(
   1222          `${this.identifier} Signature failed ${retry ? "again" : ""} ${e}`
   1223        );
   1224        if (!(e instanceof InvalidSignatureError)) {
   1225          // If it failed for any other kind of error (eg. shutdown)
   1226          // then give up quickly.
   1227          throw e;
   1228        }
   1229 
   1230        // In order to distinguish signature errors that happen
   1231        // during sync, from hijacks of local DBs, we will verify
   1232        // the signature on the data that we had before syncing
   1233        // (if any).
   1234        if (!hasLocalData) {
   1235          lazy.console.debug(`${this.identifier} No previous data to restore`);
   1236        }
   1237        const localTrustworthy =
   1238          hasLocalData && (await new Promise(verifySignatureLocalData));
   1239        if (!localTrustworthy && !retry) {
   1240          // Signature failed, clear local DB because it contains
   1241          // bad data (local + remote changes).
   1242          lazy.console.debug(`${this.identifier} clear local data`);
   1243          await this.db.clear();
   1244          // Local data was tampered, throw and it will retry from empty DB.
   1245          lazy.console.error(`${this.identifier} local data was corrupted`);
   1246          throw new CorruptedDataError(this.identifier);
   1247        } else if (retry) {
   1248          // We retried already, we will restore the previous local data
   1249          // before throwing eventually.
   1250          if (localTrustworthy) {
   1251            await this.db.importChanges(
   1252              localMetadata,
   1253              localTimestamp,
   1254              localRecords,
   1255              {
   1256                clear: true, // clear before importing.
   1257              }
   1258            );
   1259          } else {
   1260            // Restore the dump if available (no-op if no dump)
   1261            const imported = await this._importJSONDump();
   1262            // _importJSONDump() only clears DB if dump is available,
   1263            // therefore do it here!
   1264            if (imported < 0) {
   1265              await this.db.clear();
   1266            }
   1267          }
   1268        }
   1269        throw e;
   1270      }
   1271    } else {
   1272      lazy.console.warn(`${this.identifier} has signature disabled`);
   1273    }
   1274 
   1275    // We build a sync result, based on remote changes.
   1276    const syncResult = {
   1277      current: localRecords,
   1278      created: [],
   1279      updated: [],
   1280      deleted: [],
   1281    };
   1282    if (this.hasListeners("sync")) {
   1283      // If we have some listeners for the "sync" event,
   1284      // Compute the changes, comparing records before and after.
   1285      syncResult.current = newRecords;
   1286      const oldById = hasLocalData
   1287        ? new Map(localRecords.map(e => [e.id, e]))
   1288        : new Map();
   1289      for (const r of newRecords) {
   1290        const old = oldById.get(r.id);
   1291        if (old) {
   1292          oldById.delete(r.id);
   1293          if (r.last_modified != old.last_modified) {
   1294            syncResult.updated.push({ old, new: r });
   1295          }
   1296        } else {
   1297          syncResult.created.push(r);
   1298        }
   1299      }
   1300      syncResult.deleted = syncResult.deleted.concat(
   1301        Array.from(oldById.values())
   1302      );
   1303      lazy.console.debug(
   1304        `${this.identifier} ${syncResult.created.length} created. ${syncResult.updated.length} updated. ${syncResult.deleted.length} deleted.`
   1305      );
   1306    }
   1307 
   1308    return syncResult;
   1309  }
   1310 
   1311  /**
   1312   * Fetch information from changeset endpoint.
   1313   *
   1314   * @param expectedTimestamp cache busting value
   1315   * @param since timestamp of last sync (optional)
   1316   */
   1317  async _fetchChangeset(expectedTimestamp, since) {
   1318    const client = this.httpClient();
   1319    const {
   1320      metadata,
   1321      timestamp: remoteTimestamp,
   1322      changes: remoteRecords,
   1323    } = await client.execute(
   1324      {
   1325        path: `/buckets/${this.bucketName}/collections/${this.collectionName}/changeset`,
   1326      },
   1327      {
   1328        query: {
   1329          _expected: expectedTimestamp,
   1330          _since: since,
   1331        },
   1332      }
   1333    );
   1334    return {
   1335      remoteTimestamp,
   1336      metadata,
   1337      remoteRecords,
   1338    };
   1339  }
   1340 
   1341  /**
   1342   * Use the filter func to filter the lists of changes obtained from synchronization,
   1343   * and return them along with the filtered list of local records.
   1344   *
   1345   * If the filtered lists of changes are all empty, we return null (and thus don't
   1346   * bother listing local DB).
   1347   *
   1348   * @param {object}     syncResult       Synchronization result without filtering.
   1349   *
   1350   * @returns {Promise<object>} the filtered list of local records, plus the filtered
   1351   *                            list of created, updated and deleted records.
   1352   */
   1353  async _filterSyncResult(syncResult) {
   1354    // Handle the obtained records (ie. apply locally through events).
   1355    // Build the event data list. It should be filtered (ie. by application target)
   1356    const {
   1357      current: allData,
   1358      created: allCreated,
   1359      updated: allUpdated,
   1360      deleted: allDeleted,
   1361    } = syncResult;
   1362    const [created, deleted, updatedFiltered] = await Promise.all(
   1363      [allCreated, allDeleted, allUpdated.map(e => e.new)].map(
   1364        this._filterEntries.bind(this)
   1365      )
   1366    );
   1367    // For updates, keep entries whose updated form matches the target.
   1368    const updatedFilteredIds = new Set(updatedFiltered.map(e => e.id));
   1369    const updated = allUpdated.filter(({ new: { id } }) =>
   1370      updatedFilteredIds.has(id)
   1371    );
   1372 
   1373    if (!created.length && !updated.length && !deleted.length) {
   1374      return null;
   1375    }
   1376    // Read local collection of records (also filtered).
   1377    const current = await this._filterEntries(allData);
   1378    return { created, updated, deleted, current };
   1379  }
   1380 
   1381  /**
   1382   * Filter entries for which calls to the filter's `filterEntry` method
   1383   * return null.
   1384   *
   1385   * @param {object[]} data
   1386   * @returns {Promise<object[]>}
   1387   */
   1388  async _filterEntries(data) {
   1389    if (!this.filterCreator) {
   1390      return data;
   1391    }
   1392    const filter = await this.filterCreator(
   1393      lazy.ClientEnvironmentBase,
   1394      this.identifier
   1395    );
   1396    const results = [];
   1397    for (const entry of data) {
   1398      const filteredEntry = await filter.filterEntry(entry);
   1399      if (filteredEntry) {
   1400        results.push(filteredEntry);
   1401      }
   1402    }
   1403    return results;
   1404  }
   1405 
   1406  /**
   1407   * Remove the fields from the specified record
   1408   * that are not present on server.
   1409   *
   1410   * @param {object} record
   1411   */
   1412  _cleanLocalFields(record) {
   1413    const keys = ["_status"].concat(this.localFields);
   1414    const result = { ...record };
   1415    for (const key of keys) {
   1416      delete result[key];
   1417    }
   1418    return result;
   1419  }
   1420 }