gecko-dev/services/settings/RemoteSettingsClient.sys.mjs

1290 lines
44 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
import { Downloader } from "resource://services-settings/Attachments.sys.mjs";
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ClientEnvironmentBase:
"resource://gre/modules/components-utils/ClientEnvironment.sys.mjs",
Database: "resource://services-settings/Database.sys.mjs",
IDBHelpers: "resource://services-settings/IDBHelpers.sys.mjs",
KintoHttpClient: "resource://services-common/kinto-http-client.sys.mjs",
ObjectUtils: "resource://gre/modules/ObjectUtils.sys.mjs",
RemoteSettingsWorker:
"resource://services-settings/RemoteSettingsWorker.sys.mjs",
SharedUtils: "resource://services-settings/SharedUtils.sys.mjs",
UptakeTelemetry: "resource://services-common/uptake-telemetry.sys.mjs",
Utils: "resource://services-settings/Utils.sys.mjs",
});
const TELEMETRY_COMPONENT = "Remotesettings";
ChromeUtils.defineLazyGetter(lazy, "console", () => lazy.Utils.log);
/**
* cacheProxy returns an object Proxy that will memoize properties of the target.
* @param {Object} target the object to wrap.
* @returns {Proxy}
*/
function cacheProxy(target) {
const cache = new Map();
return new Proxy(target, {
get(target, prop) {
if (!cache.has(prop)) {
cache.set(prop, target[prop]);
}
return cache.get(prop);
},
});
}
/**
* Minimalist event emitter.
*
* Note: we don't use `toolkit/modules/EventEmitter` because **we want** to throw
* an error when a listener fails to execute.
*/
class EventEmitter {
constructor(events) {
this._listeners = new Map();
for (const event of events) {
this._listeners.set(event, []);
}
}
/**
* Event emitter: will execute the registered listeners in the order and
* sequentially.
*
* @param {string} event the event name
* @param {Object} payload the event payload to call the listeners with
*/
async emit(event, payload) {
const callbacks = this._listeners.get(event);
let lastError;
for (const cb of callbacks) {
try {
await cb(payload);
} catch (e) {
lastError = e;
}
}
if (lastError) {
throw lastError;
}
}
hasListeners(event) {
return this._listeners.has(event) && !!this._listeners.get(event).length;
}
on(event, callback) {
if (!this._listeners.has(event)) {
throw new Error(`Unknown event type ${event}`);
}
this._listeners.get(event).push(callback);
}
off(event, callback) {
if (!this._listeners.has(event)) {
throw new Error(`Unknown event type ${event}`);
}
const callbacks = this._listeners.get(event);
const i = callbacks.indexOf(callback);
if (i < 0) {
throw new Error(`Unknown callback`);
} else {
callbacks.splice(i, 1);
}
}
}
class APIError extends Error {}
class NetworkError extends APIError {
constructor(e) {
super(`Network error: ${e}`, { cause: e });
this.name = "NetworkError";
}
}
class NetworkOfflineError extends APIError {
constructor() {
super("Network is offline");
this.name = "NetworkOfflineError";
}
}
class ServerContentParseError extends APIError {
constructor(e) {
super(`Cannot parse server content: ${e}`, { cause: e });
this.name = "ServerContentParseError";
}
}
class BackendError extends APIError {
constructor(e) {
super(`Backend error: ${e}`, { cause: e });
this.name = "BackendError";
}
}
class BackoffError extends APIError {
constructor(e) {
super(`Server backoff: ${e}`, { cause: e });
this.name = "BackoffError";
}
}
class TimeoutError extends APIError {
constructor(e) {
super(`API timeout: ${e}`, { cause: e });
this.name = "TimeoutError";
}
}
class StorageError extends Error {
constructor(e) {
super(`Storage error: ${e}`, { cause: e });
this.name = "StorageError";
}
}
class InvalidSignatureError extends Error {
constructor(cid, x5u) {
let message = `Invalid content signature (${cid})`;
if (x5u) {
const chain = x5u.split("/").pop();
message += ` using '${chain}'`;
}
super(message);
this.name = "InvalidSignatureError";
}
}
class MissingSignatureError extends InvalidSignatureError {
constructor(cid) {
super(cid);
this.message = `Missing signature (${cid})`;
this.name = "MissingSignatureError";
}
}
class CorruptedDataError extends InvalidSignatureError {
constructor(cid) {
super(cid);
this.message = `Corrupted local data (${cid})`;
this.name = "CorruptedDataError";
}
}
class UnknownCollectionError extends Error {
constructor(cid) {
super(`Unknown Collection "${cid}"`);
this.name = "UnknownCollectionError";
}
}
class AttachmentDownloader extends Downloader {
constructor(client) {
super(client.bucketName, client.collectionName);
this._client = client;
}
get cacheImpl() {
const cacheImpl = {
get: async attachmentId => {
return this._client.db.getAttachment(attachmentId);
},
set: async (attachmentId, attachment) => {
return this._client.db.saveAttachment(attachmentId, attachment);
},
setMultiple: async attachmentsIdsBlobs => {
return this._client.db.saveAttachments(attachmentsIdsBlobs);
},
delete: async attachmentId => {
return this._client.db.saveAttachment(attachmentId, null);
},
prune: async excludeIds => {
return this._client.db.pruneAttachments(excludeIds);
},
hasData: async () => {
return this._client.db.hasAttachments();
},
};
Object.defineProperty(this, "cacheImpl", { value: cacheImpl });
return cacheImpl;
}
/**
* Download attachment and report Telemetry on failure.
*
* @see Downloader.download
*/
async download(record, options) {
try {
// Explicitly await here to ensure we catch a network error.
return await super.download(record, options);
} catch (err) {
// Report download error.
let status = lazy.UptakeTelemetry.STATUS.DOWNLOAD_ERROR;
if (lazy.Utils.isOffline) {
status = lazy.UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
} else if (/NetworkError/.test(err.message)) {
status = lazy.UptakeTelemetry.STATUS.NETWORK_ERROR;
}
// If the file failed to be downloaded, report it as such in Telemetry.
await lazy.UptakeTelemetry.report(TELEMETRY_COMPONENT, status, {
source: this._client.identifier,
errorName: err.name,
});
throw err;
}
}
/**
* Delete all downloaded records attachments.
*
* Note: the list of attachments to be deleted is based on the
* current list of records.
*/
async deleteAll() {
let allRecords = await this._client.db.list();
return Promise.all(
allRecords
.filter(r => !!r.attachment)
.map(r =>
Promise.all([this.deleteDownloaded(r), this.deleteFromDisk(r)])
)
);
}
}
export class RemoteSettingsClient extends EventEmitter {
static get APIError() {
return APIError;
}
static get NetworkError() {
return NetworkError;
}
static get NetworkOfflineError() {
return NetworkOfflineError;
}
static get ServerContentParseError() {
return ServerContentParseError;
}
static get BackendError() {
return BackendError;
}
static get BackoffError() {
return BackoffError;
}
static get TimeoutError() {
return TimeoutError;
}
static get StorageError() {
return StorageError;
}
static get InvalidSignatureError() {
return InvalidSignatureError;
}
static get MissingSignatureError() {
return MissingSignatureError;
}
static get CorruptedDataError() {
return CorruptedDataError;
}
static get UnknownCollectionError() {
return UnknownCollectionError;
}
constructor(
collectionName,
{
bucketName = AppConstants.REMOTE_SETTINGS_DEFAULT_BUCKET,
signerName,
filterFunc,
localFields = [],
keepAttachmentsIds = [],
lastCheckTimePref,
} = {}
) {
// Remote Settings cannot be used in child processes (no access to disk,
// easily killed, isolated observer notifications etc.).
// Since our goal here is to prevent consumers to instantiate while developing their
// feature, throwing in Nightly only is enough, and prevents unexpected crashes
// in release or beta.
if (
!AppConstants.RELEASE_OR_BETA &&
Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT
) {
throw new Error(
"Cannot instantiate Remote Settings client in child processes."
);
}
super(["sync"]); // emitted events
this.collectionName = collectionName;
// Client is constructed with the raw bucket name (eg. "main", "security-state", "blocklist")
// The `bucketName` will contain the `-preview` suffix if the preview mode is enabled.
this.bucketName = lazy.Utils.actualBucketName(bucketName);
this.signerName = signerName;
this.filterFunc = filterFunc;
this.localFields = localFields;
this.keepAttachmentsIds = keepAttachmentsIds;
this._lastCheckTimePref = lastCheckTimePref;
this._verifier = null;
this._syncRunning = false;
// This attribute allows signature verification to be disabled, when running tests
// or when pulling data from a dev server.
this.verifySignature = AppConstants.REMOTE_SETTINGS_VERIFY_SIGNATURE;
ChromeUtils.defineLazyGetter(
this,
"db",
() => new lazy.Database(this.identifier)
);
ChromeUtils.defineLazyGetter(
this,
"attachments",
() => new AttachmentDownloader(this)
);
}
/**
* Internal method to refresh the client bucket name after the preview mode
* was toggled.
*
* See `RemoteSettings.enabledPreviewMode()`.
*/
refreshBucketName() {
this.bucketName = lazy.Utils.actualBucketName(this.bucketName);
this.db.identifier = this.identifier;
}
get identifier() {
return `${this.bucketName}/${this.collectionName}`;
}
get lastCheckTimePref() {
return (
this._lastCheckTimePref ||
`services.settings.${this.bucketName}.${this.collectionName}.last_check`
);
}
httpClient() {
const api = new lazy.KintoHttpClient(lazy.Utils.SERVER_URL, {
fetchFunc: lazy.Utils.fetch, // Use fetch() wrapper.
});
return api.bucket(this.bucketName).collection(this.collectionName);
}
/**
* Retrieve the collection timestamp for the last synchronization.
* This is an opaque and comparable value assigned automatically by
* the server.
*
* @returns {number}
* The timestamp in milliseconds, returns -1 if retrieving
* the timestamp from the kinto collection fails.
*/
async getLastModified() {
let timestamp = -1;
try {
timestamp = await this.db.getLastModified();
} catch (err) {
lazy.console.warn(
`Error retrieving the getLastModified timestamp from ${this.identifier} RemoteSettingsClient`,
err
);
}
return timestamp;
}
/**
* Lists settings.
*
* @param {Object} options The options object.
* @param {Object} options.filters Filter the results (default: `{}`).
* @param {String} options.order The order to apply (eg. `"-last_modified"`).
* @param {boolean} options.dumpFallback Fallback to dump data if read of local DB fails (default: `true`).
* @param {boolean} options.emptyListFallback Fallback to empty list if no dump data and read of local DB fails (default: `true`).
* @param {boolean} options.loadDumpIfNewer Use dump data if it is newer than local data (default: `true`).
* @param {boolean} options.forceSync Always synchronize from server before returning results (default: `false`).
* @param {boolean} options.syncIfEmpty Synchronize from server if local data is empty (default: `true`).
* @param {boolean} options.verifySignature Verify the signature of the local data (default: `false`).
* @return {Promise}
*/
async get(options = {}) {
const {
filters = {},
order = "", // not sorted by default.
dumpFallback = true,
emptyListFallback = true,
forceSync = false,
loadDumpIfNewer = true,
syncIfEmpty = true,
} = options;
let { verifySignature = false } = options;
const hasParallelCall = !!this._importingPromise;
let data;
try {
let lastModified = forceSync ? null : await this.db.getLastModified();
let hasLocalData = lastModified !== null;
if (forceSync) {
if (!this._importingPromise) {
this._importingPromise = (async () => {
await this.sync({ sendEvents: false, trigger: "forced" });
return true; // No need to re-verify signature after sync.
})();
}
} else if (syncIfEmpty && !hasLocalData) {
// .get() was called before we had the chance to synchronize the local database.
// We'll try to avoid returning an empty list.
if (!this._importingPromise) {
// Prevent parallel loading when .get() is called multiple times.
this._importingPromise = (async () => {
const importedFromDump = lazy.Utils.LOAD_DUMPS
? await this._importJSONDump()
: -1;
if (importedFromDump < 0) {
// There is no JSON dump to load, force a synchronization from the server.
// We don't want the "sync" event to be sent, since some consumers use `.get()`
// in "sync" callbacks. See Bug 1761953
lazy.console.debug(
`${this.identifier} Local DB is empty, pull data from server`
);
await this.sync({ loadDump: false, sendEvents: false });
}
// Return `true` to indicate we don't need to `verifySignature`,
// since a trusted dump was loaded or a signature verification
// happened during synchronization.
return true;
})();
} else {
lazy.console.debug(`${this.identifier} Awaiting existing import.`);
}
} else if (hasLocalData && loadDumpIfNewer && lazy.Utils.LOAD_DUMPS) {
// Check whether the local data is older than the packaged dump.
// If it is and we are on production, load the packaged dump (which
// overwrites the local data).
let lastModifiedDump = await lazy.Utils.getLocalDumpLastModified(
this.bucketName,
this.collectionName
);
if (lastModified < lastModifiedDump) {
lazy.console.debug(
`${this.identifier} Local DB is stale (${lastModified}), using dump instead (${lastModifiedDump})`
);
if (!this._importingPromise) {
// As part of importing, any existing data is wiped.
this._importingPromise = (async () => {
const importedFromDump = await this._importJSONDump();
// Return `true` to skip signature verification if a dump was found.
// The dump can be missing if a collection is listed in the timestamps summary file,
// because its dump is present in the source tree, but the dump was not
// included in the `package-manifest.in` file. (eg. android, thunderbird)
return importedFromDump >= 0;
})();
} else {
lazy.console.debug(`${this.identifier} Awaiting existing import.`);
}
}
}
if (this._importingPromise) {
try {
if (await this._importingPromise) {
// No need to verify signature, because either we've just loaded a trusted
// dump (here or in a parallel call), or it was verified during sync.
verifySignature = false;
}
} catch (e) {
if (!hasParallelCall) {
// Sync or load dump failed. Throw.
throw e;
}
// Report error, but continue because there could have been data
// loaded from a parallel call.
console.error(e);
} finally {
// then delete this promise again, as now we should have local data:
delete this._importingPromise;
}
}
// Read from the local DB.
data = await this.db.list({ filters, order });
} catch (e) {
// If the local DB cannot be read (for unknown reasons, Bug 1649393)
// or sync failed, we fallback to the packaged data, and filter/sort in memory.
if (!dumpFallback) {
throw e;
}
console.error(e);
let { data } = await lazy.SharedUtils.loadJSONDump(
this.bucketName,
this.collectionName
);
if (data !== null) {
lazy.console.info(`${this.identifier} falling back to JSON dump`);
} else if (emptyListFallback) {
lazy.console.info(
`${this.identifier} no dump fallback, return empty list`
);
data = [];
} else {
// Obtaining the records failed, there is no dump, and we don't fallback
// to an empty list. Throw the original error.
throw e;
}
if (!lazy.ObjectUtils.isEmpty(filters)) {
data = data.filter(r => lazy.Utils.filterObject(filters, r));
}
if (order) {
data = lazy.Utils.sortObjects(order, data);
}
// No need to verify signature on JSON dumps.
// If local DB cannot be read, then we don't even try to do anything,
// we return results early.
return this._filterEntries(data);
}
lazy.console.debug(
`${this.identifier} ${data.length} records before filtering.`
);
if (verifySignature) {
lazy.console.debug(
`${this.identifier} verify signature of local data on read`
);
const allData = lazy.ObjectUtils.isEmpty(filters)
? data
: await this.db.list();
const localRecords = allData.map(r => this._cleanLocalFields(r));
const timestamp = await this.db.getLastModified();
let metadata = await this.db.getMetadata();
if (syncIfEmpty && lazy.ObjectUtils.isEmpty(metadata)) {
// No sync occured yet, may have records from dump but no metadata.
// We don't want the "sync" event to be sent, since some consumers use `.get()`
// in "sync" callbacks. See Bug 1761953
await this.sync({ loadDump: false, sendEvents: false });
metadata = await this.db.getMetadata();
}
// Will throw MissingSignatureError if no metadata and `syncIfEmpty` is false.
await this._validateCollectionSignature(
localRecords,
timestamp,
metadata
);
}
// Filter the records based on `this.filterFunc` results.
const final = await this._filterEntries(data);
lazy.console.debug(
`${this.identifier} ${final.length} records after filtering.`
);
return final;
}
/**
* Synchronize the local database with the remote server.
*
* @param {Object} options See #maybeSync() options.
*/
async sync(options) {
if (lazy.Utils.shouldSkipRemoteActivityDueToTests) {
return;
}
// We want to know which timestamp we are expected to obtain in order to leverage
// cache busting. We don't provide ETag because we don't want a 304.
const { changes } = await lazy.Utils.fetchLatestChanges(
lazy.Utils.SERVER_URL,
{
filters: {
collection: this.collectionName,
bucket: this.bucketName,
},
}
);
if (changes.length === 0) {
throw new RemoteSettingsClient.UnknownCollectionError(this.identifier);
}
// According to API, there will be one only (fail if not).
const [{ last_modified: expectedTimestamp }] = changes;
await this.maybeSync(expectedTimestamp, { ...options, trigger: "forced" });
}
/**
* Synchronize the local database with the remote server, **only if necessary**.
*
* @param {int} expectedTimestamp the lastModified date (on the server) for the remote collection.
* This will be compared to the local timestamp, and will be used for
* cache busting if local data is out of date.
* @param {Object} options additional advanced options.
* @param {bool} options.loadDump load initial dump from disk on first sync (default: true if server is prod)
* @param {bool} options.sendEvents send `"sync"` events (default: `true`)
* @param {string} options.trigger label to identify what triggered this sync (eg. ``"timer"``, default: `"manual"`)
* @return {Promise} which rejects on sync or process failure.
*/
async maybeSync(expectedTimestamp, options = {}) {
// Should the clients try to load JSON dump? (mainly disabled in tests)
const {
loadDump = lazy.Utils.LOAD_DUMPS,
trigger = "manual",
sendEvents = true,
} = options;
// Make sure we don't run several synchronizations in parallel, mainly
// in order to avoid race conditions in "sync" events listeners.
if (this._syncRunning) {
lazy.console.warn(`${this.identifier} sync already running`);
return;
}
// Prevent network requests and IndexedDB calls to be initiated
// during shutdown.
if (Services.startup.shuttingDown) {
lazy.console.warn(`${this.identifier} sync interrupted by shutdown`);
return;
}
this._syncRunning = true;
let importedFromDump = [];
const startedAt = new Date();
let reportStatus = null;
let thrownError = null;
try {
// If network is offline, we can't synchronize.
if (lazy.Utils.isOffline) {
throw new RemoteSettingsClient.NetworkOfflineError();
}
// Read last timestamp and local data before sync.
let collectionLastModified = await this.db.getLastModified();
const allData = await this.db.list();
// Local data can contain local fields, strip them.
let localRecords = allData.map(r => this._cleanLocalFields(r));
const localMetadata = await this.db.getMetadata();
// If there is no data currently in the collection, attempt to import
// initial data from the application defaults.
// This allows to avoid synchronizing the whole collection content on
// cold start.
if (!collectionLastModified && loadDump) {
try {
const imported = await this._importJSONDump();
// The worker only returns an integer. List the imported records to build the sync event.
if (imported > 0) {
lazy.console.debug(
`${this.identifier} ${imported} records loaded from JSON dump`
);
importedFromDump = await this.db.list();
// Local data is the data loaded from dump. We will need this later
// to compute the sync result.
localRecords = importedFromDump;
}
collectionLastModified = await this.db.getLastModified();
} catch (e) {
// Report but go-on.
console.error(e);
}
}
let syncResult;
try {
// Is local timestamp up to date with the server?
if (expectedTimestamp == collectionLastModified) {
lazy.console.debug(`${this.identifier} local data is up-to-date`);
reportStatus = lazy.UptakeTelemetry.STATUS.UP_TO_DATE;
// If the data is up-to-date but don't have metadata (records loaded from dump),
// we fetch them and validate the signature immediately.
if (this.verifySignature && lazy.ObjectUtils.isEmpty(localMetadata)) {
lazy.console.debug(`${this.identifier} pull collection metadata`);
const metadata = await this.httpClient().getData({
query: { _expected: expectedTimestamp },
});
await this.db.importChanges(metadata);
// We don't bother validating the signature if the dump was just loaded. We do
// if the dump was loaded at some other point (eg. from .get()).
if (this.verifySignature && !importedFromDump.length) {
lazy.console.debug(
`${this.identifier} verify signature of local data`
);
await this._validateCollectionSignature(
localRecords,
collectionLastModified,
metadata
);
}
}
// Since the data is up-to-date, if we didn't load any dump then we're done here.
if (!importedFromDump.length) {
return;
}
// Otherwise we want to continue with sending the sync event to notify about the created records.
syncResult = {
current: importedFromDump,
created: importedFromDump,
updated: [],
deleted: [],
};
} else {
// Local data is either outdated or tampered.
// In both cases we will fetch changes from server,
// and make sure we overwrite local data.
syncResult = await this._importChanges(
localRecords,
collectionLastModified,
localMetadata,
expectedTimestamp
);
if (sendEvents && this.hasListeners("sync")) {
// If we have listeners for the "sync" event, then compute the lists of changes.
// The records imported from the dump should be considered as "created" for the
// listeners.
const importedById = importedFromDump.reduce((acc, r) => {
acc.set(r.id, r);
return acc;
}, new Map());
// Deleted records should not appear as created.
syncResult.deleted.forEach(r => importedById.delete(r.id));
// Records from dump that were updated should appear in their newest form.
syncResult.updated.forEach(u => {
if (importedById.has(u.old.id)) {
importedById.set(u.old.id, u.new);
}
});
syncResult.created = syncResult.created.concat(
Array.from(importedById.values())
);
}
// When triggered from the daily timer, and if the sync was successful, and once
// all sync listeners have been executed successfully, we prune potential
// obsolete attachments that may have been left in the local cache.
if (trigger == "timer") {
const deleted = await this.attachments.prune(
this.keepAttachmentsIds
);
if (deleted > 0) {
lazy.console.warn(
`${this.identifier} Pruned ${deleted} obsolete attachments`
);
}
}
}
} catch (e) {
if (e instanceof InvalidSignatureError) {
// Signature verification failed during synchronization.
reportStatus =
e instanceof CorruptedDataError
? lazy.UptakeTelemetry.STATUS.CORRUPTION_ERROR
: lazy.UptakeTelemetry.STATUS.SIGNATURE_ERROR;
// If sync fails with a signature error, it's likely that our
// local data has been modified in some way.
// We will attempt to fix this by retrieving the whole
// remote collection.
try {
lazy.console.warn(
`${this.identifier} Signature verified failed. Retry from scratch`
);
syncResult = await this._importChanges(
localRecords,
collectionLastModified,
localMetadata,
expectedTimestamp,
{ retry: true }
);
} catch (e) {
// If the signature fails again, or if an error occured during wiping out the
// local data, then we report it as a *signature retry* error.
reportStatus = lazy.UptakeTelemetry.STATUS.SIGNATURE_RETRY_ERROR;
throw e;
}
} else {
// The sync has thrown for other reason than signature verification.
// Obtain a more precise error than original one.
const adjustedError = this._adjustedError(e);
// Default status for errors at this step is SYNC_ERROR.
reportStatus = this._telemetryFromError(adjustedError, {
default: lazy.UptakeTelemetry.STATUS.SYNC_ERROR,
});
throw adjustedError;
}
}
if (sendEvents) {
// Filter the synchronization results using `filterFunc` (ie. JEXL).
const filteredSyncResult = await this._filterSyncResult(syncResult);
// If every changed entry is filtered, we don't even fire the event.
if (filteredSyncResult) {
try {
await this.emit("sync", { data: filteredSyncResult });
} catch (e) {
reportStatus = lazy.UptakeTelemetry.STATUS.APPLY_ERROR;
throw e;
}
} else {
lazy.console.info(
`All changes are filtered by JEXL expressions for ${this.identifier}`
);
}
}
} catch (e) {
thrownError = e;
// Obtain a more precise error than original one.
const adjustedError = this._adjustedError(e);
// If browser is shutting down, then we can report a specific status.
// (eg. IndexedDB will abort transactions)
if (Services.startup.shuttingDown) {
reportStatus = lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
}
// If no Telemetry status was determined yet (ie. outside sync step),
// then introspect error, default status at this step is UNKNOWN.
else if (reportStatus == null) {
reportStatus = this._telemetryFromError(adjustedError, {
default: lazy.UptakeTelemetry.STATUS.UNKNOWN_ERROR,
});
}
throw e;
} finally {
const durationMilliseconds = new Date() - startedAt;
// No error was reported, this is a success!
if (reportStatus === null) {
reportStatus = lazy.UptakeTelemetry.STATUS.SUCCESS;
}
// Report success/error status to Telemetry.
let reportArgs = {
source: this.identifier,
trigger,
duration: durationMilliseconds,
};
// In Bug 1617133, we will try to break down specific errors into
// more precise statuses by reporting the JavaScript error name
// ("TypeError", etc.) to Telemetry on Nightly.
const channel = lazy.UptakeTelemetry.Policy.getChannel();
if (
thrownError !== null &&
channel == "nightly" &&
[
lazy.UptakeTelemetry.STATUS.SYNC_ERROR,
lazy.UptakeTelemetry.STATUS.CUSTOM_1_ERROR, // IndexedDB.
lazy.UptakeTelemetry.STATUS.UNKNOWN_ERROR,
lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR,
].includes(reportStatus)
) {
// List of possible error names for IndexedDB:
// https://searchfox.org/mozilla-central/rev/49ed791/dom/base/DOMException.cpp#28-53
reportArgs = { ...reportArgs, errorName: thrownError.name };
}
await lazy.UptakeTelemetry.report(
TELEMETRY_COMPONENT,
reportStatus,
reportArgs
);
lazy.console.debug(`${this.identifier} sync status is ${reportStatus}`);
this._syncRunning = false;
}
}
/**
* Return a more precise error instance, based on the specified
* error and its message.
* @param {Error} e the original error
* @returns {Error}
*/
_adjustedError(e) {
if (/unparseable/.test(e.message)) {
return new RemoteSettingsClient.ServerContentParseError(e);
}
if (/NetworkError/.test(e.message)) {
return new RemoteSettingsClient.NetworkError(e);
}
if (/Timeout/.test(e.message)) {
return new RemoteSettingsClient.TimeoutError(e);
}
if (/HTTP 5??/.test(e.message)) {
return new RemoteSettingsClient.BackendError(e);
}
if (/Backoff/.test(e.message)) {
return new RemoteSettingsClient.BackoffError(e);
}
if (
// Errors from kinto.js IDB adapter.
e instanceof lazy.IDBHelpers.IndexedDBError ||
// Other IndexedDB errors (eg. RemoteSettingsWorker).
/IndexedDB/.test(e.message)
) {
return new RemoteSettingsClient.StorageError(e);
}
return e;
}
/**
* Determine the Telemetry uptake status based on the specified
* error.
*/
_telemetryFromError(e, options = { default: null }) {
let reportStatus = options.default;
if (e instanceof RemoteSettingsClient.NetworkOfflineError) {
reportStatus = lazy.UptakeTelemetry.STATUS.NETWORK_OFFLINE_ERROR;
} else if (e instanceof lazy.IDBHelpers.ShutdownError) {
reportStatus = lazy.UptakeTelemetry.STATUS.SHUTDOWN_ERROR;
} else if (e instanceof RemoteSettingsClient.ServerContentParseError) {
reportStatus = lazy.UptakeTelemetry.STATUS.PARSE_ERROR;
} else if (e instanceof RemoteSettingsClient.NetworkError) {
reportStatus = lazy.UptakeTelemetry.STATUS.NETWORK_ERROR;
} else if (e instanceof RemoteSettingsClient.TimeoutError) {
reportStatus = lazy.UptakeTelemetry.STATUS.TIMEOUT_ERROR;
} else if (e instanceof RemoteSettingsClient.BackendError) {
reportStatus = lazy.UptakeTelemetry.STATUS.SERVER_ERROR;
} else if (e instanceof RemoteSettingsClient.BackoffError) {
reportStatus = lazy.UptakeTelemetry.STATUS.BACKOFF;
} else if (e instanceof RemoteSettingsClient.StorageError) {
reportStatus = lazy.UptakeTelemetry.STATUS.CUSTOM_1_ERROR;
}
return reportStatus;
}
/**
* Import the JSON files from services/settings/dump into the local DB.
*/
async _importJSONDump() {
lazy.console.info(`${this.identifier} try to restore dump`);
const result = await lazy.RemoteSettingsWorker.importJSONDump(
this.bucketName,
this.collectionName
);
if (result < 0) {
lazy.console.debug(`${this.identifier} no dump available`);
} else {
lazy.console.info(
`${this.identifier} imported ${result} records from dump`
);
}
return result;
}
/**
* Fetch the signature info from the collection metadata and verifies that the
* local set of records has the same.
*
* @param {Array<Object>} records The list of records to validate.
* @param {int} timestamp The timestamp associated with the list of remote records.
* @param {Object} metadata The collection metadata, that contains the signature payload.
* @returns {Promise}
*/
async _validateCollectionSignature(records, timestamp, metadata) {
if (!metadata?.signature) {
throw new MissingSignatureError(this.identifier);
}
if (!this._verifier) {
this._verifier = Cc[
"@mozilla.org/security/contentsignatureverifier;1"
].createInstance(Ci.nsIContentSignatureVerifier);
}
// This is a content-signature field from an autograph response.
const {
signature: { x5u, signature },
} = metadata;
const certChain = await (await lazy.Utils.fetch(x5u)).text();
// Merge remote records with local ones and serialize as canonical JSON.
const serialized = await lazy.RemoteSettingsWorker.canonicalStringify(
records,
timestamp
);
lazy.console.debug(`${this.identifier} verify signature using ${x5u}`);
if (
!(await this._verifier.asyncVerifyContentSignature(
serialized,
"p384ecdsa=" + signature,
certChain,
this.signerName,
lazy.Utils.CERT_CHAIN_ROOT_IDENTIFIER
))
) {
throw new InvalidSignatureError(this.identifier, x5u);
}
}
/**
* This method is in charge of fetching data from server, applying the diff-based
* changes to the local DB, validating the signature, and computing a synchronization
* result with the list of creation, updates, and deletions.
*
* @param {Array<Object>} localRecords Current list of records in local DB.
* @param {int} localTimestamp Current timestamp in local DB.
* @param {Object} localMetadata Current metadata in local DB.
* @param {int} expectedTimestamp Cache busting of collection metadata
* @param {Object} options
* @param {bool} options.retry Whether this method is called in the
* retry situation.
*
* @returns {Promise<Object>} the computed sync result.
*/
async _importChanges(
localRecords,
localTimestamp,
localMetadata,
expectedTimestamp,
options = {}
) {
const { retry = false } = options;
const since = retry || !localTimestamp ? undefined : `"${localTimestamp}"`;
// Fetch collection metadata and list of changes from server.
lazy.console.debug(
`${this.identifier} Fetch changes from server (expected=${expectedTimestamp}, since=${since})`
);
const { metadata, remoteTimestamp, remoteRecords } =
await this._fetchChangeset(expectedTimestamp, since);
// We build a sync result, based on remote changes.
const syncResult = {
current: localRecords,
created: [],
updated: [],
deleted: [],
};
// If data wasn't changed, return empty sync result.
// This can happen when we update the signature but not the data.
lazy.console.debug(
`${this.identifier} local timestamp: ${localTimestamp}, remote: ${remoteTimestamp}`
);
if (localTimestamp && remoteTimestamp < localTimestamp) {
return syncResult;
}
await this.db.importChanges(metadata, remoteTimestamp, remoteRecords, {
clear: retry,
});
// Read the new local data, after updating.
const newLocal = await this.db.list();
const newRecords = newLocal.map(r => this._cleanLocalFields(r));
// And verify the signature on what is now stored.
if (this.verifySignature) {
try {
await this._validateCollectionSignature(
newRecords,
remoteTimestamp,
metadata
);
} catch (e) {
lazy.console.error(
`${this.identifier} Signature failed ${retry ? "again" : ""} ${e}`
);
if (!(e instanceof InvalidSignatureError)) {
// If it failed for any other kind of error (eg. shutdown)
// then give up quickly.
throw e;
}
// In order to distinguish signature errors that happen
// during sync, from hijacks of local DBs, we will verify
// the signature on the data that we had before syncing.
let localTrustworthy = false;
lazy.console.debug(`${this.identifier} verify data before sync`);
try {
await this._validateCollectionSignature(
localRecords,
localTimestamp,
localMetadata
);
localTrustworthy = true;
} catch (sigerr) {
if (!(sigerr instanceof InvalidSignatureError)) {
// If it fails for other reason, keep original error and give up.
throw sigerr;
}
lazy.console.debug(`${this.identifier} previous data was invalid`);
}
if (!localTrustworthy && !retry) {
// Signature failed, clear local DB because it contains
// bad data (local + remote changes).
lazy.console.debug(`${this.identifier} clear local data`);
await this.db.clear();
// Local data was tampered, throw and it will retry from empty DB.
lazy.console.error(`${this.identifier} local data was corrupted`);
throw new CorruptedDataError(this.identifier);
} else if (retry) {
// We retried already, we will restore the previous local data
// before throwing eventually.
if (localTrustworthy) {
await this.db.importChanges(
localMetadata,
localTimestamp,
localRecords,
{
clear: true, // clear before importing.
}
);
} else {
// Restore the dump if available (no-op if no dump)
const imported = await this._importJSONDump();
// _importJSONDump() only clears DB if dump is available,
// therefore do it here!
if (imported < 0) {
await this.db.clear();
}
}
}
throw e;
}
} else {
lazy.console.warn(`${this.identifier} has signature disabled`);
}
if (this.hasListeners("sync")) {
// If we have some listeners for the "sync" event,
// Compute the changes, comparing records before and after.
syncResult.current = newRecords;
const oldById = new Map(localRecords.map(e => [e.id, e]));
for (const r of newRecords) {
const old = oldById.get(r.id);
if (old) {
oldById.delete(r.id);
if (r.last_modified != old.last_modified) {
syncResult.updated.push({ old, new: r });
}
} else {
syncResult.created.push(r);
}
}
syncResult.deleted = syncResult.deleted.concat(
Array.from(oldById.values())
);
lazy.console.debug(
`${this.identifier} ${syncResult.created.length} created. ${syncResult.updated.length} updated. ${syncResult.deleted.length} deleted.`
);
}
return syncResult;
}
/**
* Fetch information from changeset endpoint.
*
* @param expectedTimestamp cache busting value
* @param since timestamp of last sync (optional)
*/
async _fetchChangeset(expectedTimestamp, since) {
const client = this.httpClient();
const {
metadata,
timestamp: remoteTimestamp,
changes: remoteRecords,
} = await client.execute(
{
path: `/buckets/${this.bucketName}/collections/${this.collectionName}/changeset`,
},
{
query: {
_expected: expectedTimestamp,
_since: since,
},
}
);
return {
remoteTimestamp,
metadata,
remoteRecords,
};
}
/**
* Use the filter func to filter the lists of changes obtained from synchronization,
* and return them along with the filtered list of local records.
*
* If the filtered lists of changes are all empty, we return null (and thus don't
* bother listing local DB).
*
* @param {Object} syncResult Synchronization result without filtering.
*
* @returns {Promise<Object>} the filtered list of local records, plus the filtered
* list of created, updated and deleted records.
*/
async _filterSyncResult(syncResult) {
// Handle the obtained records (ie. apply locally through events).
// Build the event data list. It should be filtered (ie. by application target)
const {
current: allData,
created: allCreated,
updated: allUpdated,
deleted: allDeleted,
} = syncResult;
const [created, deleted, updatedFiltered] = await Promise.all(
[allCreated, allDeleted, allUpdated.map(e => e.new)].map(
this._filterEntries.bind(this)
)
);
// For updates, keep entries whose updated form matches the target.
const updatedFilteredIds = new Set(updatedFiltered.map(e => e.id));
const updated = allUpdated.filter(({ new: { id } }) =>
updatedFilteredIds.has(id)
);
if (!created.length && !updated.length && !deleted.length) {
return null;
}
// Read local collection of records (also filtered).
const current = await this._filterEntries(allData);
return { created, updated, deleted, current };
}
/**
* Filter entries for which calls to `this.filterFunc` returns null.
*
* @param {Array<Objet>} data
* @returns {Array<Object>}
*/
async _filterEntries(data) {
if (!this.filterFunc) {
return data;
}
const environment = cacheProxy(lazy.ClientEnvironmentBase);
const dataPromises = data.map(e => this.filterFunc(e, environment));
const results = await Promise.all(dataPromises);
return results.filter(Boolean);
}
/**
* Remove the fields from the specified record
* that are not present on server.
*
* @param {Object} record
*/
_cleanLocalFields(record) {
const keys = ["_status"].concat(this.localFields);
const result = { ...record };
for (const key of keys) {
delete result[key];
}
return result;
}
}