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