Bug 1746711 Part 2: Ensure the enqueued surface has a color space. r=gfx-reviewers...
[gecko.git] / services / settings / RemoteSettingsClient.jsm
blobbd3fddbcf21c5c30e8702fffc45c7949d8e04333
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/. */
5 "use strict";
7 var EXPORTED_SYMBOLS = ["RemoteSettingsClient"];
9 const { XPCOMUtils } = ChromeUtils.import(
10   "resource://gre/modules/XPCOMUtils.jsm"
12 const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
14 XPCOMUtils.defineLazyModuleGetters(this, {
15   AppConstants: "resource://gre/modules/AppConstants.jsm",
16   ClientEnvironmentBase:
17     "resource://gre/modules/components-utils/ClientEnvironment.jsm",
18   Database: "resource://services-settings/Database.jsm",
19   Downloader: "resource://services-settings/Attachments.jsm",
20   IDBHelpers: "resource://services-settings/IDBHelpers.jsm",
21   KintoHttpClient: "resource://services-common/kinto-http-client.js",
22   ObjectUtils: "resource://gre/modules/ObjectUtils.jsm",
23   PerformanceCounters: "resource://gre/modules/PerformanceCounters.jsm",
24   RemoteSettingsWorker: "resource://services-settings/RemoteSettingsWorker.jsm",
25   SharedUtils: "resource://services-settings/SharedUtils.jsm",
26   UptakeTelemetry: "resource://services-common/uptake-telemetry.js",
27   Utils: "resource://services-settings/Utils.jsm",
28 });
30 const TELEMETRY_COMPONENT = "remotesettings";
32 XPCOMUtils.defineLazyGetter(this, "console", () => Utils.log);
34 XPCOMUtils.defineLazyPreferenceGetter(
35   this,
36   "gTimingEnabled",
37   "services.settings.enablePerformanceCounters",
38   false
41 XPCOMUtils.defineLazyPreferenceGetter(
42   this,
43   "gLoadDump",
44   "services.settings.load_dump",
45   true
48 /**
49  * cacheProxy returns an object Proxy that will memoize properties of the target.
50  * @param {Object} target the object to wrap.
51  * @returns {Proxy}
52  */
53 function cacheProxy(target) {
54   const cache = new Map();
55   return new Proxy(target, {
56     get(target, prop, receiver) {
57       if (!cache.has(prop)) {
58         cache.set(prop, target[prop]);
59       }
60       return cache.get(prop);
61     },
62   });
65 /**
66  * Minimalist event emitter.
67  *
68  * Note: we don't use `toolkit/modules/EventEmitter` because **we want** to throw
69  * an error when a listener fails to execute.
70  */
71 class EventEmitter {
72   constructor(events) {
73     this._listeners = new Map();
74     for (const event of events) {
75       this._listeners.set(event, []);
76     }
77   }
79   /**
80    * Event emitter: will execute the registered listeners in the order and
81    * sequentially.
82    *
83    * @param {string} event    the event name
84    * @param {Object} payload  the event payload to call the listeners with
85    */
86   async emit(event, payload) {
87     const callbacks = this._listeners.get(event);
88     let lastError;
89     for (const cb of callbacks) {
90       try {
91         await cb(payload);
92       } catch (e) {
93         lastError = e;
94       }
95     }
96     if (lastError) {
97       throw lastError;
98     }
99   }
101   hasListeners(event) {
102     return this._listeners.has(event) && this._listeners.get(event).length > 0;
103   }
105   on(event, callback) {
106     if (!this._listeners.has(event)) {
107       throw new Error(`Unknown event type ${event}`);
108     }
109     this._listeners.get(event).push(callback);
110   }
112   off(event, callback) {
113     if (!this._listeners.has(event)) {
114       throw new Error(`Unknown event type ${event}`);
115     }
116     const callbacks = this._listeners.get(event);
117     const i = callbacks.indexOf(callback);
118     if (i < 0) {
119       throw new Error(`Unknown callback`);
120     } else {
121       callbacks.splice(i, 1);
122     }
123   }
126 class NetworkOfflineError extends Error {
127   constructor(cid) {
128     super("Network is offline");
129     this.name = "NetworkOfflineError";
130   }
133 class InvalidSignatureError extends Error {
134   constructor(cid) {
135     super(`Invalid content signature (${cid})`);
136     this.name = "InvalidSignatureError";
137   }
140 class MissingSignatureError extends InvalidSignatureError {
141   constructor(cid) {
142     super(cid);
143     this.message = `Missing signature (${cid})`;
144     this.name = "MissingSignatureError";
145   }
148 class CorruptedDataError extends InvalidSignatureError {
149   constructor(cid) {
150     super(cid);
151     this.message = `Corrupted local data (${cid})`;
152     this.name = "CorruptedDataError";
153   }
156 class UnknownCollectionError extends Error {
157   constructor(cid) {
158     super(`Unknown Collection "${cid}"`);
159     this.name = "UnknownCollectionError";
160   }
163 class AttachmentDownloader extends Downloader {
164   constructor(client) {
165     super(client.bucketName, client.collectionName);
166     this._client = client;
167   }
169   get cacheImpl() {
170     const cacheImpl = {
171       get: async attachmentId => {
172         return this._client.db.getAttachment(attachmentId);
173       },
174       set: async (attachmentId, attachment) => {
175         return this._client.db.saveAttachment(attachmentId, attachment);
176       },
177       delete: async attachmentId => {
178         return this._client.db.saveAttachment(attachmentId, null);
179       },
180     };
181     Object.defineProperty(this, "cacheImpl", { value: cacheImpl });
182     return cacheImpl;
183   }
185   /**
186    * Download attachment and report Telemetry on failure.
187    *
188    * @see Downloader.download
189    */
190   async download(record, options) {
191     try {
192       // Explicitly await here to ensure we catch a network error.
193       return await super.download(record, options);
194     } catch (err) {
195       // Report download error.
196       let status = UptakeTelemetry.STATUS.DOWNLOAD_ERROR;
197       if (Utils.isOffline) {
198         status = UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
199       } else if (/NetworkError/.test(err.message)) {
200         status = UptakeTelemetry.STATUS.NETWORK_ERROR;
201       }
202       // If the file failed to be downloaded, report it as such in Telemetry.
203       await UptakeTelemetry.report(TELEMETRY_COMPONENT, status, {
204         source: this._client.identifier,
205       });
206       throw err;
207     }
208   }
210   /**
211    * Delete all downloaded records attachments.
212    *
213    * Note: the list of attachments to be deleted is based on the
214    * current list of records.
215    */
216   async deleteAll() {
217     let allRecords = await this._client.db.list();
218     return Promise.all(
219       allRecords.filter(r => !!r.attachment).map(r => this.delete(r))
220     );
221   }
224 class RemoteSettingsClient extends EventEmitter {
225   static get NetworkOfflineError() {
226     return NetworkOfflineError;
227   }
228   static get InvalidSignatureError() {
229     return InvalidSignatureError;
230   }
231   static get MissingSignatureError() {
232     return MissingSignatureError;
233   }
234   static get CorruptedDataError() {
235     return CorruptedDataError;
236   }
237   static get UnknownCollectionError() {
238     return UnknownCollectionError;
239   }
241   constructor(
242     collectionName,
243     {
244       bucketName,
245       bucketNamePref,
246       signerName,
247       filterFunc,
248       localFields = [],
249       lastCheckTimePref,
250     }
251   ) {
252     super(["sync"]); // emitted events
254     this.collectionName = collectionName;
255     this.bucketName = bucketName;
256     this.signerName = signerName;
257     this.filterFunc = filterFunc;
258     this.localFields = localFields;
259     this._lastCheckTimePref = lastCheckTimePref;
260     this._verifier = null;
261     this._syncRunning = false;
263     // This attribute allows signature verification to be disabled, when running tests
264     // or when pulling data from a dev server.
265     this.verifySignature = AppConstants.REMOTE_SETTINGS_VERIFY_SIGNATURE;
267     if (!bucketName) {
268       // TODO bug 1702759: Remove bucketNamePref.
269       // The bucket preference value can be changed (eg. `main` to `main-preview`) in order
270       // to preview the changes to be approved in a real client.
271       this.bucketNamePref = bucketNamePref;
272       XPCOMUtils.defineLazyPreferenceGetter(
273         this,
274         "bucketName",
275         this.bucketNamePref,
276         null,
277         () => {
278           this.db.identifier = this.identifier;
279         }
280       );
281     }
283     XPCOMUtils.defineLazyGetter(
284       this,
285       "db",
286       () => new Database(this.identifier)
287     );
289     XPCOMUtils.defineLazyGetter(
290       this,
291       "attachments",
292       () => new AttachmentDownloader(this)
293     );
294   }
296   get identifier() {
297     return `${this.bucketName}/${this.collectionName}`;
298   }
300   get lastCheckTimePref() {
301     return (
302       this._lastCheckTimePref ||
303       `services.settings.${this.bucketName}.${this.collectionName}.last_check`
304     );
305   }
307   httpClient() {
308     const api = new KintoHttpClient(Utils.SERVER_URL, {
309       fetchFunc: Utils.fetch, // Use fetch() wrapper.
310     });
311     return api.bucket(this.bucketName).collection(this.collectionName);
312   }
314   /**
315    * Retrieve the collection timestamp for the last synchronization.
316    * This is an opaque and comparable value assigned automatically by
317    * the server.
318    *
319    * @returns {number}
320    *          The timestamp in milliseconds, returns -1 if retrieving
321    *          the timestamp from the kinto collection fails.
322    */
323   async getLastModified() {
324     let timestamp = -1;
325     try {
326       timestamp = await this.db.getLastModified();
327     } catch (err) {
328       console.warn(
329         `Error retrieving the getLastModified timestamp from ${this.identifier} RemoteSettingsClient`,
330         err
331       );
332     }
334     return timestamp;
335   }
337   /**
338    * Lists settings.
339    *
340    * @param  {Object} options                  The options object.
341    * @param  {Object} options.filters          Filter the results (default: `{}`).
342    * @param  {String} options.order            The order to apply (eg. `"-last_modified"`).
343    * @param  {boolean} options.dumpFallback    Fallback to dump data if read of local DB fails (default: `true`).
344    * @param  {boolean} options.loadDumpIfNewer Use dump data if it is newer than local data (default: `false`).
345    * @param  {boolean} options.syncIfEmpty     Synchronize from server if local data is empty (default: `true`).
346    * @param  {boolean} options.verifySignature Verify the signature of the local data (default: `false`).
347    * @return {Promise}
348    */
349   async get(options = {}) {
350     const {
351       filters = {},
352       order = "", // not sorted by default.
353       dumpFallback = true,
354       loadDumpIfNewer = false, // TODO bug 1718083: should default to true.
355       syncIfEmpty = true,
356     } = options;
357     let { verifySignature = false } = options;
359     let data;
360     try {
361       let lastModified = await this.db.getLastModified();
362       let hasLocalData = lastModified !== null;
364       if (syncIfEmpty && !hasLocalData) {
365         // .get() was called before we had the chance to synchronize the local database.
366         // We'll try to avoid returning an empty list.
367         if (!this._importingPromise) {
368           // Prevent parallel loading when .get() is called multiple times.
369           this._importingPromise = (async () => {
370             const importedFromDump = gLoadDump
371               ? await this._importJSONDump()
372               : -1;
373             if (importedFromDump < 0) {
374               // There is no JSON dump to load, force a synchronization from the server.
375               console.debug(
376                 `${this.identifier} Local DB is empty, pull data from server`
377               );
378               await this.sync({ loadDump: false });
379             }
380           })();
381         } else {
382           console.debug(`${this.identifier} Awaiting existing import.`);
383         }
384       } else if (hasLocalData && loadDumpIfNewer) {
385         // Check whether the local data is older than the packaged dump.
386         // If it is, load the packaged dump (which overwrites the local data).
387         let lastModifiedDump = await Utils.getLocalDumpLastModified(
388           this.bucketName,
389           this.collectionName
390         );
391         if (lastModified < lastModifiedDump) {
392           console.debug(
393             `${this.identifier} Local DB is stale (${lastModified}), using dump instead (${lastModifiedDump})`
394           );
395           if (!this._importingPromise) {
396             // As part of importing, any existing data is wiped.
397             this._importingPromise = this._importJSONDump();
398           } else {
399             console.debug(`${this.identifier} Awaiting existing import.`);
400           }
401         }
402       }
404       if (this._importingPromise) {
405         try {
406           await this._importingPromise;
407           // No need to verify signature, because either we've just load a trusted
408           // dump (here or in a parallel call), or it was verified during sync.
409           verifySignature = false;
410         } catch (e) {
411           // Report error, but continue because there could have been data
412           // loaded from a parrallel call.
413           Cu.reportError(e);
414         } finally {
415           // then delete this promise again, as now we should have local data:
416           delete this._importingPromise;
417         }
418       }
420       // Read from the local DB.
421       data = await this.db.list({ filters, order });
422     } catch (e) {
423       // If the local DB cannot be read (for unknown reasons, Bug 1649393)
424       // We fallback to the packaged data, and filter/sort in memory.
425       if (!dumpFallback) {
426         throw e;
427       }
428       Cu.reportError(e);
429       let { data } = await SharedUtils.loadJSONDump(
430         this.bucketName,
431         this.collectionName
432       );
433       if (data !== null) {
434         console.info(`${this.identifier} falling back to JSON dump`);
435       } else {
436         console.info(`${this.identifier} no dump fallback, return empty list`);
437         data = [];
438       }
439       if (!ObjectUtils.isEmpty(filters)) {
440         data = data.filter(r => Utils.filterObject(filters, r));
441       }
442       if (order) {
443         data = Utils.sortObjects(order, data);
444       }
445       // No need to verify signature on JSON dumps.
446       // If local DB cannot be read, then we don't even try to do anything,
447       // we return results early.
448       return this._filterEntries(data);
449     }
451     console.debug(
452       `${this.identifier} ${data.length} records before filtering.`
453     );
455     if (verifySignature) {
456       console.debug(
457         `${this.identifier} verify signature of local data on read`
458       );
459       const allData = ObjectUtils.isEmpty(filters)
460         ? data
461         : await this.db.list();
462       const localRecords = allData.map(r => this._cleanLocalFields(r));
463       const timestamp = await this.db.getLastModified();
464       let metadata = await this.db.getMetadata();
465       if (syncIfEmpty && ObjectUtils.isEmpty(metadata)) {
466         // No sync occured yet, may have records from dump but no metadata.
467         await this.sync({ loadDump: false });
468         metadata = await this.db.getMetadata();
469       }
470       // Will throw MissingSignatureError if no metadata and `syncIfEmpty` is false.
471       await this._validateCollectionSignature(
472         localRecords,
473         timestamp,
474         metadata
475       );
476     }
478     // Filter the records based on `this.filterFunc` results.
479     const final = await this._filterEntries(data);
480     console.debug(
481       `${this.identifier} ${final.length} records after filtering.`
482     );
483     return final;
484   }
486   /**
487    * Synchronize the local database with the remote server.
488    *
489    * @param {Object} options See #maybeSync() options.
490    */
491   async sync(options) {
492     // We want to know which timestamp we are expected to obtain in order to leverage
493     // cache busting. We don't provide ETag because we don't want a 304.
494     const { changes } = await Utils.fetchLatestChanges(Utils.SERVER_URL, {
495       filters: {
496         collection: this.collectionName,
497         bucket: this.bucketName,
498       },
499     });
500     if (changes.length === 0) {
501       throw new RemoteSettingsClient.UnknownCollectionError(this.identifier);
502     }
503     // According to API, there will be one only (fail if not).
504     const [{ last_modified: expectedTimestamp }] = changes;
506     return this.maybeSync(expectedTimestamp, { ...options, trigger: "forced" });
507   }
509   /**
510    * Synchronize the local database with the remote server, **only if necessary**.
511    *
512    * @param {int}    expectedTimestamp the lastModified date (on the server) for the remote collection.
513    *                                   This will be compared to the local timestamp, and will be used for
514    *                                   cache busting if local data is out of date.
515    * @param {Object} options           additional advanced options.
516    * @param {bool}   options.loadDump  load initial dump from disk on first sync (default: true, unless
517    *                                   `services.settings.load_dump` says otherwise).
518    * @param {string} options.trigger   label to identify what triggered this sync (eg. ``"timer"``, default: `"manual"`)
519    * @return {Promise}                 which rejects on sync or process failure.
520    */
521   async maybeSync(expectedTimestamp, options = {}) {
522     // Should the clients try to load JSON dump? (mainly disabled in tests)
523     const { loadDump = gLoadDump, trigger = "manual" } = options;
525     // Make sure we don't run several synchronizations in parallel, mainly
526     // in order to avoid race conditions in "sync" events listeners.
527     if (this._syncRunning) {
528       console.warn(`${this.identifier} sync already running`);
529       return;
530     }
532     // Prevent network requests and IndexedDB calls to be initiated
533     // during shutdown.
534     if (Services.startup.shuttingDown) {
535       console.warn(`${this.identifier} sync interrupted by shutdown`);
536       return;
537     }
539     this._syncRunning = true;
541     let importedFromDump = [];
542     const startedAt = new Date();
543     let reportStatus = null;
544     let thrownError = null;
545     try {
546       // If network is offline, we can't synchronize.
547       if (Utils.isOffline) {
548         throw new RemoteSettingsClient.NetworkOfflineError();
549       }
551       // Read last timestamp and local data before sync.
552       let collectionLastModified = await this.db.getLastModified();
553       const allData = await this.db.list();
554       // Local data can contain local fields, strip them.
555       let localRecords = allData.map(r => this._cleanLocalFields(r));
556       const localMetadata = await this.db.getMetadata();
558       // If there is no data currently in the collection, attempt to import
559       // initial data from the application defaults.
560       // This allows to avoid synchronizing the whole collection content on
561       // cold start.
562       if (!collectionLastModified && loadDump) {
563         try {
564           const imported = await this._importJSONDump();
565           // The worker only returns an integer. List the imported records to build the sync event.
566           if (imported > 0) {
567             console.debug(
568               `${this.identifier} ${imported} records loaded from JSON dump`
569             );
570             importedFromDump = await this.db.list();
571             // Local data is the data loaded from dump. We will need this later
572             // to compute the sync result.
573             localRecords = importedFromDump;
574           }
575           collectionLastModified = await this.db.getLastModified();
576         } catch (e) {
577           // Report but go-on.
578           Cu.reportError(e);
579         }
580       }
581       let syncResult;
582       try {
583         // Is local timestamp up to date with the server?
584         if (expectedTimestamp == collectionLastModified) {
585           console.debug(`${this.identifier} local data is up-to-date`);
586           reportStatus = UptakeTelemetry.STATUS.UP_TO_DATE;
588           // If the data is up-to-date but don't have metadata (records loaded from dump),
589           // we fetch them and validate the signature immediately.
590           if (this.verifySignature && ObjectUtils.isEmpty(localMetadata)) {
591             console.debug(`${this.identifier} pull collection metadata`);
592             const metadata = await this.httpClient().getData({
593               query: { _expected: expectedTimestamp },
594             });
595             await this.db.importChanges(metadata);
596             // We don't bother validating the signature if the dump was just loaded. We do
597             // if the dump was loaded at some other point (eg. from .get()).
598             if (this.verifySignature && importedFromDump.length == 0) {
599               console.debug(
600                 `${this.identifier} verify signature of local data`
601               );
602               await this._validateCollectionSignature(
603                 localRecords,
604                 collectionLastModified,
605                 metadata
606               );
607             }
608           }
610           // Since the data is up-to-date, if we didn't load any dump then we're done here.
611           if (importedFromDump.length == 0) {
612             return;
613           }
614           // Otherwise we want to continue with sending the sync event to notify about the created records.
615           syncResult = {
616             current: importedFromDump,
617             created: importedFromDump,
618             updated: [],
619             deleted: [],
620           };
621         } else {
622           // Local data is either outdated or tampered.
623           // In both cases we will fetch changes from server,
624           // and make sure we overwrite local data.
625           const startSyncDB = Cu.now() * 1000;
626           syncResult = await this._importChanges(
627             localRecords,
628             collectionLastModified,
629             localMetadata,
630             expectedTimestamp
631           );
632           if (gTimingEnabled) {
633             const endSyncDB = Cu.now() * 1000;
634             PerformanceCounters.storeExecutionTime(
635               `remotesettings/${this.identifier}`,
636               "syncDB",
637               endSyncDB - startSyncDB,
638               "duration"
639             );
640           }
641           if (this.hasListeners("sync")) {
642             // If we have listeners for the "sync" event, then compute the lists of changes.
643             // The records imported from the dump should be considered as "created" for the
644             // listeners.
645             const importedById = importedFromDump.reduce((acc, r) => {
646               acc.set(r.id, r);
647               return acc;
648             }, new Map());
649             // Deleted records should not appear as created.
650             syncResult.deleted.forEach(r => importedById.delete(r.id));
651             // Records from dump that were updated should appear in their newest form.
652             syncResult.updated.forEach(u => {
653               if (importedById.has(u.old.id)) {
654                 importedById.set(u.old.id, u.new);
655               }
656             });
657             syncResult.created = syncResult.created.concat(
658               Array.from(importedById.values())
659             );
660           }
661         }
662       } catch (e) {
663         if (e instanceof InvalidSignatureError) {
664           // Signature verification failed during synchronization.
665           reportStatus =
666             e instanceof CorruptedDataError
667               ? UptakeTelemetry.STATUS.CORRUPTION_ERROR
668               : UptakeTelemetry.STATUS.SIGNATURE_ERROR;
669           // If sync fails with a signature error, it's likely that our
670           // local data has been modified in some way.
671           // We will attempt to fix this by retrieving the whole
672           // remote collection.
673           try {
674             console.warn(
675               `${this.identifier} Signature verified failed. Retry from scratch`
676             );
677             syncResult = await this._importChanges(
678               localRecords,
679               collectionLastModified,
680               localMetadata,
681               expectedTimestamp,
682               { retry: true }
683             );
684           } catch (e) {
685             // If the signature fails again, or if an error occured during wiping out the
686             // local data, then we report it as a *signature retry* error.
687             reportStatus = UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
688             throw e;
689           }
690         } else {
691           // The sync has thrown for other reason than signature verification.
692           // Default status for errors at this step is SYNC_ERROR.
693           reportStatus = this._telemetryFromError(e, {
694             default: UptakeTelemetry.STATUS.SYNC_ERROR,
695           });
696           throw e;
697         }
698       }
699       // Filter the synchronization results using `filterFunc` (ie. JEXL).
700       const filteredSyncResult = await this._filterSyncResult(syncResult);
701       // If every changed entry is filtered, we don't even fire the event.
702       if (filteredSyncResult) {
703         try {
704           await this.emit("sync", { data: filteredSyncResult });
705         } catch (e) {
706           reportStatus = UptakeTelemetry.STATUS.APPLY_ERROR;
707           throw e;
708         }
709       } else {
710         console.info(
711           `All changes are filtered by JEXL expressions for ${this.identifier}`
712         );
713       }
714     } catch (e) {
715       thrownError = e;
716       // If browser is shutting down, then we can report a specific status.
717       // (eg. IndexedDB will abort transactions)
718       if (Services.startup.shuttingDown) {
719         reportStatus = UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
720       }
721       // If no Telemetry status was determined yet (ie. outside sync step),
722       // then introspect error, default status at this step is UNKNOWN.
723       else if (reportStatus == null) {
724         reportStatus = this._telemetryFromError(e, {
725           default: UptakeTelemetry.STATUS.UNKNOWN_ERROR,
726         });
727       }
728       throw e;
729     } finally {
730       const durationMilliseconds = new Date() - startedAt;
731       // No error was reported, this is a success!
732       if (reportStatus === null) {
733         reportStatus = UptakeTelemetry.STATUS.SUCCESS;
734       }
735       // Report success/error status to Telemetry.
736       let reportArgs = {
737         source: this.identifier,
738         trigger,
739         duration: durationMilliseconds,
740       };
741       // In Bug 1617133, we will try to break down specific errors into
742       // more precise statuses by reporting the JavaScript error name
743       // ("TypeError", etc.) to Telemetry on Nightly.
744       const channel = UptakeTelemetry.Policy.getChannel();
745       if (
746         thrownError !== null &&
747         channel == "nightly" &&
748         [
749           UptakeTelemetry.STATUS.SYNC_ERROR,
750           UptakeTelemetry.STATUS.CUSTOM_1_ERROR, // IndexedDB.
751           UptakeTelemetry.STATUS.UNKNOWN_ERROR,
752           UptakeTelemetry.STATUS.SHUTDOWN_ERROR,
753         ].includes(reportStatus)
754       ) {
755         // List of possible error names for IndexedDB:
756         // https://searchfox.org/mozilla-central/rev/49ed791/dom/base/DOMException.cpp#28-53
757         reportArgs = { ...reportArgs, errorName: thrownError.name };
758       }
760       await UptakeTelemetry.report(
761         TELEMETRY_COMPONENT,
762         reportStatus,
763         reportArgs
764       );
766       console.debug(`${this.identifier} sync status is ${reportStatus}`);
767       this._syncRunning = false;
768     }
769   }
771   /**
772    * Determine the Telemetry uptake status based on the specified
773    * error.
774    */
775   _telemetryFromError(e, options = { default: null }) {
776     let reportStatus = options.default;
778     if (e instanceof RemoteSettingsClient.NetworkOfflineError) {
779       reportStatus = UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
780     } else if (e instanceof IDBHelpers.ShutdownError) {
781       reportStatus = UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
782     } else if (/unparseable/.test(e.message)) {
783       reportStatus = UptakeTelemetry.STATUS.PARSE_ERROR;
784     } else if (/NetworkError/.test(e.message)) {
785       reportStatus = UptakeTelemetry.STATUS.NETWORK_ERROR;
786     } else if (/Timeout/.test(e.message)) {
787       reportStatus = UptakeTelemetry.STATUS.TIMEOUT_ERROR;
788     } else if (/HTTP 5??/.test(e.message)) {
789       reportStatus = UptakeTelemetry.STATUS.SERVER_ERROR;
790     } else if (/Backoff/.test(e.message)) {
791       reportStatus = UptakeTelemetry.STATUS.BACKOFF;
792     } else if (
793       // Errors from kinto.js IDB adapter.
794       e instanceof IDBHelpers.IndexedDBError ||
795       // Other IndexedDB errors (eg. RemoteSettingsWorker).
796       /IndexedDB/.test(e.message)
797     ) {
798       reportStatus = UptakeTelemetry.STATUS.CUSTOM_1_ERROR;
799     }
801     return reportStatus;
802   }
804   /**
805    * Import the JSON files from services/settings/dump into the local DB.
806    */
807   async _importJSONDump() {
808     console.info(`${this.identifier} try to restore dump`);
810     const start = Cu.now() * 1000;
811     const result = await RemoteSettingsWorker.importJSONDump(
812       this.bucketName,
813       this.collectionName
814     );
815     console.info(
816       `${this.identifier} imported ${result} records from JSON dump`
817     );
818     if (gTimingEnabled) {
819       const end = Cu.now() * 1000;
820       PerformanceCounters.storeExecutionTime(
821         `remotesettings/${this.identifier}`,
822         "importJSONDump",
823         end - start,
824         "duration"
825       );
826     }
827     if (result < 0) {
828       console.debug(`${this.identifier} no dump available`);
829     } else {
830       console.info(`${this.identifier} imported ${result} records from dump`);
831     }
832     return result;
833   }
835   /**
836    * Fetch the signature info from the collection metadata and verifies that the
837    * local set of records has the same.
838    *
839    * @param {Array<Object>} records The list of records to validate.
840    * @param {int} timestamp         The timestamp associated with the list of remote records.
841    * @param {Object} metadata       The collection metadata, that contains the signature payload.
842    * @returns {Promise}
843    */
844   async _validateCollectionSignature(records, timestamp, metadata) {
845     const start = Cu.now() * 1000;
847     if (!metadata?.signature) {
848       throw new MissingSignatureError(this.identifier);
849     }
851     if (!this._verifier) {
852       this._verifier = Cc[
853         "@mozilla.org/security/contentsignatureverifier;1"
854       ].createInstance(Ci.nsIContentSignatureVerifier);
855     }
857     // This is a content-signature field from an autograph response.
858     const {
859       signature: { x5u, signature },
860     } = metadata;
861     const certChain = await (await Utils.fetch(x5u)).text();
862     // Merge remote records with local ones and serialize as canonical JSON.
863     const serialized = await RemoteSettingsWorker.canonicalStringify(
864       records,
865       timestamp
866     );
867     if (
868       !(await this._verifier.asyncVerifyContentSignature(
869         serialized,
870         "p384ecdsa=" + signature,
871         certChain,
872         this.signerName
873       ))
874     ) {
875       throw new InvalidSignatureError(this.identifier);
876     }
877     if (gTimingEnabled) {
878       const end = Cu.now() * 1000;
879       PerformanceCounters.storeExecutionTime(
880         `remotesettings/${this.identifier}`,
881         "validateCollectionSignature",
882         end - start,
883         "duration"
884       );
885     }
886   }
888   /**
889    * This method is in charge of fetching data from server, applying the diff-based
890    * changes to the local DB, validating the signature, and computing a synchronization
891    * result with the list of creation, updates, and deletions.
892    *
893    * @param {Array<Object>} localRecords      Current list of records in local DB.
894    * @param {int}           localTimestamp    Current timestamp in local DB.
895    * @param {Object}        localMetadata     Current metadata in local DB.
896    * @param {int}           expectedTimestamp Cache busting of collection metadata
897    * @param {Object}        options
898    * @param {bool}          options.retry     Whether this method is called in the
899    *                                          retry situation.
900    *
901    * @returns {Promise<Object>} the computed sync result.
902    */
903   async _importChanges(
904     localRecords,
905     localTimestamp,
906     localMetadata,
907     expectedTimestamp,
908     options = {}
909   ) {
910     const { retry = false } = options;
911     const since = retry || !localTimestamp ? undefined : `"${localTimestamp}"`;
913     // Fetch collection metadata and list of changes from server.
914     console.debug(
915       `${this.identifier} Fetch changes from server (expected=${expectedTimestamp}, since=${since})`
916     );
917     const {
918       metadata,
919       remoteTimestamp,
920       remoteRecords,
921     } = await this._fetchChangeset(expectedTimestamp, since);
923     // We build a sync result, based on remote changes.
924     const syncResult = {
925       current: localRecords,
926       created: [],
927       updated: [],
928       deleted: [],
929     };
930     // If data wasn't changed, return empty sync result.
931     // This can happen when we update the signature but not the data.
932     console.debug(
933       `${this.identifier} local timestamp: ${localTimestamp}, remote: ${remoteTimestamp}`
934     );
935     if (localTimestamp && remoteTimestamp < localTimestamp) {
936       return syncResult;
937     }
939     const start = Cu.now() * 1000;
940     await this.db.importChanges(metadata, remoteTimestamp, remoteRecords, {
941       clear: retry,
942     });
943     if (gTimingEnabled) {
944       const end = Cu.now() * 1000;
945       PerformanceCounters.storeExecutionTime(
946         `remotesettings/${this.identifier}`,
947         "loadRawData",
948         end - start,
949         "duration"
950       );
951     }
953     // Read the new local data, after updating.
954     const newLocal = await this.db.list();
955     const newRecords = newLocal.map(r => this._cleanLocalFields(r));
956     // And verify the signature on what is now stored.
957     if (this.verifySignature) {
958       try {
959         await this._validateCollectionSignature(
960           newRecords,
961           remoteTimestamp,
962           metadata
963         );
964       } catch (e) {
965         console.error(
966           `${this.identifier} Signature failed ${retry ? "again" : ""} ${e}`
967         );
968         if (!(e instanceof InvalidSignatureError)) {
969           // If it failed for any other kind of error (eg. shutdown)
970           // then give up quickly.
971           throw e;
972         }
974         // In order to distinguish signature errors that happen
975         // during sync, from hijacks of local DBs, we will verify
976         // the signature on the data that we had before syncing.
977         let localTrustworthy = false;
978         console.debug(`${this.identifier} verify data before sync`);
979         try {
980           await this._validateCollectionSignature(
981             localRecords,
982             localTimestamp,
983             localMetadata
984           );
985           localTrustworthy = true;
986         } catch (sigerr) {
987           if (!(sigerr instanceof InvalidSignatureError)) {
988             // If it fails for other reason, keep original error and give up.
989             throw sigerr;
990           }
991           console.debug(`${this.identifier} previous data was invalid`);
992         }
994         if (!localTrustworthy && !retry) {
995           // Signature failed, clear local DB because it contains
996           // bad data (local + remote changes).
997           console.debug(`${this.identifier} clear local data`);
998           await this.db.clear();
999           // Local data was tampered, throw and it will retry from empty DB.
1000           console.error(`${this.identifier} local data was corrupted`);
1001           throw new CorruptedDataError(this.identifier);
1002         } else if (retry) {
1003           // We retried already, we will restore the previous local data
1004           // before throwing eventually.
1005           if (localTrustworthy) {
1006             await this.db.importChanges(
1007               localMetadata,
1008               localTimestamp,
1009               localRecords,
1010               {
1011                 clear: true, // clear before importing.
1012               }
1013             );
1014           } else {
1015             // Restore the dump if available (no-op if no dump)
1016             const imported = await this._importJSONDump();
1017             // _importJSONDump() only clears DB if dump is available,
1018             // therefore do it here!
1019             if (imported < 0) {
1020               await this.db.clear();
1021             }
1022           }
1023         }
1024         throw e;
1025       }
1026     } else {
1027       console.warn(`${this.identifier} has signature disabled`);
1028     }
1030     if (this.hasListeners("sync")) {
1031       // If we have some listeners for the "sync" event,
1032       // Compute the changes, comparing records before and after.
1033       syncResult.current = newRecords;
1034       const oldById = new Map(localRecords.map(e => [e.id, e]));
1035       for (const r of newRecords) {
1036         const old = oldById.get(r.id);
1037         if (old) {
1038           oldById.delete(r.id);
1039           if (r.last_modified != old.last_modified) {
1040             syncResult.updated.push({ old, new: r });
1041           }
1042         } else {
1043           syncResult.created.push(r);
1044         }
1045       }
1046       syncResult.deleted = syncResult.deleted.concat(
1047         Array.from(oldById.values())
1048       );
1049       console.debug(
1050         `${this.identifier} ${syncResult.created.length} created. ${syncResult.updated.length} updated. ${syncResult.deleted.length} deleted.`
1051       );
1052     }
1054     return syncResult;
1055   }
1057   /**
1058    * Fetch information from changeset endpoint.
1059    *
1060    * @param expectedTimestamp cache busting value
1061    * @param since timestamp of last sync (optional)
1062    */
1063   async _fetchChangeset(expectedTimestamp, since) {
1064     const client = this.httpClient();
1065     const {
1066       metadata,
1067       timestamp: remoteTimestamp,
1068       changes: remoteRecords,
1069     } = await client.execute(
1070       {
1071         path: `/buckets/${this.bucketName}/collections/${this.collectionName}/changeset`,
1072       },
1073       {
1074         query: {
1075           _expected: expectedTimestamp,
1076           _since: since,
1077         },
1078       }
1079     );
1080     return {
1081       remoteTimestamp,
1082       metadata,
1083       remoteRecords,
1084     };
1085   }
1087   /**
1088    * Use the filter func to filter the lists of changes obtained from synchronization,
1089    * and return them along with the filtered list of local records.
1090    *
1091    * If the filtered lists of changes are all empty, we return null (and thus don't
1092    * bother listing local DB).
1093    *
1094    * @param {Object}     syncResult       Synchronization result without filtering.
1095    *
1096    * @returns {Promise<Object>} the filtered list of local records, plus the filtered
1097    *                            list of created, updated and deleted records.
1098    */
1099   async _filterSyncResult(syncResult) {
1100     // Handle the obtained records (ie. apply locally through events).
1101     // Build the event data list. It should be filtered (ie. by application target)
1102     const {
1103       current: allData,
1104       created: allCreated,
1105       updated: allUpdated,
1106       deleted: allDeleted,
1107     } = syncResult;
1108     const [created, deleted, updatedFiltered] = await Promise.all(
1109       [allCreated, allDeleted, allUpdated.map(e => e.new)].map(
1110         this._filterEntries.bind(this)
1111       )
1112     );
1113     // For updates, keep entries whose updated form matches the target.
1114     const updatedFilteredIds = new Set(updatedFiltered.map(e => e.id));
1115     const updated = allUpdated.filter(({ new: { id } }) =>
1116       updatedFilteredIds.has(id)
1117     );
1119     if (!created.length && !updated.length && !deleted.length) {
1120       return null;
1121     }
1122     // Read local collection of records (also filtered).
1123     const current = await this._filterEntries(allData);
1124     return { created, updated, deleted, current };
1125   }
1127   /**
1128    * Filter entries for which calls to `this.filterFunc` returns null.
1129    *
1130    * @param {Array<Objet>} data
1131    * @returns {Array<Object>}
1132    */
1133   async _filterEntries(data) {
1134     if (!this.filterFunc) {
1135       return data;
1136     }
1137     const start = Cu.now() * 1000;
1138     const environment = cacheProxy(ClientEnvironmentBase);
1139     const dataPromises = data.map(e => this.filterFunc(e, environment));
1140     const results = await Promise.all(dataPromises);
1141     if (gTimingEnabled) {
1142       const end = Cu.now() * 1000;
1143       PerformanceCounters.storeExecutionTime(
1144         `remotesettings/${this.identifier}`,
1145         "filterEntries",
1146         end - start,
1147         "duration"
1148       );
1149     }
1150     return results.filter(Boolean);
1151   }
1153   /**
1154    * Remove the fields from the specified record
1155    * that are not present on server.
1156    *
1157    * @param {Object} record
1158    */
1159   _cleanLocalFields(record) {
1160     const keys = ["_status"].concat(this.localFields);
1161     const result = { ...record };
1162     for (const key of keys) {
1163       delete result[key];
1164     }
1165     return result;
1166   }