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 }