diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /services/sync/modules | |
parent | Initial commit. (diff) | |
download | firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
36 files changed, 20350 insertions, 0 deletions
diff --git a/services/sync/modules-testing/fakeservices.js b/services/sync/modules-testing/fakeservices.js new file mode 100644 index 0000000000..f13ef27c89 --- /dev/null +++ b/services/sync/modules-testing/fakeservices.js @@ -0,0 +1,124 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "FakeCryptoService", + "FakeFilesystemService", + "FakeGUIDService", + "fakeSHA256HMAC", +]; + +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { RawCryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +function FakeFilesystemService(contents) { + this.fakeContents = contents; + let self = this; + + // Save away the unmocked versions of the functions we replace here for tests + // that really want the originals. As this may be called many times per test, + // we must be careful to not replace them with ones we previously replaced. + // (And why are we bothering with these mocks in the first place? Is the + // performance of the filesystem *really* such that it outweighs the downside + // of not running our real JSON functions in the tests? Eg, these mocks don't + // always throw exceptions when the real ones do. Anyway...) + for (let name of ["jsonSave", "jsonLoad", "jsonMove", "jsonRemove"]) { + let origName = "_real_" + name; + if (!Utils[origName]) { + Utils[origName] = Utils[name]; + } + } + + Utils.jsonSave = async function jsonSave(filePath, that, obj) { + let json = typeof obj == "function" ? obj.call(that) : obj; + self.fakeContents["weave/" + filePath + ".json"] = JSON.stringify(json); + }; + + Utils.jsonLoad = async function jsonLoad(filePath, that) { + let obj; + let json = self.fakeContents["weave/" + filePath + ".json"]; + if (json) { + obj = JSON.parse(json); + } + return obj; + }; + + Utils.jsonMove = function jsonMove(aFrom, aTo, that) { + const fromPath = "weave/" + aFrom + ".json"; + self.fakeContents["weave/" + aTo + ".json"] = self.fakeContents[fromPath]; + delete self.fakeContents[fromPath]; + return Promise.resolve(); + }; + + Utils.jsonRemove = function jsonRemove(filePath, that) { + delete self.fakeContents["weave/" + filePath + ".json"]; + return Promise.resolve(); + }; +} + +function fakeSHA256HMAC(message) { + message = message.substr(0, 64); + while (message.length < 64) { + message += " "; + } + return message; +} + +function FakeGUIDService() { + let latestGUID = 0; + + Utils.makeGUID = function makeGUID() { + // ensure that this always returns a unique 12 character string + let nextGUID = "fake-guid-" + String(latestGUID++).padStart(2, "0"); + return nextGUID.slice(nextGUID.length - 12, nextGUID.length); + }; +} + +/* + * Mock implementation of WeaveCrypto. It does not encrypt or + * decrypt, merely returning the input verbatim. + */ +function FakeCryptoService() { + this.counter = 0; + + delete Weave.Crypto; // get rid of the getter first + Weave.Crypto = this; + + RawCryptoWrapper.prototype.ciphertextHMAC = function ciphertextHMAC( + keyBundle + ) { + return fakeSHA256HMAC(this.ciphertext); + }; +} +FakeCryptoService.prototype = { + async encrypt(clearText, symmetricKey, iv) { + return clearText; + }, + + async decrypt(cipherText, symmetricKey, iv) { + return cipherText; + }, + + async generateRandomKey() { + return btoa("fake-symmetric-key-" + this.counter++); + }, + + generateRandomIV: function generateRandomIV() { + // A base64-encoded IV is 24 characters long + return btoa("fake-fake-fake-random-iv"); + }, + + expandData: function expandData(data, len) { + return data; + }, + + generateRandomBytes: function generateRandomBytes(byteCount) { + return "not-so-random-now-are-we-HA-HA-HA! >:)".slice(byteCount); + }, +}; diff --git a/services/sync/modules-testing/fxa_utils.js b/services/sync/modules-testing/fxa_utils.js new file mode 100644 index 0000000000..64df324ea2 --- /dev/null +++ b/services/sync/modules-testing/fxa_utils.js @@ -0,0 +1,63 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["initializeIdentityWithTokenServerResponse"]; + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { BrowserIDManager } = ChromeUtils.import( + "resource://services-sync/browserid_identity.js" +); +const { TokenServerClient } = ChromeUtils.import( + "resource://services-common/tokenserverclient.js" +); +const { configureFxAccountIdentity } = ChromeUtils.import( + "resource://testing-common/services/sync/utils.js" +); + +// Create a new browserid_identity object and initialize it with a +// mocked TokenServerClient which always receives the specified response. +var initializeIdentityWithTokenServerResponse = function(response) { + // First create a mock "request" object that well' hack into the token server. + // A log for it + let requestLog = Log.repository.getLogger("testing.mock-rest"); + if (!requestLog.appenders.length) { + // might as well see what it says :) + requestLog.addAppender(new Log.DumpAppender()); + requestLog.level = Log.Level.Trace; + } + + // A mock request object. + function MockRESTRequest(url) {} + MockRESTRequest.prototype = { + _log: requestLog, + setHeader() {}, + async get() { + this.response = response; + return response; + }, + }; + // The mocked TokenServer client which will get the response. + function MockTSC() {} + MockTSC.prototype = new TokenServerClient(); + MockTSC.prototype.constructor = MockTSC; + MockTSC.prototype.newRESTRequest = function(url) { + return new MockRESTRequest(url); + }; + // Arrange for the same observerPrefix as browserid_identity uses. + MockTSC.prototype.observerPrefix = "weave:service"; + + // tie it all together. + Weave.Status.__authManager = Weave.Service.identity = new BrowserIDManager(); + let browseridManager = Weave.Service.identity; + // a sanity check + if (!(browseridManager instanceof BrowserIDManager)) { + throw new Error("sync isn't configured for browserid_identity"); + } + let mockTSC = new MockTSC(); + configureFxAccountIdentity(browseridManager); + browseridManager._tokenServerClient = mockTSC; +}; diff --git a/services/sync/modules-testing/rotaryengine.js b/services/sync/modules-testing/rotaryengine.js new file mode 100644 index 0000000000..888c7fe6f2 --- /dev/null +++ b/services/sync/modules-testing/rotaryengine.js @@ -0,0 +1,128 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "RotaryEngine", + "RotaryRecord", + "RotaryStore", + "RotaryTracker", +]; + +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { SerializableSet, Utils } = ChromeUtils.import( + "resource://services-sync/util.js" +); + +/* + * A fake engine implementation. + * This is used all over the place. + * + * Complete with record, store, and tracker implementations. + */ + +function RotaryRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} +RotaryRecord.prototype = { + __proto__: CryptoWrapper.prototype, +}; +Utils.deferGetSet(RotaryRecord, "cleartext", ["denomination"]); + +function RotaryStore(name, engine) { + Store.call(this, name, engine); + this.items = {}; +} +RotaryStore.prototype = { + __proto__: Store.prototype, + + async create(record) { + this.items[record.id] = record.denomination; + }, + + async remove(record) { + delete this.items[record.id]; + }, + + async update(record) { + this.items[record.id] = record.denomination; + }, + + async itemExists(id) { + return id in this.items; + }, + + async createRecord(id, collection) { + let record = new RotaryRecord(collection, id); + + if (!(id in this.items)) { + record.deleted = true; + return record; + } + + record.denomination = this.items[id] || "Data for new record: " + id; + return record; + }, + + async changeItemID(oldID, newID) { + if (oldID in this.items) { + this.items[newID] = this.items[oldID]; + } + + delete this.items[oldID]; + }, + + async getAllIDs() { + let ids = {}; + for (let id in this.items) { + ids[id] = true; + } + return ids; + }, + + async wipe() { + this.items = {}; + }, +}; + +function RotaryTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +RotaryTracker.prototype = { + __proto__: LegacyTracker.prototype, +}; + +function RotaryEngine(service) { + SyncEngine.call(this, "Rotary", service); + // Ensure that the engine starts with a clean slate. + this.toFetch = new SerializableSet(); + this.previousFailed = new SerializableSet(); +} +RotaryEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: RotaryStore, + _trackerObj: RotaryTracker, + _recordObj: RotaryRecord, + + async _findDupe(item) { + // This is a Special Value® used for testing proper reconciling on dupe + // detection. + if (item.id == "DUPE_INCOMING") { + return "DUPE_LOCAL"; + } + + for (let [id, value] of Object.entries(this._store.items)) { + if (item.denomination == value) { + return id; + } + } + return null; + }, +}; diff --git a/services/sync/modules-testing/utils.js b/services/sync/modules-testing/utils.js new file mode 100644 index 0000000000..ae3741d60f --- /dev/null +++ b/services/sync/modules-testing/utils.js @@ -0,0 +1,373 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "encryptPayload", + "makeIdentityConfig", + "makeFxAccountsInternalMock", + "configureFxAccountIdentity", + "configureIdentity", + "SyncTestingInfrastructure", + "waitForZeroTimer", + "promiseZeroTimer", + "promiseNamedTimer", + "MockFxaStorageManager", + "AccountState", // from a module import + "sumHistogram", + "syncTestLogging", +]; + +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); +const { Assert } = ChromeUtils.import("resource://testing-common/Assert.jsm"); +const { initTestLogging } = ChromeUtils.import( + "resource://testing-common/services/common/logging.js" +); +const { + FakeCryptoService, + FakeFilesystemService, + FakeGUIDService, + fakeSHA256HMAC, +} = ChromeUtils.import( + "resource://testing-common/services/sync/fakeservices.js" +); +const { FxAccounts } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" +); +const { FxAccountsClient } = ChromeUtils.import( + "resource://gre/modules/FxAccountsClient.jsm" +); +const { SCOPE_OLD_SYNC, LEGACY_SCOPE_WEBEXT_SYNC } = ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// and grab non-exported stuff via a backstage pass. +const { AccountState } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm", + null +); + +// A mock "storage manager" for FxAccounts that doesn't actually write anywhere. +function MockFxaStorageManager() {} + +MockFxaStorageManager.prototype = { + promiseInitialized: Promise.resolve(), + + initialize(accountData) { + this.accountData = accountData; + }, + + finalize() { + return Promise.resolve(); + }, + + getAccountData(fields = null) { + let result; + if (!this.accountData) { + result = null; + } else if (fields == null) { + // can't use cloneInto as the keys get upset... + result = {}; + for (let field of Object.keys(this.accountData)) { + result[field] = this.accountData[field]; + } + } else { + if (!Array.isArray(fields)) { + fields = [fields]; + } + result = {}; + for (let field of fields) { + result[field] = this.accountData[field]; + } + } + return Promise.resolve(result); + }, + + updateAccountData(updatedFields) { + for (let [name, value] of Object.entries(updatedFields)) { + if (value == null) { + delete this.accountData[name]; + } else { + this.accountData[name] = value; + } + } + return Promise.resolve(); + }, + + deleteAccountData() { + this.accountData = null; + return Promise.resolve(); + }, +}; + +/** + * First wait >100ms (nsITimers can take up to that much time to fire, so + * we can account for the timer in delayedAutoconnect) and then two event + * loop ticks (to account for the CommonUtils.nextTick() in autoConnect). + */ +function waitForZeroTimer(callback) { + let ticks = 2; + function wait() { + if (ticks) { + ticks -= 1; + CommonUtils.nextTick(wait); + return; + } + callback(); + } + CommonUtils.namedTimer(wait, 150, {}, "timer"); +} + +var promiseZeroTimer = function() { + return new Promise(resolve => { + waitForZeroTimer(resolve); + }); +}; + +var promiseNamedTimer = function(wait, thisObj, name) { + return new Promise(resolve => { + CommonUtils.namedTimer(resolve, wait, thisObj, name); + }); +}; + +// Return an identity configuration suitable for testing with our identity +// providers. |overrides| can specify overrides for any default values. +// |server| is optional, but if specified, will be used to form the cluster +// URL for the FxA identity. +var makeIdentityConfig = function(overrides) { + // first setup the defaults. + let result = { + // Username used in both fxaccount and sync identity configs. + username: "foo", + // fxaccount specific credentials. + fxaccount: { + user: { + assertion: "assertion", + email: "foo", + kSync: "a".repeat(128), + kXCS: "b".repeat(32), + kExtSync: "c".repeat(128), + kExtKbHash: "d".repeat(64), + scopedKeys: { + [SCOPE_OLD_SYNC]: { + kid: "1234567890123-u7u7u7u7u7u7u7u7u7u7uw", + k: + "qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg", + kty: "oct", + }, + [LEGACY_SCOPE_WEBEXT_SYNC]: { + kid: "1234567890123-3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d0", + k: + "zMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzMzA", + kty: "oct", + }, + }, + sessionToken: "sessionToken", + uid: "a".repeat(32), + verified: true, + }, + token: { + endpoint: null, + duration: 300, + id: "id", + key: "key", + hashed_fxa_uid: "f".repeat(32), // used during telemetry validation + // uid will be set to the username. + }, + }, + }; + + // Now handle any specified overrides. + if (overrides) { + if (overrides.username) { + result.username = overrides.username; + } + if (overrides.fxaccount) { + // TODO: allow just some attributes to be specified + result.fxaccount = overrides.fxaccount; + } + if (overrides.node_type) { + result.fxaccount.token.node_type = overrides.node_type; + } + } + return result; +}; + +var makeFxAccountsInternalMock = function(config) { + return { + newAccountState(credentials) { + // We only expect this to be called with null indicating the (mock) + // storage should be read. + if (credentials) { + throw new Error("Not expecting to have credentials passed"); + } + let storageManager = new MockFxaStorageManager(); + storageManager.initialize(config.fxaccount.user); + let accountState = new AccountState(storageManager); + return accountState; + }, + _getAssertion(audience) { + return Promise.resolve(config.fxaccount.user.assertion); + }, + getOAuthToken: () => Promise.resolve("some-access-token"), + keys: { + getScopedKeys: () => + Promise.resolve({ + "https://identity.mozilla.com/apps/oldsync": { + identifier: "https://identity.mozilla.com/apps/oldsync", + keyRotationSecret: + "0000000000000000000000000000000000000000000000000000000000000000", + keyRotationTimestamp: 1510726317123, + }, + }), + }, + profile: { + getProfile() { + return null; + }, + }, + }; +}; + +// Configure an instance of an FxAccount identity provider with the specified +// config (or the default config if not specified). +var configureFxAccountIdentity = function( + authService, + config = makeIdentityConfig(), + fxaInternal = makeFxAccountsInternalMock(config) +) { + // until we get better test infrastructure for bid_identity, we set the + // signedin user's "email" to the username, simply as many tests rely on this. + config.fxaccount.user.email = config.username; + + let fxa = new FxAccounts(fxaInternal); + + let MockFxAccountsClient = function() { + FxAccountsClient.apply(this); + }; + MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype, + accountStatus() { + return Promise.resolve(true); + }, + }; + let mockFxAClient = new MockFxAccountsClient(); + fxa._internal._fxAccountsClient = mockFxAClient; + + let mockTSC = { + // TokenServerClient + async getTokenFromBrowserIDAssertion(uri, assertion) { + Assert.equal( + uri, + Services.prefs.getStringPref("identity.sync.tokenserver.uri") + ); + Assert.equal(assertion, config.fxaccount.user.assertion); + config.fxaccount.token.uid = config.username; + return config.fxaccount.token; + }, + async getTokenFromOAuthToken(url, oauthToken) { + Assert.equal( + url, + Services.prefs.getStringPref("identity.sync.tokenserver.uri") + ); + Assert.ok(oauthToken, "oauth token present"); + config.fxaccount.token.uid = config.username; + return config.fxaccount.token; + }, + }; + authService._fxaService = fxa; + authService._tokenServerClient = mockTSC; + // Set the "account" of the browserId manager to be the "email" of the + // logged in user of the mockFXA service. + authService._signedInUser = config.fxaccount.user; + authService._account = config.fxaccount.user.email; +}; + +var configureIdentity = async function(identityOverrides, server) { + let config = makeIdentityConfig(identityOverrides, server); + let ns = {}; + ChromeUtils.import("resource://services-sync/service.js", ns); + + // If a server was specified, ensure FxA has a correct cluster URL available. + if (server && !config.fxaccount.token.endpoint) { + let ep = server.baseURI; + if (!ep.endsWith("/")) { + ep += "/"; + } + ep += "1.1/" + config.username + "/"; + config.fxaccount.token.endpoint = ep; + } + + configureFxAccountIdentity(ns.Service.identity, config); + Services.prefs.setStringPref("services.sync.username", config.username); + // many of these tests assume all the auth stuff is setup and don't hit + // a path which causes that auth to magically happen - so do it now. + await ns.Service.identity._ensureValidToken(); + + // and cheat to avoid requiring each test do an explicit login - give it + // a cluster URL. + if (config.fxaccount.token.endpoint) { + ns.Service.clusterURL = config.fxaccount.token.endpoint; + } +}; + +function syncTestLogging(level = "Trace") { + let logStats = initTestLogging(level); + Services.prefs.setStringPref("services.sync.log.logger", level); + Services.prefs.setStringPref("services.sync.log.logger.engine", ""); + return logStats; +} + +var SyncTestingInfrastructure = async function(server, username) { + let ns = {}; + ChromeUtils.import("resource://services-sync/service.js", ns); + + let config = makeIdentityConfig({ username }); + await configureIdentity(config, server); + return { + logStats: syncTestLogging(), + fakeFilesystem: new FakeFilesystemService({}), + fakeGUIDService: new FakeGUIDService(), + fakeCryptoService: new FakeCryptoService(), + }; +}; + +/** + * Turn WBO cleartext into fake "encrypted" payload as it goes over the wire. + */ +function encryptPayload(cleartext) { + if (typeof cleartext == "object") { + cleartext = JSON.stringify(cleartext); + } + + return { + ciphertext: cleartext, // ciphertext == cleartext with fake crypto + IV: "irrelevant", + hmac: fakeSHA256HMAC(cleartext, CryptoUtils.makeHMACKey("")), + }; +} + +var sumHistogram = function(name, options = {}) { + let histogram = options.key + ? Services.telemetry.getKeyedHistogramById(name) + : Services.telemetry.getHistogramById(name); + let snapshot = histogram.snapshot(); + let sum = -Infinity; + if (snapshot) { + if (options.key && snapshot[options.key]) { + sum = snapshot[options.key].sum; + } else { + sum = snapshot.sum; + } + } + histogram.clear(); + return sum; +}; diff --git a/services/sync/modules/SyncDisconnect.jsm b/services/sync/modules/SyncDisconnect.jsm new file mode 100644 index 0000000000..8553265fd1 --- /dev/null +++ b/services/sync/modules/SyncDisconnect.jsm @@ -0,0 +1,237 @@ +// 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/. + +// This module provides a facility for disconnecting Sync and FxA, optionally +// sanitizing profile data as part of the process. + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Services: "resource://gre/modules/Services.jsm", + Log: "resource://gre/modules/Log.jsm", + Sanitizer: "resource:///modules/Sanitizer.jsm", + AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm", + fxAccounts: "resource://gre/modules/FxAccounts.jsm", + setTimeout: "resource://gre/modules/Timer.jsm", + Utils: "resource://services-sync/util.js", +}); + +XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function() { + return ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js", {}); +}); + +this.EXPORTED_SYMBOLS = ["SyncDisconnectInternal", "SyncDisconnect"]; + +this.SyncDisconnectInternal = { + lockRetryInterval: 1000, // wait 1 seconds before trying for the lock again. + lockRetryCount: 120, // Try 120 times (==2 mins) before giving up in disgust. + promiseDisconnectFinished: null, // If we are sanitizing, a promise for completion. + + // mocked by tests. + getWeave() { + return ChromeUtils.import("resource://services-sync/main.js", {}).Weave; + }, + + // Returns a promise that resolves when we are not syncing, waiting until + // a current Sync completes if necessary. Resolves with true if we + // successfully waited, in which case the sync lock will have been taken to + // ensure future syncs don't state, or resolves with false if we gave up + // waiting for the sync to complete (in which case we didn't take a lock - + // but note that Sync probably remains locked in this case regardless.) + async promiseNotSyncing(abortController) { + let weave = this.getWeave(); + let log = Log.repository.getLogger("Sync.Service"); + // We might be syncing - poll for up to 2 minutes waiting for the lock. + // (2 minutes seems extreme, but should be very rare.) + return new Promise(resolve => { + abortController.signal.onabort = () => { + resolve(false); + }; + + let attempts = 0; + let checkLock = () => { + if (abortController.signal.aborted) { + // We've already resolved, so don't want a new timer to ever start. + return; + } + if (weave.Service.lock()) { + resolve(true); + return; + } + attempts += 1; + if (attempts >= this.lockRetryCount) { + log.error( + "Gave up waiting for the sync lock - going ahead with sanitize anyway" + ); + resolve(false); + return; + } + log.debug("Waiting a couple of seconds to get the sync lock"); + setTimeout(checkLock, this.lockRetryInterval); + }; + checkLock(); + }); + }, + + // Sanitize Sync-related data. + async doSanitizeSyncData() { + let weave = this.getWeave(); + // Get the sync logger - if stuff goes wrong it can be useful to have that + // recorded in the sync logs. + let log = Log.repository.getLogger("Sync.Service"); + log.info("Starting santitize of Sync data"); + try { + // We clobber data for all Sync engines that are enabled. + await weave.Service.promiseInitialized; + weave.Service.enabled = false; + + log.info("starting actual sanitization"); + for (let engine of weave.Service.engineManager.getAll()) { + if (engine.enabled) { + try { + log.info("Wiping engine", engine.name); + await engine.wipeClient(); + } catch (ex) { + log.error("Failed to wipe engine", ex); + } + } + } + // Reset the pref which is used to show a warning when a different user + // signs in - this is no longer a concern now that we've removed the + // data from the profile. + Services.prefs.clearUserPref(FxAccountsCommon.PREF_LAST_FXA_USER); + + log.info("Finished wiping sync data"); + } catch (ex) { + log.error("Failed to sanitize Sync data", ex); + console.error("Failed to sanitize Sync data", ex); + } + try { + // ensure any logs we wrote are flushed to disk. + await weave.Service.errorHandler.resetFileLog(); + } catch (ex) { + console.log("Failed to flush the Sync log", ex); + } + }, + + // Sanitize all Browser data. + async doSanitizeBrowserData() { + try { + // sanitize everything other than "open windows" (and we don't do that + // because it may confuse the user - they probably want to see + // about:prefs with the disconnection reflected. + let itemsToClear = Object.keys(Sanitizer.items).filter( + k => k != "openWindows" + ); + await Sanitizer.sanitize(itemsToClear); + } catch (ex) { + console.error("Failed to sanitize other data", ex); + } + }, + + async doSyncAndAccountDisconnect(shouldUnlock) { + // We do a startOver of Sync first - if we do the account first we end + // up with Sync configured but FxA not configured, which causes the browser + // UI to briefly enter a "needs reauth" state. + let Weave = this.getWeave(); + await Weave.Service.promiseInitialized; + await Weave.Service.startOver(); + await fxAccounts.signOut(); + // Sync may have been disabled if we santized, so re-enable it now or + // else the user will be unable to resync should they sign in before a + // restart. + Weave.Service.enabled = true; + + // and finally, if we managed to get the lock before, we should unlock it + // now. + if (shouldUnlock) { + Weave.Service.unlock(); + } + }, + + // Start the sanitization process. Returns a promise that resolves when + // the sanitize is complete, and an AbortController which can be used to + // abort the process of waiting for a sync to complete. + async _startDisconnect(abortController, sanitizeData = false) { + // This is a bit convoluted - we want to wait for a sync to finish before + // sanitizing, but want to abort that wait if the browser shuts down while + // we are waiting (in which case we'll charge ahead anyway). + // So we do this by using an AbortController and passing that to the + // function that waits for the sync lock - it will immediately resolve + // if the abort controller is aborted. + let log = Log.repository.getLogger("Sync.Service"); + + // If the master-password is locked then we will fail to fully sanitize, + // so prompt for that now. If canceled, we just abort now. + log.info("checking master-password state"); + if (!Utils.ensureMPUnlocked()) { + log.warn( + "The master-password needs to be unlocked to fully disconnect from sync" + ); + return; + } + + log.info("waiting for any existing syncs to complete"); + let locked = await this.promiseNotSyncing(abortController); + + if (sanitizeData) { + await this.doSanitizeSyncData(); + + // We disconnect before sanitizing the browser data - in a worst-case + // scenario where the sanitize takes so long that even the shutdown + // blocker doesn't allow it to finish, we should still at least be in + // a disconnected state on the next startup. + log.info("disconnecting account"); + await this.doSyncAndAccountDisconnect(locked); + + await this.doSanitizeBrowserData(); + } else { + log.info("disconnecting account"); + await this.doSyncAndAccountDisconnect(locked); + } + }, + + async disconnect(sanitizeData) { + if (this.promiseDisconnectFinished) { + throw new Error("A disconnect is already in progress"); + } + let abortController = new AbortController(); + let promiseDisconnectFinished = this._startDisconnect( + abortController, + sanitizeData + ); + this.promiseDisconnectFinished = promiseDisconnectFinished; + let shutdownBlocker = () => { + // oh dear - we are sanitizing (probably stuck waiting for a sync to + // complete) and the browser is shutting down. Let's avoid the wait + // for sync to complete and continue the process anyway. + abortController.abort(); + return promiseDisconnectFinished; + }; + AsyncShutdown.quitApplicationGranted.addBlocker( + "SyncDisconnect: removing requested data", + shutdownBlocker + ); + + // wait for it to finish - hopefully without the blocker being called. + await promiseDisconnectFinished; + this.promiseDisconnectFinished = null; + + // sanitize worked so remove our blocker - it's a noop if the blocker + // did call us. + AsyncShutdown.quitApplicationGranted.removeBlocker(shutdownBlocker); + }, +}; + +this.SyncDisconnect = { + get promiseDisconnectFinished() { + return SyncDisconnectInternal.promiseDisconnectFinished; + }, + + disconnect(sanitizeData) { + return SyncDisconnectInternal.disconnect(sanitizeData); + }, +}; diff --git a/services/sync/modules/SyncedTabs.jsm b/services/sync/modules/SyncedTabs.jsm new file mode 100644 index 0000000000..5725b60f18 --- /dev/null +++ b/services/sync/modules/SyncedTabs.jsm @@ -0,0 +1,276 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SyncedTabs"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +// The Sync XPCOM service +XPCOMUtils.defineLazyGetter(this, "weaveXPCService", function() { + return Cc["@mozilla.org/weave/service;1"].getService( + Ci.nsISupports + ).wrappedJSObject; +}); + +// from MDN... +function escapeRegExp(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// A topic we fire whenever we have new tabs available. This might be due +// to a request made by this module to refresh the tab list, or as the result +// of a regularly scheduled sync. The intent is that consumers just listen +// for this notification and update their UI in response. +const TOPIC_TABS_CHANGED = "services.sync.tabs.changed"; + +// The interval, in seconds, before which we consider the existing list +// of tabs "fresh enough" and don't force a new sync. +const TABS_FRESH_ENOUGH_INTERVAL = 30; + +XPCOMUtils.defineLazyGetter(this, "log", function() { + let log = Log.repository.getLogger("Sync.RemoteTabs"); + log.manageLevelFromPref("services.sync.log.logger.tabs"); + return log; +}); + +// A private singleton that does the work. +let SyncedTabsInternal = { + /* Make a "tab" record. Returns a promise */ + async _makeTab(client, tab, url, showRemoteIcons) { + let icon; + if (showRemoteIcons) { + icon = tab.icon; + } + if (!icon) { + // By not specifying a size the favicon service will pick the default, + // that is usually set through setDefaultIconURIPreferredSize by the + // first browser window. Commonly it's 16px at current dpi. + icon = "page-icon:" + url; + } + return { + type: "tab", + title: tab.title || url, + url, + icon, + client: client.id, + lastUsed: tab.lastUsed, + }; + }, + + /* Make a "client" record. Returns a promise for consistency with _makeTab */ + async _makeClient(client) { + return { + id: client.id, + type: "client", + name: Weave.Service.clientsEngine.getClientName(client.id), + clientType: Weave.Service.clientsEngine.getClientType(client.id), + lastModified: client.lastModified * 1000, // sec to ms + tabs: [], + }; + }, + + _tabMatchesFilter(tab, filter) { + let reFilter = new RegExp(escapeRegExp(filter), "i"); + return reFilter.test(tab.url) || reFilter.test(tab.title); + }, + + async getTabClients(filter) { + log.info("Generating tab list with filter", filter); + let result = []; + + // If Sync isn't ready, don't try and get anything. + if (!weaveXPCService.ready) { + log.debug("Sync isn't yet ready, so returning an empty tab list"); + return result; + } + + // A boolean that controls whether we should show the icon from the remote tab. + const showRemoteIcons = Preferences.get( + "services.sync.syncedTabs.showRemoteIcons", + true + ); + + let engine = Weave.Service.engineManager.get("tabs"); + + let ntabs = 0; + + for (let client of Object.values(engine.getAllClients())) { + if (!Weave.Service.clientsEngine.remoteClientExists(client.id)) { + continue; + } + let clientRepr = await this._makeClient(client); + log.debug("Processing client", clientRepr); + + for (let tab of client.tabs) { + let url = tab.urlHistory[0]; + log.trace("remote tab", url); + + if (!url) { + continue; + } + let tabRepr = await this._makeTab(client, tab, url, showRemoteIcons); + if (filter && !this._tabMatchesFilter(tabRepr, filter)) { + continue; + } + clientRepr.tabs.push(tabRepr); + } + // We return all clients, even those without tabs - the consumer should + // filter it if they care. + ntabs += clientRepr.tabs.length; + result.push(clientRepr); + } + log.info(`Final tab list has ${result.length} clients with ${ntabs} tabs.`); + return result; + }, + + async syncTabs(force) { + if (!force) { + // Don't bother refetching tabs if we already did so recently + let lastFetch = Preferences.get("services.sync.lastTabFetch", 0); + let now = Math.floor(Date.now() / 1000); + if (now - lastFetch < TABS_FRESH_ENOUGH_INTERVAL) { + log.info("_refetchTabs was done recently, do not doing it again"); + return false; + } + } + + // If Sync isn't configured don't try and sync, else we will get reports + // of a login failure. + if (Weave.Status.checkSetup() == Weave.CLIENT_NOT_CONFIGURED) { + log.info("Sync client is not configured, so not attempting a tab sync"); + return false; + } + // Ask Sync to just do the tabs engine if it can. + try { + log.info("Doing a tab sync."); + await Weave.Service.sync({ why: "tabs", engines: ["tabs"] }); + return true; + } catch (ex) { + log.error("Sync failed", ex); + throw ex; + } + }, + + observe(subject, topic, data) { + log.trace(`observed topic=${topic}, data=${data}, subject=${subject}`); + switch (topic) { + case "weave:engine:sync:finish": + if (data != "tabs") { + return; + } + // The tabs engine just finished syncing + // Set our lastTabFetch pref here so it tracks both explicit sync calls + // and normally scheduled ones. + Preferences.set( + "services.sync.lastTabFetch", + Math.floor(Date.now() / 1000) + ); + Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); + break; + case "weave:service:start-over": + // start-over needs to notify so consumers find no tabs. + Preferences.reset("services.sync.lastTabFetch"); + Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); + break; + case "nsPref:changed": + Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED); + break; + default: + break; + } + }, + + // Returns true if Sync is configured to Sync tabs, false otherwise + get isConfiguredToSyncTabs() { + if (!weaveXPCService.ready) { + log.debug("Sync isn't yet ready; assuming tab engine is enabled"); + return true; + } + + let engine = Weave.Service.engineManager.get("tabs"); + return engine && engine.enabled; + }, + + get hasSyncedThisSession() { + let engine = Weave.Service.engineManager.get("tabs"); + return engine && engine.hasSyncedThisSession; + }, +}; + +Services.obs.addObserver(SyncedTabsInternal, "weave:engine:sync:finish"); +Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over"); +// Observe the pref the indicates the state of the tabs engine has changed. +// This will force consumers to re-evaluate the state of sync and update +// accordingly. +Services.prefs.addObserver("services.sync.engine.tabs", SyncedTabsInternal); + +// The public interface. +var SyncedTabs = { + // A mock-point for tests. + _internal: SyncedTabsInternal, + + // We make the topic for the observer notification public. + TOPIC_TABS_CHANGED, + + // Returns true if Sync is configured to Sync tabs, false otherwise + get isConfiguredToSyncTabs() { + return this._internal.isConfiguredToSyncTabs; + }, + + // Returns true if a tab sync has completed once this session. If this + // returns false, then getting back no clients/tabs possibly just means we + // are waiting for that first sync to complete. + get hasSyncedThisSession() { + return this._internal.hasSyncedThisSession; + }, + + // Return a promise that resolves with an array of client records, each with + // a .tabs array. Note that part of the contract for this module is that the + // returned objects are not shared between invocations, so callers are free + // to mutate the returned objects (eg, sort, truncate) however they see fit. + getTabClients(query) { + return this._internal.getTabClients(query); + }, + + // Starts a background request to start syncing tabs. Returns a promise that + // resolves when the sync is complete, but there's no resolved value - + // callers should be listening for TOPIC_TABS_CHANGED. + // If |force| is true we always sync. If false, we only sync if the most + // recent sync wasn't "recently". + syncTabs(force) { + return this._internal.syncTabs(force); + }, + + sortTabClientsByLastUsed(clients) { + // First sort the list of tabs for each client. Note that + // this module promises that the objects it returns are never + // shared, so we are free to mutate those objects directly. + for (let client of clients) { + let tabs = client.tabs; + tabs.sort((a, b) => b.lastUsed - a.lastUsed); + } + // Now sort the clients - the clients are sorted in the order of the + // most recent tab for that client (ie, it is important the tabs for + // each client are already sorted.) + clients.sort((a, b) => { + if (a.tabs.length == 0) { + return 1; // b comes first. + } + if (b.tabs.length == 0) { + return -1; // a comes first. + } + return b.tabs[0].lastUsed - a.tabs[0].lastUsed; + }); + }, +}; diff --git a/services/sync/modules/UIState.jsm b/services/sync/modules/UIState.jsm new file mode 100644 index 0000000000..64b16a9f7d --- /dev/null +++ b/services/sync/modules/UIState.jsm @@ -0,0 +1,289 @@ +/* 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/. */ + +"use strict"; + +/** + * @typedef {Object} UIState + * @property {string} status The Sync/FxA status, see STATUS_* constants. + * @property {string} [email] The FxA email configured to log-in with Sync. + * @property {string} [displayName] The user's FxA display name. + * @property {string} [avatarURL] The user's FxA avatar URL. + * @property {Date} [lastSync] The last sync time. + * @property {boolean} [syncing] Whether or not we are currently syncing. + */ + +var EXPORTED_SYMBOLS = ["UIState"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + this, + "Weave", + "resource://services-sync/main.js" +); + +const TOPICS = [ + "weave:connected", + "weave:service:login:change", + "weave:service:login:error", + "weave:service:ready", + "weave:service:sync:start", + "weave:service:sync:finish", + "weave:service:sync:error", + "weave:service:start-over:finish", + "fxaccounts:onverified", + "fxaccounts:onlogin", // Defined in FxAccountsCommon, pulling it is expensive. + "fxaccounts:onlogout", + "fxaccounts:profilechange", + "fxaccounts:statechange", +]; + +const ON_UPDATE = "sync-ui-state:update"; + +const STATUS_NOT_CONFIGURED = "not_configured"; +const STATUS_LOGIN_FAILED = "login_failed"; +const STATUS_NOT_VERIFIED = "not_verified"; +const STATUS_SIGNED_IN = "signed_in"; + +const DEFAULT_STATE = { + status: STATUS_NOT_CONFIGURED, +}; + +const UIStateInternal = { + _initialized: false, + _state: null, + + // We keep _syncing out of the state object because we can only track it + // using sync events and we can't determine it at any point in time. + _syncing: false, + + get state() { + if (!this._state) { + return DEFAULT_STATE; + } + return Object.assign({}, this._state, { syncing: this._syncing }); + }, + + isReady() { + if (!this._initialized) { + this.init(); + return false; + } + return true; + }, + + init() { + this._initialized = true; + // Because the FxA toolbar is usually visible, this module gets loaded at + // browser startup, and we want to avoid pulling in all of FxA or Sync at + // that time, so we refresh the state after the browser has settled. + Services.tm.idleDispatchToMainThread(() => { + this.refreshState().catch(e => { + Cu.reportError(e); + }); + }, 2000); + }, + + // Used for testing. + reset() { + this._state = null; + this._syncing = false; + this._initialized = false; + }, + + observe(subject, topic, data) { + switch (topic) { + case "weave:service:sync:start": + this.toggleSyncActivity(true); + break; + case "weave:service:sync:finish": + case "weave:service:sync:error": + this.toggleSyncActivity(false); + break; + default: + this.refreshState().catch(e => { + Cu.reportError(e); + }); + break; + } + }, + + // Builds a new state from scratch. + async refreshState() { + const newState = {}; + await this._refreshFxAState(newState); + // Optimize the "not signed in" case to avoid refreshing twice just after + // startup - if there's currently no _state, and we still aren't configured, + // just early exit. + if (this._state == null && newState.status == DEFAULT_STATE.status) { + return this.state; + } + if (newState.syncEnabled) { + this._setLastSyncTime(newState); // We want this in case we change accounts. + } + this._state = newState; + + this.notifyStateUpdated(); + return this.state; + }, + + // Update the current state with the last sync time/currently syncing status. + toggleSyncActivity(syncing) { + this._syncing = syncing; + this._setLastSyncTime(this._state); + + this.notifyStateUpdated(); + }, + + notifyStateUpdated() { + Services.obs.notifyObservers(null, ON_UPDATE); + }, + + async _refreshFxAState(newState) { + let userData = await this._getUserData(); + await this._populateWithUserData(newState, userData); + }, + + async _populateWithUserData(state, userData) { + let status; + let syncUserName = Services.prefs.getStringPref( + "services.sync.username", + "" + ); + if (!userData) { + // If Sync thinks it is configured but there's no FxA user, then we + // want to enter the "login failed" state so the user can get + // reconfigured. + if (syncUserName) { + state.email = syncUserName; + status = STATUS_LOGIN_FAILED; + } else { + // everyone agrees nothing is configured. + status = STATUS_NOT_CONFIGURED; + } + } else { + let loginFailed = await this._loginFailed(); + if (loginFailed) { + status = STATUS_LOGIN_FAILED; + } else if (!userData.verified) { + status = STATUS_NOT_VERIFIED; + } else { + status = STATUS_SIGNED_IN; + } + state.uid = userData.uid; + state.email = userData.email; + state.displayName = userData.displayName; + // for better or worse, this module renames these attribues. + state.avatarURL = userData.avatar; + state.avatarIsDefault = userData.avatarDefault; + state.syncEnabled = !!syncUserName; + } + state.status = status; + }, + + async _getUserData() { + try { + return await this.fxAccounts.getSignedInUser(); + } catch (e) { + // This is most likely in tests, where we quickly log users in and out. + // The most likely scenario is a user logged out, so reflect that. + // Bug 995134 calls for better errors so we could retry if we were + // sure this was the failure reason. + Cu.reportError("Error updating FxA account info: " + e); + return null; + } + }, + + _setLastSyncTime(state) { + if (state.status == UIState.STATUS_SIGNED_IN) { + const lastSync = Services.prefs.getCharPref( + "services.sync.lastSync", + null + ); + state.lastSync = lastSync ? new Date(lastSync) : null; + } + }, + + async _loginFailed() { + // First ask FxA if it thinks the user needs re-authentication. In practice, + // this check is probably canonical (ie, we probably don't really need + // the check below at all as we drop local session info on the first sign + // of a problem) - but we keep it for now to keep the risk down. + let hasLocalSession = await this.fxAccounts.hasLocalSession(); + if (!hasLocalSession) { + return true; + } + + // Referencing Weave.Service will implicitly initialize sync, and we don't + // want to force that - so first check if it is ready. + let service = Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) + .wrappedJSObject; + if (!service.ready) { + return false; + } + // LOGIN_FAILED_LOGIN_REJECTED explicitly means "you must log back in". + // All other login failures are assumed to be transient and should go + // away by themselves, so aren't reflected here. + return Weave.Status.login == Weave.LOGIN_FAILED_LOGIN_REJECTED; + }, + + set fxAccounts(mockFxAccounts) { + delete this.fxAccounts; + this.fxAccounts = mockFxAccounts; + }, +}; + +ChromeUtils.defineModuleGetter( + UIStateInternal, + "fxAccounts", + "resource://gre/modules/FxAccounts.jsm" +); + +for (let topic of TOPICS) { + Services.obs.addObserver(UIStateInternal, topic); +} + +var UIState = { + _internal: UIStateInternal, + + ON_UPDATE, + + STATUS_NOT_CONFIGURED, + STATUS_LOGIN_FAILED, + STATUS_NOT_VERIFIED, + STATUS_SIGNED_IN, + + /** + * Returns true if the module has been initialized and the state set. + * If not, return false and trigger an init in the background. + */ + isReady() { + return this._internal.isReady(); + }, + + /** + * @returns {UIState} The current Sync/FxA UI State. + */ + get() { + return this._internal.state; + }, + + /** + * Refresh the state. Used for testing, don't call this directly since + * UIState already listens to Sync/FxA notifications to determine if the state + * needs to be refreshed. ON_UPDATE will be fired once the state is refreshed. + * + * @returns {Promise<UIState>} Resolved once the state is refreshed. + */ + refresh() { + return this._internal.refreshState(); + }, + + /** + * Reset the state of the whole module. Used for testing. + */ + reset() { + this._internal.reset(); + }, +}; diff --git a/services/sync/modules/addonsreconciler.js b/services/sync/modules/addonsreconciler.js new file mode 100644 index 0000000000..c9da4d6f6c --- /dev/null +++ b/services/sync/modules/addonsreconciler.js @@ -0,0 +1,592 @@ +/* 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/. */ + +/** + * This file contains middleware to reconcile state of AddonManager for + * purposes of tracking events for Sync. The content in this file exists + * because AddonManager does not have a getChangesSinceX() API and adding + * that functionality properly was deemed too time-consuming at the time + * add-on sync was originally written. If/when AddonManager adds this API, + * this file can go away and the add-ons engine can be rewritten to use it. + * + * It was decided to have this tracking functionality exist in a separate + * standalone file so it could be more easily understood, tested, and + * hopefully ported. + */ + +"use strict"; + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +const DEFAULT_STATE_FILE = "addonsreconciler"; + +var CHANGE_INSTALLED = 1; +var CHANGE_UNINSTALLED = 2; +var CHANGE_ENABLED = 3; +var CHANGE_DISABLED = 4; + +var EXPORTED_SYMBOLS = [ + "AddonsReconciler", + "CHANGE_INSTALLED", + "CHANGE_UNINSTALLED", + "CHANGE_ENABLED", + "CHANGE_DISABLED", +]; +/** + * Maintains state of add-ons. + * + * State is maintained in 2 data structures, an object mapping add-on IDs + * to metadata and an array of changes over time. The object mapping can be + * thought of as a minimal copy of data from AddonManager which is needed for + * Sync. The array is effectively a log of changes over time. + * + * The data structures are persisted to disk by serializing to a JSON file in + * the current profile. The data structures are updated by 2 mechanisms. First, + * they can be refreshed from the global state of the AddonManager. This is a + * sure-fire way of ensuring the reconciler is up to date. Second, the + * reconciler adds itself as an AddonManager listener. When it receives change + * notifications, it updates its internal state incrementally. + * + * The internal state is persisted to a JSON file in the profile directory. + * + * An instance of this is bound to an AddonsEngine instance. In reality, it + * likely exists as a singleton. To AddonsEngine, it functions as a store and + * an entity which emits events for tracking. + * + * The usage pattern for instances of this class is: + * + * let reconciler = new AddonsReconciler(...); + * await reconciler.ensureStateLoaded(); + * + * // At this point, your instance should be ready to use. + * + * When you are finished with the instance, please call: + * + * reconciler.stopListening(); + * await reconciler.saveState(...); + * + * This class uses the AddonManager AddonListener interface. + * When an add-on is installed, listeners are called in the following order: + * AL.onInstalling, AL.onInstalled + * + * For uninstalls, we see AL.onUninstalling then AL.onUninstalled. + * + * Enabling and disabling work by sending: + * + * AL.onEnabling, AL.onEnabled + * AL.onDisabling, AL.onDisabled + * + * Actions can be undone. All undoable actions notify the same + * AL.onOperationCancelled event. We treat this event like any other. + * + * When an add-on is uninstalled from about:addons, the user is offered an + * "Undo" option, which leads to the following sequence of events as + * observed by an AddonListener: + * Add-ons are first disabled then they are actually uninstalled. So, we will + * see AL.onDisabling and AL.onDisabled. The onUninstalling and onUninstalled + * events only come after the Addon Manager is closed or another view is + * switched to. In the case of Sync performing the uninstall, the uninstall + * events will occur immediately. However, we still see disabling events and + * heed them like they were normal. In the end, the state is proper. + */ +function AddonsReconciler(queueCaller) { + this._log = Log.repository.getLogger("Sync.AddonsReconciler"); + this._log.manageLevelFromPref("services.sync.log.logger.addonsreconciler"); + this.queueCaller = queueCaller; + + Svc.Obs.add("xpcom-shutdown", this.stopListening, this); +} +AddonsReconciler.prototype = { + /** Flag indicating whether we are listening to AddonManager events. */ + _listening: false, + + /** + * Define this as false if the reconciler should not persist state + * to disk when handling events. + * + * This allows test code to avoid spinning to write during observer + * notifications and xpcom shutdown, which appears to cause hangs on WinXP + * (Bug 873861). + */ + _shouldPersist: true, + + /** Log logger instance */ + _log: null, + + /** + * Container for add-on metadata. + * + * Keys are add-on IDs. Values are objects which describe the state of the + * add-on. This is a minimal mirror of data that can be queried from + * AddonManager. In some cases, we retain data longer than AddonManager. + */ + _addons: {}, + + /** + * List of add-on changes over time. + * + * Each element is an array of [time, change, id]. + */ + _changes: [], + + /** + * Objects subscribed to changes made to this instance. + */ + _listeners: [], + + /** + * Accessor for add-ons in this object. + * + * Returns an object mapping add-on IDs to objects containing metadata. + */ + get addons() { + return this._addons; + }, + + async ensureStateLoaded() { + if (!this._promiseStateLoaded) { + this._promiseStateLoaded = this.loadState(); + } + return this._promiseStateLoaded; + }, + + /** + * Load reconciler state from a file. + * + * The path is relative to the weave directory in the profile. If no + * path is given, the default one is used. + * + * If the file does not exist or there was an error parsing the file, the + * state will be transparently defined as empty. + * + * @param file + * Path to load. ".json" is appended automatically. If not defined, + * a default path will be consulted. + */ + async loadState(file = DEFAULT_STATE_FILE) { + let json = await Utils.jsonLoad(file, this); + this._addons = {}; + this._changes = []; + + if (!json) { + this._log.debug("No data seen in loaded file: " + file); + return false; + } + + let version = json.version; + if (!version || version != 1) { + this._log.error( + "Could not load JSON file because version not " + + "supported: " + + version + ); + return false; + } + + this._addons = json.addons; + for (let id in this._addons) { + let record = this._addons[id]; + record.modified = new Date(record.modified); + } + + for (let [time, change, id] of json.changes) { + this._changes.push([new Date(time), change, id]); + } + + return true; + }, + + /** + * Saves the current state to a file in the local profile. + * + * @param file + * String path in profile to save to. If not defined, the default + * will be used. + */ + async saveState(file = DEFAULT_STATE_FILE) { + let state = { version: 1, addons: {}, changes: [] }; + + for (let [id, record] of Object.entries(this._addons)) { + state.addons[id] = {}; + for (let [k, v] of Object.entries(record)) { + if (k == "modified") { + state.addons[id][k] = v.getTime(); + } else { + state.addons[id][k] = v; + } + } + } + + for (let [time, change, id] of this._changes) { + state.changes.push([time.getTime(), change, id]); + } + + this._log.info("Saving reconciler state to file: " + file); + await Utils.jsonSave(file, this, state); + }, + + /** + * Registers a change listener with this instance. + * + * Change listeners are called every time a change is recorded. The listener + * is an object with the function "changeListener" that takes 3 arguments, + * the Date at which the change happened, the type of change (a CHANGE_* + * constant), and the add-on state object reflecting the current state of + * the add-on at the time of the change. + * + * @param listener + * Object containing changeListener function. + */ + addChangeListener: function addChangeListener(listener) { + if (!this._listeners.includes(listener)) { + this._log.debug("Adding change listener."); + this._listeners.push(listener); + } + }, + + /** + * Removes a previously-installed change listener from the instance. + * + * @param listener + * Listener instance to remove. + */ + removeChangeListener: function removeChangeListener(listener) { + this._listeners = this._listeners.filter(element => { + if (element == listener) { + this._log.debug("Removing change listener."); + return false; + } + return true; + }); + }, + + /** + * Tells the instance to start listening for AddonManager changes. + * + * This is typically called automatically when Sync is loaded. + */ + startListening: function startListening() { + if (this._listening) { + return; + } + + this._log.info("Registering as Add-on Manager listener."); + AddonManager.addAddonListener(this); + this._listening = true; + }, + + /** + * Tells the instance to stop listening for AddonManager changes. + * + * The reconciler should always be listening. This should only be called when + * the instance is being destroyed. + * + * This function will get called automatically on XPCOM shutdown. However, it + * is a best practice to call it yourself. + */ + stopListening: function stopListening() { + if (!this._listening) { + return; + } + + this._log.debug("Stopping listening and removing AddonManager listener."); + AddonManager.removeAddonListener(this); + this._listening = false; + }, + + /** + * Refreshes the global state of add-ons by querying the AddonManager. + */ + async refreshGlobalState() { + this._log.info("Refreshing global state from AddonManager."); + + let installs; + let addons = await AddonManager.getAllAddons(); + + let ids = {}; + + for (let addon of addons) { + ids[addon.id] = true; + await this.rectifyStateFromAddon(addon); + } + + // Look for locally-defined add-ons that no longer exist and update their + // record. + for (let [id, addon] of Object.entries(this._addons)) { + if (id in ids) { + continue; + } + + // If the id isn't in ids, it means that the add-on has been deleted or + // the add-on is in the process of being installed. We detect the + // latter by seeing if an AddonInstall is found for this add-on. + + if (!installs) { + installs = await AddonManager.getAllInstalls(); + } + + let installFound = false; + for (let install of installs) { + if ( + install.addon && + install.addon.id == id && + install.state == AddonManager.STATE_INSTALLED + ) { + installFound = true; + break; + } + } + + if (installFound) { + continue; + } + + if (addon.installed) { + addon.installed = false; + this._log.debug( + "Adding change because add-on not present in " + + "Add-on Manager: " + + id + ); + await this._addChange(new Date(), CHANGE_UNINSTALLED, addon); + } + } + + // See note for _shouldPersist. + if (this._shouldPersist) { + await this.saveState(); + } + }, + + /** + * Rectifies the state of an add-on from an Addon instance. + * + * This basically says "given an Addon instance, assume it is truth and + * apply changes to the local state to reflect it." + * + * This function could result in change listeners being called if the local + * state differs from the passed add-on's state. + * + * @param addon + * Addon instance being updated. + */ + async rectifyStateFromAddon(addon) { + this._log.debug( + `Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})` + ); + + let id = addon.id; + let enabled = !addon.userDisabled; + let guid = addon.syncGUID; + let now = new Date(); + + if (!(id in this._addons)) { + let record = { + id, + guid, + enabled, + installed: true, + modified: now, + type: addon.type, + scope: addon.scope, + foreignInstall: addon.foreignInstall, + isSyncable: addon.isSyncable, + }; + this._addons[id] = record; + this._log.debug( + "Adding change because add-on not present locally: " + id + ); + await this._addChange(now, CHANGE_INSTALLED, record); + return; + } + + let record = this._addons[id]; + record.isSyncable = addon.isSyncable; + + if (!record.installed) { + // It is possible the record is marked as uninstalled because an + // uninstall is pending. + if (!(addon.pendingOperations & AddonManager.PENDING_UNINSTALL)) { + record.installed = true; + record.modified = now; + } + } + + if (record.enabled != enabled) { + record.enabled = enabled; + record.modified = now; + let change = enabled ? CHANGE_ENABLED : CHANGE_DISABLED; + this._log.debug("Adding change because enabled state changed: " + id); + await this._addChange(new Date(), change, record); + } + + if (record.guid != guid) { + record.guid = guid; + // We don't record a change because the Sync engine rectifies this on its + // own. This is tightly coupled with Sync. If this code is ever lifted + // outside of Sync, this exception should likely be removed. + } + }, + + /** + * Record a change in add-on state. + * + * @param date + * Date at which the change occurred. + * @param change + * The type of the change. A CHANGE_* constant. + * @param state + * The new state of the add-on. From this.addons. + */ + async _addChange(date, change, state) { + this._log.info("Change recorded for " + state.id); + this._changes.push([date, change, state.id]); + + for (let listener of this._listeners) { + try { + await listener.changeListener(date, change, state); + } catch (ex) { + this._log.error("Exception calling change listener", ex); + } + } + }, + + /** + * Obtain the set of changes to add-ons since the date passed. + * + * This will return an array of arrays. Each entry in the array has the + * elements [date, change_type, id], where + * + * date - Date instance representing when the change occurred. + * change_type - One of CHANGE_* constants. + * id - ID of add-on that changed. + */ + getChangesSinceDate(date) { + let length = this._changes.length; + for (let i = 0; i < length; i++) { + if (this._changes[i][0] >= date) { + return this._changes.slice(i); + } + } + + return []; + }, + + /** + * Prunes all recorded changes from before the specified Date. + * + * @param date + * Entries older than this Date will be removed. + */ + pruneChangesBeforeDate(date) { + this._changes = this._changes.filter(function test_age(change) { + return change[0] >= date; + }); + }, + + /** + * Obtains the set of all known Sync GUIDs for add-ons. + */ + getAllSyncGUIDs() { + let result = {}; + for (let id in this.addons) { + result[id] = true; + } + + return result; + }, + + /** + * Obtain the add-on state record for an add-on by Sync GUID. + * + * If the add-on could not be found, returns null. + * + * @param guid + * Sync GUID of add-on to retrieve. + */ + getAddonStateFromSyncGUID(guid) { + for (let id in this.addons) { + let addon = this.addons[id]; + if (addon.guid == guid) { + return addon; + } + } + + return null; + }, + + /** + * Handler that is invoked as part of the AddonManager listeners. + */ + async _handleListener(action, addon) { + // Since this is called as an observer, we explicitly trap errors and + // log them to ourselves so we don't see errors reported elsewhere. + try { + let id = addon.id; + this._log.debug("Add-on change: " + action + " to " + id); + + switch (action) { + case "onEnabled": + case "onDisabled": + case "onInstalled": + case "onInstallEnded": + case "onOperationCancelled": + await this.rectifyStateFromAddon(addon); + break; + + case "onUninstalled": + let id = addon.id; + let addons = this.addons; + if (id in addons) { + let now = new Date(); + let record = addons[id]; + record.installed = false; + record.modified = now; + this._log.debug( + "Adding change because of uninstall listener: " + id + ); + await this._addChange(now, CHANGE_UNINSTALLED, record); + } + } + + // See note for _shouldPersist. + if (this._shouldPersist) { + await this.saveState(); + } + } catch (ex) { + this._log.warn("Exception", ex); + } + }, + + // AddonListeners + onEnabled: function onEnabled(addon) { + this.queueCaller.enqueueCall(() => + this._handleListener("onEnabled", addon) + ); + }, + onDisabled: function onDisabled(addon) { + this.queueCaller.enqueueCall(() => + this._handleListener("onDisabled", addon) + ); + }, + onInstalled: function onInstalled(addon) { + this.queueCaller.enqueueCall(() => + this._handleListener("onInstalled", addon) + ); + }, + onUninstalled: function onUninstalled(addon) { + this.queueCaller.enqueueCall(() => + this._handleListener("onUninstalled", addon) + ); + }, + onOperationCancelled: function onOperationCancelled(addon) { + this.queueCaller.enqueueCall(() => + this._handleListener("onOperationCancelled", addon) + ); + }, +}; diff --git a/services/sync/modules/addonutils.js b/services/sync/modules/addonutils.js new file mode 100644 index 0000000000..08afbeb711 --- /dev/null +++ b/services/sync/modules/addonutils.js @@ -0,0 +1,403 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["AddonUtils"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Svc } = ChromeUtils.import("resource://services-sync/util.js"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AddonRepository", + "resource://gre/modules/addons/AddonRepository.jsm" +); + +function AddonUtilsInternal() { + this._log = Log.repository.getLogger("Sync.AddonUtils"); + this._log.Level = Log.Level[Svc.Prefs.get("log.logger.addonutils")]; +} +AddonUtilsInternal.prototype = { + /** + * Obtain an AddonInstall object from an AddonSearchResult instance. + * + * The returned promise will be an AddonInstall on success or null (failure or + * addon not found) + * + * @param addon + * AddonSearchResult to obtain install from. + */ + getInstallFromSearchResult(addon) { + this._log.debug("Obtaining install for " + addon.id); + + // We should theoretically be able to obtain (and use) addon.install if + // it is available. However, the addon.sourceURI rewriting won't be + // reflected in the AddonInstall, so we can't use it. If we ever get rid + // of sourceURI rewriting, we can avoid having to reconstruct the + // AddonInstall. + return AddonManager.getInstallForURL(addon.sourceURI.spec, { + name: addon.name, + icons: addon.iconURL, + version: addon.version, + telemetryInfo: { source: "sync" }, + }); + }, + + /** + * Installs an add-on from an AddonSearchResult instance. + * + * The options argument defines extra options to control the install. + * Recognized keys in this map are: + * + * syncGUID - Sync GUID to use for the new add-on. + * enabled - Boolean indicating whether the add-on should be enabled upon + * install. + * + * The result object has the following keys: + * + * id ID of add-on that was installed. + * install AddonInstall that was installed. + * addon Addon that was installed. + * + * @param addon + * AddonSearchResult to install add-on from. + * @param options + * Object with additional metadata describing how to install add-on. + */ + async installAddonFromSearchResult(addon, options) { + this._log.info("Trying to install add-on from search result: " + addon.id); + + const install = await this.getInstallFromSearchResult(addon); + if (!install) { + throw new Error("AddonInstall not available: " + addon.id); + } + + try { + this._log.info("Installing " + addon.id); + let log = this._log; + + return new Promise((res, rej) => { + let listener = { + onInstallStarted: function onInstallStarted(install) { + if (!options) { + return; + } + + if (options.syncGUID) { + log.info( + "Setting syncGUID of " + install.name + ": " + options.syncGUID + ); + install.addon.syncGUID = options.syncGUID; + } + + // We only need to change userDisabled if it is disabled because + // enabled is the default. + if ("enabled" in options && !options.enabled) { + log.info( + "Marking add-on as disabled for install: " + install.name + ); + install.addon.disable(); + } + }, + onInstallEnded(install, addon) { + install.removeListener(listener); + + res({ id: addon.id, install, addon }); + }, + onInstallFailed(install) { + install.removeListener(listener); + + rej(new Error("Install failed: " + install.error)); + }, + onDownloadFailed(install) { + install.removeListener(listener); + + rej(new Error("Download failed: " + install.error)); + }, + }; + install.addListener(listener); + install.install(); + }); + } catch (ex) { + this._log.error("Error installing add-on", ex); + throw ex; + } + }, + + /** + * Uninstalls the addon instance. + * + * @param addon + * Addon instance to uninstall. + */ + async uninstallAddon(addon) { + return new Promise(res => { + let listener = { + onUninstalling(uninstalling, needsRestart) { + if (addon.id != uninstalling.id) { + return; + } + + // We assume restartless add-ons will send the onUninstalled event + // soon. + if (!needsRestart) { + return; + } + + // For non-restartless add-ons, we issue the callback on uninstalling + // because we will likely never see the uninstalled event. + AddonManager.removeAddonListener(listener); + res(addon); + }, + onUninstalled(uninstalled) { + if (addon.id != uninstalled.id) { + return; + } + + AddonManager.removeAddonListener(listener); + res(addon); + }, + }; + AddonManager.addAddonListener(listener); + addon.uninstall(); + }); + }, + + /** + * Installs multiple add-ons specified by metadata. + * + * The first argument is an array of objects. Each object must have the + * following keys: + * + * id - public ID of the add-on to install. + * syncGUID - syncGUID for new add-on. + * enabled - boolean indicating whether the add-on should be enabled. + * requireSecureURI - Boolean indicating whether to require a secure + * URI when installing from a remote location. This defaults to + * true. + * + * The callback will be called when activity on all add-ons is complete. The + * callback receives 2 arguments, error and result. + * + * If error is truthy, it contains a string describing the overall error. + * + * The 2nd argument to the callback is always an object with details on the + * overall execution state. It contains the following keys: + * + * installedIDs Array of add-on IDs that were installed. + * installs Array of AddonInstall instances that were installed. + * addons Array of Addon instances that were installed. + * errors Array of errors encountered. Only has elements if error is + * truthy. + * + * @param installs + * Array of objects describing add-ons to install. + */ + async installAddons(installs) { + let ids = []; + for (let addon of installs) { + ids.push(addon.id); + } + + let addons = await AddonRepository.getAddonsByIDs(ids); + this._log.info( + `Found ${addons.length} / ${ids.length}` + + " add-ons during repository search." + ); + + let ourResult = { + installedIDs: [], + installs: [], + addons: [], + skipped: [], + errors: [], + }; + + let toInstall = []; + + // Rewrite the "src" query string parameter of the source URI to note + // that the add-on was installed by Sync and not something else so + // server-side metrics aren't skewed (bug 708134). The server should + // ideally send proper URLs, but this solution was deemed too + // complicated at the time the functionality was implemented. + for (let addon of addons) { + // Find the specified options for this addon. + let options; + for (let install of installs) { + if (install.id == addon.id) { + options = install; + break; + } + } + if (!this.canInstallAddon(addon, options)) { + ourResult.skipped.push(addon.id); + continue; + } + + // We can go ahead and attempt to install it. + toInstall.push(addon); + + // We should always be able to QI the nsIURI to nsIURL. If not, we + // still try to install the add-on, but we don't rewrite the URL, + // potentially skewing metrics. + try { + addon.sourceURI.QueryInterface(Ci.nsIURL); + } catch (ex) { + this._log.warn( + "Unable to QI sourceURI to nsIURL: " + addon.sourceURI.spec + ); + continue; + } + + let params = addon.sourceURI.query + .split("&") + .map(function rewrite(param) { + if (param.indexOf("src=") == 0) { + return "src=sync"; + } + return param; + }); + + addon.sourceURI = addon.sourceURI + .mutate() + .setQuery(params.join("&")) + .finalize(); + } + + if (!toInstall.length) { + return ourResult; + } + + const installPromises = []; + // Start all the installs asynchronously. They will report back to us + // as they finish, eventually triggering the global callback. + for (let addon of toInstall) { + let options = {}; + for (let install of installs) { + if (install.id == addon.id) { + options = install; + break; + } + } + + installPromises.push( + (async () => { + try { + const result = await this.installAddonFromSearchResult( + addon, + options + ); + ourResult.installedIDs.push(result.id); + ourResult.installs.push(result.install); + ourResult.addons.push(result.addon); + } catch (error) { + ourResult.errors.push(error); + } + })() + ); + } + + await Promise.all(installPromises); + + if (ourResult.errors.length > 0) { + throw new Error("1 or more add-ons failed to install"); + } + return ourResult; + }, + + /** + * Returns true if we are able to install the specified addon, false + * otherwise. It is expected that this will log the reason if it returns + * false. + * + * @param addon + * (Addon) Add-on instance to check. + * @param options + * (object) The options specified for this addon. See installAddons() + * for the valid elements. + */ + canInstallAddon(addon, options) { + // sourceURI presence isn't enforced by AddonRepository. So, we skip + // add-ons without a sourceURI. + if (!addon.sourceURI) { + this._log.info( + "Skipping install of add-on because missing sourceURI: " + addon.id + ); + return false; + } + // Verify that the source URI uses TLS. We don't allow installs from + // insecure sources for security reasons. The Addon Manager ensures + // that cert validation etc is performed. + // (We should also consider just dropping this entirely and calling + // XPIProvider.isInstallAllowed, but that has additional semantics we might + // need to think through...) + let requireSecureURI = true; + if (options && options.requireSecureURI !== undefined) { + requireSecureURI = options.requireSecureURI; + } + + if (requireSecureURI) { + let scheme = addon.sourceURI.scheme; + if (scheme != "https") { + this._log.info( + `Skipping install of add-on "${addon.id}" because sourceURI's scheme of "${scheme}" is not trusted` + ); + return false; + } + } + + // Policy prevents either installing this addon or any addon + if ( + Services.policies && + (!Services.policies.mayInstallAddon(addon) || + !Services.policies.isAllowed("xpinstall")) + ) { + this._log.info( + `Skipping install of "${addon.id}" due to enterprise policy` + ); + return false; + } + + this._log.info(`Add-on "${addon.id}" is able to be installed`); + return true; + }, + + /** + * Update the user disabled flag for an add-on. + * + * If the new flag matches the existing or if the add-on + * isn't currently active, the function will return immediately. + * + * @param addon + * (Addon) Add-on instance to operate on. + * @param value + * (bool) New value for add-on's userDisabled property. + */ + updateUserDisabled(addon, value) { + if (addon.userDisabled == value) { + return; + } + + this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value); + if (value) { + addon.disable(); + } else { + addon.enable(); + } + }, +}; + +XPCOMUtils.defineLazyGetter(this, "AddonUtils", function() { + return new AddonUtilsInternal(); +}); diff --git a/services/sync/modules/bookmark_validator.js b/services/sync/modules/bookmark_validator.js new file mode 100644 index 0000000000..262ceaa316 --- /dev/null +++ b/services/sync/modules/bookmark_validator.js @@ -0,0 +1,1079 @@ +/* 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/. */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +ChromeUtils.defineModuleGetter( + this, + "Async", + "resource://services-common/async.js" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesSyncUtils", + "resource://gre/modules/PlacesSyncUtils.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URLSearchParams"]); + +var EXPORTED_SYMBOLS = ["BookmarkValidator", "BookmarkProblemData"]; + +const QUERY_PROTOCOL = "place:"; + +function areURLsEqual(a, b) { + if (a === b) { + return true; + } + if (a.startsWith(QUERY_PROTOCOL) != b.startsWith(QUERY_PROTOCOL)) { + return false; + } + // Tag queries are special because we rewrite them to point to the + // local tag folder ID. It's expected that the folders won't match, + // but all other params should. + let aParams = new URLSearchParams(a.slice(QUERY_PROTOCOL.length)); + let aType = +aParams.get("type"); + if (aType != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { + return false; + } + let bParams = new URLSearchParams(b.slice(QUERY_PROTOCOL.length)); + let bType = +bParams.get("type"); + if (bType != Ci.nsINavHistoryQueryOptions.RESULTS_AS_TAG_CONTENTS) { + return false; + } + let aKeys = new Set(aParams.keys()); + let bKeys = new Set(bParams.keys()); + if (aKeys.size != bKeys.size) { + return false; + } + // Tag queries shouldn't reference multiple folders, or named folders like + // "TOOLBAR" or "BOOKMARKS_MENU". Just in case, we make sure all folder IDs + // are numeric. If they are, we ignore them when comparing the query params. + if (aKeys.has("folder") && aParams.getAll("folder").every(isFinite)) { + aKeys.delete("folder"); + } + if (bKeys.has("folder") && bParams.getAll("folder").every(isFinite)) { + bKeys.delete("folder"); + } + for (let key of aKeys) { + if (!bKeys.has(key)) { + return false; + } + if ( + !CommonUtils.arrayEqual( + aParams.getAll(key).sort(), + bParams.getAll(key).sort() + ) + ) { + return false; + } + } + for (let key of bKeys) { + if (!aKeys.has(key)) { + return false; + } + } + return true; +} + +const BOOKMARK_VALIDATOR_VERSION = 1; + +/** + * Result of bookmark validation. Contains the following fields which describe + * server-side problems unless otherwise specified. + * + * - missingIDs (number): # of objects with missing ids + * - duplicates (array of ids): ids seen more than once + * - parentChildMismatches (array of {parent: parentid, child: childid}): + * instances where the child's parentid and the parent's children array + * do not match + * - cycles (array of array of ids). List of cycles found in the server-side tree. + * - clientCycles (array of array of ids). List of cycles found in the client-side tree. + * - orphans (array of {id: string, parent: string}): List of nodes with + * either no parentid, or where the parent could not be found. + * - missingChildren (array of {parent: id, child: id}): + * List of parent/children where the child id couldn't be found + * - deletedChildren (array of { parent: id, child: id }): + * List of parent/children where child id was a deleted item (but still showed up + * in the children array) + * - multipleParents (array of {child: id, parents: array of ids}): + * List of children that were part of multiple parent arrays + * - deletedParents (array of ids) : List of records that aren't deleted but + * had deleted parents + * - childrenOnNonFolder (array of ids): list of non-folders that still have + * children arrays + * - duplicateChildren (array of ids): list of records who have the same + * child listed multiple times in their children array + * - parentNotFolder (array of ids): list of records that have parents that + * aren't folders + * - rootOnServer (boolean): true if the root came from the server + * - badClientRoots (array of ids): Contains any client-side root ids where + * the root is missing or isn't a (direct) child of the places root. + * + * - clientMissing: Array of ids on the server missing from the client + * - serverMissing: Array of ids on the client missing from the server + * - serverDeleted: Array of ids on the client that the server had marked as deleted. + * - serverUnexpected: Array of ids that appear on the server but shouldn't + * because the client attempts to never upload them. + * - differences: Array of {id: string, differences: string array} recording + * the non-structural properties that are differente between the client and server + * - structuralDifferences: As above, but contains the items where the differences were + * structural, that is, they contained childGUIDs or parentid + */ +class BookmarkProblemData { + constructor() { + this.rootOnServer = false; + this.missingIDs = 0; + + this.duplicates = []; + this.parentChildMismatches = []; + this.cycles = []; + this.clientCycles = []; + this.orphans = []; + this.missingChildren = []; + this.deletedChildren = []; + this.multipleParents = []; + this.deletedParents = []; + this.childrenOnNonFolder = []; + this.duplicateChildren = []; + this.parentNotFolder = []; + + this.badClientRoots = []; + this.clientMissing = []; + this.serverMissing = []; + this.serverDeleted = []; + this.serverUnexpected = []; + this.differences = []; + this.structuralDifferences = []; + } + + /** + * Convert ("difference", [{ differences: ["tags", "name"] }, { differences: ["name"] }]) into + * [{ name: "difference:tags", count: 1}, { name: "difference:name", count: 2 }], etc. + */ + _summarizeDifferences(prefix, diffs) { + let diffCounts = new Map(); + for (let { differences } of diffs) { + for (let type of differences) { + let name = prefix + ":" + type; + let count = diffCounts.get(name) || 0; + diffCounts.set(name, count + 1); + } + } + return [...diffCounts].map(([name, count]) => ({ name, count })); + } + + /** + * Produce a list summarizing problems found. Each entry contains {name, count}, + * where name is the field name for the problem, and count is the number of times + * the problem was encountered. + * + * Validation has failed if all counts are not 0. + * + * If the `full` argument is truthy, we also include information about which + * properties we saw structural differences in. Currently, this means either + * "sdiff:parentid" and "sdiff:childGUIDS" may be present. + */ + getSummary(full) { + let result = [ + { name: "clientMissing", count: this.clientMissing.length }, + { name: "serverMissing", count: this.serverMissing.length }, + { name: "serverDeleted", count: this.serverDeleted.length }, + { name: "serverUnexpected", count: this.serverUnexpected.length }, + + { + name: "structuralDifferences", + count: this.structuralDifferences.length, + }, + { name: "differences", count: this.differences.length }, + + { name: "missingIDs", count: this.missingIDs }, + { name: "rootOnServer", count: this.rootOnServer ? 1 : 0 }, + + { name: "duplicates", count: this.duplicates.length }, + { + name: "parentChildMismatches", + count: this.parentChildMismatches.length, + }, + { name: "cycles", count: this.cycles.length }, + { name: "clientCycles", count: this.clientCycles.length }, + { name: "badClientRoots", count: this.badClientRoots.length }, + { name: "orphans", count: this.orphans.length }, + { name: "missingChildren", count: this.missingChildren.length }, + { name: "deletedChildren", count: this.deletedChildren.length }, + { name: "multipleParents", count: this.multipleParents.length }, + { name: "deletedParents", count: this.deletedParents.length }, + { name: "childrenOnNonFolder", count: this.childrenOnNonFolder.length }, + { name: "duplicateChildren", count: this.duplicateChildren.length }, + { name: "parentNotFolder", count: this.parentNotFolder.length }, + ]; + if (full) { + let structural = this._summarizeDifferences( + "sdiff", + this.structuralDifferences + ); + result.push.apply(result, structural); + } + return result; + } +} + +// Defined lazily to avoid initializing PlacesUtils.bookmarks too soon. +XPCOMUtils.defineLazyGetter(this, "SYNCED_ROOTS", () => [ + PlacesUtils.bookmarks.menuGuid, + PlacesUtils.bookmarks.toolbarGuid, + PlacesUtils.bookmarks.unfiledGuid, + PlacesUtils.bookmarks.mobileGuid, +]); + +// Maps root GUIDs to their query folder names from +// toolkit/components/places/nsNavHistoryQuery.cpp. We follow queries that +// reference existing folders in the client tree, and detect cycles where a +// query references its containing folder. +XPCOMUtils.defineLazyGetter(this, "ROOT_GUID_TO_QUERY_FOLDER_NAME", () => ({ + [PlacesUtils.bookmarks.rootGuid]: "PLACES_ROOT", + [PlacesUtils.bookmarks.menuGuid]: "BOOKMARKS_MENU", + + // Tags should never show up in our client tree, and never form cycles, but we + // report them just in case. + [PlacesUtils.bookmarks.tagsGuid]: "TAGS", + + [PlacesUtils.bookmarks.unfiledGuid]: "UNFILED_BOOKMARKS", + [PlacesUtils.bookmarks.toolbarGuid]: "TOOLBAR", + [PlacesUtils.bookmarks.mobileGuid]: "MOBILE_BOOKMARKS", +})); + +async function detectCycles(records) { + // currentPath and pathLookup contain the same data. pathLookup is faster to + // query, but currentPath gives is the order of traversal that we need in + // order to report the members of the cycles. + let pathLookup = new Set(); + let currentPath = []; + let cycles = []; + let seenEver = new Set(); + const yieldState = Async.yieldState(); + + const traverse = async node => { + if (pathLookup.has(node)) { + let cycleStart = currentPath.lastIndexOf(node); + let cyclePath = currentPath.slice(cycleStart).map(n => n.id); + cycles.push(cyclePath); + return; + } else if (seenEver.has(node)) { + // If we're checking the server, this is a problem, but it should already be reported. + // On the client, this could happen due to including `node.concrete` in the child list. + return; + } + seenEver.add(node); + let children = node.children || []; + if (node.concreteItems) { + children.push(...node.concreteItems); + } + if (children.length) { + pathLookup.add(node); + currentPath.push(node); + await Async.yieldingForEach(children, traverse, yieldState); + currentPath.pop(); + pathLookup.delete(node); + } + }; + + await Async.yieldingForEach( + records, + async record => { + if (!seenEver.has(record)) { + await traverse(record); + } + }, + yieldState + ); + + return cycles; +} + +class ServerRecordInspection { + constructor() { + this.serverRecords = null; + this.liveRecords = []; + + this.folders = []; + + this.root = null; + + this.idToRecord = new Map(); + + this.deletedIds = new Set(); + this.deletedRecords = []; + + this.problemData = new BookmarkProblemData(); + + // These are handled outside of problemData + this._orphans = new Map(); + this._multipleParents = new Map(); + + this.yieldState = Async.yieldState(); + } + + static async create(records) { + return new ServerRecordInspection().performInspection(records); + } + + async performInspection(records) { + await this._setRecords(records); + await this._linkParentIDs(); + await this._linkChildren(); + await this._findOrphans(); + await this._finish(); + return this; + } + + // We don't set orphans in this.problemData. Instead, we walk the tree at the + // end to find unreachable items. + _noteOrphan(id, parentId = undefined) { + // This probably shouldn't be called with a parentId twice, but if it + // happens we take the most recent one. + if (parentId || !this._orphans.has(id)) { + this._orphans.set(id, parentId); + } + } + + noteParent(child, parent) { + let parents = this._multipleParents.get(child); + if (!parents) { + this._multipleParents.set(child, [parent]); + } else { + parents.push(parent); + } + } + + noteMismatch(child, parent) { + let exists = this.problemData.parentChildMismatches.some( + match => match.child == child && match.parent == parent + ); + if (!exists) { + this.problemData.parentChildMismatches.push({ child, parent }); + } + } + + // - Populates `this.deletedIds`, `this.folders`, and `this.idToRecord` + // - calls `_initRoot` (thus initializing `this.root`). + async _setRecords(records) { + if (this.serverRecords) { + // In general this class is expected to be created, have + // `performInspection` called, and then only read from from that point on. + throw new Error("Bug: ServerRecordInspection can't `setRecords` twice"); + } + this.serverRecords = records; + let rootChildren = []; + + await Async.yieldingForEach( + this.serverRecords, + async record => { + if (!record.id) { + ++this.problemData.missingIDs; + return; + } + + if (record.deleted) { + this.deletedIds.add(record.id); + } + if (this.idToRecord.has(record.id)) { + this.problemData.duplicates.push(record.id); + return; + } + + this.idToRecord.set(record.id, record); + + if (!record.deleted) { + this.liveRecords.push(record); + + if (record.parentid == "places") { + rootChildren.push(record); + } + } + + if (!record.children) { + return; + } + + if (record.type != "folder") { + // Due to implementation details in engines/bookmarks.js, (Livemark + // subclassing BookmarkFolder) Livemarks will have a children array, + // but it should still be empty. + if (!record.children.length) { + return; + } + // Otherwise we mark it as an error and still try to resolve the children + this.problemData.childrenOnNonFolder.push(record.id); + } + + this.folders.push(record); + + if (new Set(record.children).size !== record.children.length) { + this.problemData.duplicateChildren.push(record.id); + } + + // After we're through with them, folder records store 3 (ugh) arrays that + // represent their folder information. The final fields looks like: + // + // - childGUIDs: The original `children` array, which is an array of + // record IDs. + // + // - unfilteredChildren: Contains more or less `childGUIDs.map(id => + // idToRecord.get(id))`, without the nulls for missing children. It will + // still have deleted, duplicate, mismatching, etc. children. + // + // - children: This is the 'cleaned' version of the child records that are + // safe to iterate over, etc.. If there are no reported problems, it should + // be identical to unfilteredChildren. + // + // The last two are left alone until later `this._linkChildren`, however. + record.childGUIDs = record.children; + + await Async.yieldingForEach( + record.childGUIDs, + id => { + this.noteParent(id, record.id); + }, + this.yieldState + ); + + record.children = []; + }, + this.yieldState + ); + + // Finish up some parts we can easily do now that we have idToRecord. + this.deletedRecords = Array.from(this.deletedIds, id => + this.idToRecord.get(id) + ); + + this._initRoot(rootChildren); + } + + _initRoot(rootChildren) { + let serverRoot = this.idToRecord.get("places"); + if (serverRoot) { + this.root = serverRoot; + this.problemData.rootOnServer = true; + return; + } + + // Fabricate a root. We want to be able to remember that it's fake, but + // would like to avoid it needing too much special casing, so we come up + // with children for it too (we just get these while we're iterating over + // the records to avoid needing two passes over a potentially large number + // of records). + + this.root = { + id: "places", + fake: true, + children: rootChildren, + childGUIDs: rootChildren.map(record => record.id), + type: "folder", + title: "", + }; + this.liveRecords.push(this.root); + this.idToRecord.set("places", this.root); + } + + // Adds `parent` to all records it can that have `parentid` + async _linkParentIDs() { + await Async.yieldingForEach( + this.idToRecord, + ([id, record]) => { + if (record == this.root || record.deleted) { + return false; + } + + // Check and update our orphan map. + let parentID = record.parentid; + let parent = this.idToRecord.get(parentID); + if (!parentID || !parent) { + this._noteOrphan(id, parentID); + return false; + } + + record.parent = parent; + + if (parent.deleted) { + this.problemData.deletedParents.push(id); + return true; + } else if (parent.type != "folder") { + this.problemData.parentNotFolder.push(record.id); + return true; + } + + if (parent.id !== "place" || this.problemData.rootOnServer) { + if (!parent.childGUIDs.includes(record.id)) { + this.noteMismatch(record.id, parent.id); + } + } + + if (parent.deleted && !record.deleted) { + this.problemData.deletedParents.push(record.id); + } + + // Note: We used to check if the parentName on the server matches the + // actual local parent name, but given this is used only for de-duping a + // record the first time it is seen and expensive to keep up-to-date, we + // decided to just stop recording it. See bug 1276969 for more. + return false; + }, + this.yieldState + ); + } + + // Build the children and unfilteredChildren arrays, (which are of record + // objects, not ids) + async _linkChildren() { + // Check that we aren't missing any children. + await Async.yieldingForEach( + this.folders, + async folder => { + folder.children = []; + folder.unfilteredChildren = []; + + let idsThisFolder = new Set(); + + await Async.yieldingForEach( + folder.childGUIDs, + childID => { + let child = this.idToRecord.get(childID); + + if (!child) { + this.problemData.missingChildren.push({ + parent: folder.id, + child: childID, + }); + return; + } + + if (child.deleted) { + this.problemData.deletedChildren.push({ + parent: folder.id, + child: childID, + }); + return; + } + + if (child.parentid != folder.id) { + this.noteMismatch(childID, folder.id); + return; + } + + if (idsThisFolder.has(childID)) { + // Already recorded earlier, we just don't want to mess up `children` + return; + } + folder.children.push(child); + }, + this.yieldState + ); + }, + this.yieldState + ); + } + + // Finds the orphans in the tree using something similar to a `mark and sweep` + // strategy. That is, we iterate over the children from the root, remembering + // which items we've seen. Then, we iterate all items, and know the ones we + // haven't seen are orphans. + async _findOrphans() { + let seen = new Set([this.root.id]); + + const inCycle = await Async.yieldingForEach( + Utils.walkTree(this.root), + ([node]) => { + if (seen.has(node.id)) { + // We're in an infloop due to a cycle. + // Return early to avoid reporting false positives for orphans. + return true; + } + seen.add(node.id); + + return false; + }, + this.yieldState + ); + + if (inCycle) { + return; + } + + await Async.yieldingForEach( + this.liveRecords, + (record, i) => { + if (!seen.has(record.id)) { + // We intentionally don't record the parentid here, since we only record + // that if the record refers to a parent that doesn't exist, which we + // have already handled (when linking parentid's). + this._noteOrphan(record.id); + } + }, + this.yieldState + ); + + await Async.yieldingForEach( + this._orphans, + ([id, parent]) => { + this.problemData.orphans.push({ id, parent }); + }, + this.yieldState + ); + } + + async _finish() { + this.problemData.cycles = await detectCycles(this.liveRecords); + + for (const [child, recordedParents] of this._multipleParents) { + let parents = new Set(recordedParents); + if (parents.size > 1) { + this.problemData.multipleParents.push({ child, parents: [...parents] }); + } + } + // Dedupe simple arrays in the problem data, so that we don't have to worry + // about it in the code + const idArrayProps = [ + "duplicates", + "deletedParents", + "childrenOnNonFolder", + "duplicateChildren", + "parentNotFolder", + ]; + for (let prop of idArrayProps) { + this.problemData[prop] = [...new Set(this.problemData[prop])]; + } + } +} + +class BookmarkValidator { + constructor() { + this.yieldState = Async.yieldState(); + } + + async canValidate() { + return !(await PlacesSyncUtils.bookmarks.havePendingChanges()); + } + + async _followQueries(recordsByQueryId) { + await Async.yieldingForEach( + recordsByQueryId.values(), + entry => { + if ( + entry.type !== "query" && + (!entry.bmkUri || !entry.bmkUri.startsWith(QUERY_PROTOCOL)) + ) { + return; + } + let params = new URLSearchParams( + entry.bmkUri.slice(QUERY_PROTOCOL.length) + ); + // Queries with `excludeQueries` won't form cycles because they'll + // exclude all queries, including themselves, from the result set. + let excludeQueries = params.get("excludeQueries"); + if (excludeQueries === "1" || excludeQueries === "true") { + // `nsNavHistoryQuery::ParseQueryBooleanString` allows `1` and `true`. + return; + } + entry.concreteItems = []; + let queryIds = params.getAll("folder"); + for (let queryId of queryIds) { + let concreteItem = recordsByQueryId.get(queryId); + if (concreteItem) { + entry.concreteItems.push(concreteItem); + } + } + }, + this.yieldState + ); + } + + async createClientRecordsFromTree(clientTree) { + // Iterate over the treeNode, converting it to something more similar to what + // the server stores. + let records = []; + // A map of local IDs and well-known query folder names to records. Unlike + // GUIDs, local IDs aren't synced, since they're not stable across devices. + // New Places APIs use GUIDs to refer to bookmarks, but the legacy APIs + // still use local IDs. We use this mapping to parse `place:` queries that + // refer to folders via their local IDs. + let recordsByQueryId = new Map(); + let syncedRoots = SYNCED_ROOTS; + + const traverse = async (treeNode, synced) => { + if (!synced) { + synced = syncedRoots.includes(treeNode.guid); + } + let localId = treeNode.id; + let guid = PlacesSyncUtils.bookmarks.guidToRecordId(treeNode.guid); + let itemType = "item"; + treeNode.ignored = !synced; + treeNode.id = guid; + switch (treeNode.type) { + case PlacesUtils.TYPE_X_MOZ_PLACE: + if (treeNode.uri.startsWith(QUERY_PROTOCOL)) { + itemType = "query"; + } else { + itemType = "bookmark"; + } + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_CONTAINER: + let isLivemark = false; + if (treeNode.annos) { + for (let anno of treeNode.annos) { + if (anno.name === PlacesUtils.LMANNO_FEEDURI) { + isLivemark = true; + treeNode.feedUri = anno.value; + } else if (anno.name === PlacesUtils.LMANNO_SITEURI) { + isLivemark = true; + treeNode.siteUri = anno.value; + } + } + } + itemType = isLivemark ? "livemark" : "folder"; + break; + case PlacesUtils.TYPE_X_MOZ_PLACE_SEPARATOR: + itemType = "separator"; + break; + } + + if (treeNode.tags) { + treeNode.tags = treeNode.tags.split(","); + } else { + treeNode.tags = []; + } + treeNode.type = itemType; + treeNode.pos = treeNode.index; + treeNode.bmkUri = treeNode.uri; + records.push(treeNode); + if (treeNode.guid in ROOT_GUID_TO_QUERY_FOLDER_NAME) { + let queryId = ROOT_GUID_TO_QUERY_FOLDER_NAME[treeNode.guid]; + recordsByQueryId.set(queryId, treeNode); + } + if (localId) { + // Always add the ID, since it's still possible for a query to + // reference a root without using the well-known name. For example, + // `place:folder=${PlacesUtils.mobileFolderId}` and + // `place:folder=MOBILE_BOOKMARKS` are equivalent. + recordsByQueryId.set(localId.toString(10), treeNode); + } + if (treeNode.type === "folder") { + treeNode.childGUIDs = []; + if (!treeNode.children) { + treeNode.children = []; + } + + await Async.yieldingForEach( + treeNode.children, + async child => { + await traverse(child, synced); + child.parent = treeNode; + child.parentid = guid; + treeNode.childGUIDs.push(child.guid); + }, + this.yieldState + ); + } + }; + + await traverse(clientTree, false); + + clientTree.id = "places"; + await this._followQueries(recordsByQueryId); + return records; + } + + /** + * Process the server-side list. Mainly this builds the records into a tree, + * but it also records information about problems, and produces arrays of the + * deleted and non-deleted nodes. + * + * Returns an object containing: + * - records:Array of non-deleted records. Each record contains the following + * properties + * - childGUIDs (array of strings, only present if type is 'folder'): the + * list of child GUIDs stored on the server. + * - children (array of records, only present if type is 'folder'): + * each record has these same properties. This may differ in content + * from what you may expect from the childGUIDs list, as it won't + * contain any records that could not be found. + * - parent (record): The parent to this record. + * - Unchanged properties send down from the server: id, title, type, + * parentName, parentid, bmkURI, keyword, tags, pos, queryId + * - root: Root of the server-side bookmark tree. Has the same properties as + * above. + * - deletedRecords: As above, but only contains items that the server sent + * where it also sent indication that the item should be deleted. + * - problemData: a BookmarkProblemData object, with the caveat that + * the fields describing client/server relationship will not have been filled + * out yet. + */ + async inspectServerRecords(serverRecords) { + const data = await ServerRecordInspection.create(serverRecords); + return { + deletedRecords: data.deletedRecords, + records: data.liveRecords, + problemData: data.problemData, + root: data.root, + }; + } + + // Perform client-side sanity checking that doesn't involve server data + async _validateClient(problemData, clientRecords) { + problemData.clientCycles = await detectCycles(clientRecords); + for (let rootGUID of SYNCED_ROOTS) { + let record = clientRecords.find(record => record.guid === rootGUID); + if (!record || record.parentid !== "places") { + problemData.badClientRoots.push(rootGUID); + } + } + } + + async _computeUnifiedRecordMap(serverRecords, clientRecords) { + let allRecords = new Map(); + await Async.yieldingForEach( + serverRecords, + sr => { + if (sr.fake) { + return; + } + allRecords.set(sr.id, { client: null, server: sr }); + }, + this.yieldState + ); + + await Async.yieldingForEach( + clientRecords, + cr => { + let unified = allRecords.get(cr.id); + if (!unified) { + allRecords.set(cr.id, { client: cr, server: null }); + } else { + unified.client = cr; + } + }, + this.yieldState + ); + + return allRecords; + } + + _recordMissing(problems, id, clientRecord, serverRecord, serverTombstones) { + if (!clientRecord && serverRecord) { + problems.clientMissing.push(id); + } + if (!serverRecord && clientRecord) { + if (serverTombstones.has(id)) { + problems.serverDeleted.push(id); + } else if (!clientRecord.ignored && clientRecord.id != "places") { + problems.serverMissing.push(id); + } + } + } + + _compareRecords(client, server) { + let structuralDifferences = []; + let differences = []; + + // Don't bother comparing titles of roots. It's okay if locally it's + // "Mobile Bookmarks", but the server thinks it's "mobile". + // TODO: We probably should be handing other localized bookmarks (e.g. + // default bookmarks) here as well, see bug 1316041. + if (!SYNCED_ROOTS.includes(client.guid)) { + // We want to treat undefined, null and an empty string as identical + if ((client.title || "") !== (server.title || "")) { + differences.push("title"); + } + } + + if (client.parentid || server.parentid) { + if (client.parentid !== server.parentid) { + structuralDifferences.push("parentid"); + } + } + + if (client.tags || server.tags) { + let cl = client.tags ? [...client.tags].sort() : []; + let sl = server.tags ? [...server.tags].sort() : []; + if (!CommonUtils.arrayEqual(cl, sl)) { + differences.push("tags"); + } + } + + let sameType = client.type === server.type; + if (!sameType) { + if ( + server.type === "query" && + client.type === "bookmark" && + client.bmkUri.startsWith(QUERY_PROTOCOL) + ) { + sameType = true; + } + } + + if (!sameType) { + differences.push("type"); + } else { + switch (server.type) { + case "bookmark": + case "query": + if (!areURLsEqual(server.bmkUri, client.bmkUri)) { + differences.push("bmkUri"); + } + break; + case "separator": + if (server.pos != client.pos) { + differences.push("pos"); + } + break; + case "livemark": + if (server.feedUri != client.feedUri) { + differences.push("feedUri"); + } + if (server.siteUri != client.siteUri) { + differences.push("siteUri"); + } + break; + case "folder": + if (server.id === "places" && server.fake) { + // It's the fabricated places root. It won't have the GUIDs, but + // it doesn't matter. + break; + } + if (client.childGUIDs || server.childGUIDs) { + let cl = client.childGUIDs || []; + let sl = server.childGUIDs || []; + if (!CommonUtils.arrayEqual(cl, sl)) { + structuralDifferences.push("childGUIDs"); + } + } + break; + } + } + return { differences, structuralDifferences }; + } + + /** + * Compare the list of server records with the client tree. + * + * Returns the same data as described in the inspectServerRecords comment, + * with the following additional fields. + * - clientRecords: an array of client records in a similar format to + * the .records (ie, server records) entry. + * - problemData is the same as for inspectServerRecords, except all properties + * will be filled out. + */ + async compareServerWithClient(serverRecords, clientTree) { + let clientRecords = await this.createClientRecordsFromTree(clientTree); + let inspectionInfo = await this.inspectServerRecords(serverRecords); + inspectionInfo.clientRecords = clientRecords; + + // Mainly do this to remove deleted items and normalize child guids. + serverRecords = inspectionInfo.records; + let problemData = inspectionInfo.problemData; + + await this._validateClient(problemData, clientRecords); + + let allRecords = await this._computeUnifiedRecordMap( + serverRecords, + clientRecords + ); + + let serverDeleted = new Set(inspectionInfo.deletedRecords.map(r => r.id)); + + await Async.yieldingForEach( + allRecords, + ([id, { client, server }]) => { + if (!client || !server) { + this._recordMissing(problemData, id, client, server, serverDeleted); + return; + } + if (server && client && client.ignored) { + problemData.serverUnexpected.push(id); + } + let { differences, structuralDifferences } = this._compareRecords( + client, + server + ); + + if (differences.length) { + problemData.differences.push({ id, differences }); + } + if (structuralDifferences.length) { + problemData.structuralDifferences.push({ + id, + differences: structuralDifferences, + }); + } + }, + this.yieldState + ); + + return inspectionInfo; + } + + async _getServerState(engine) { + let collection = engine.itemSource(); + let collectionKey = engine.service.collectionKeys.keyForCollection( + engine.name + ); + collection.full = true; + let result = await collection.getBatched(); + if (!result.response.success) { + throw result.response; + } + let cleartexts = []; + await Async.yieldingForEach( + result.records, + async record => { + await record.decrypt(collectionKey); + cleartexts.push(record.cleartext); + }, + this.yieldState + ); + return cleartexts; + } + + async validate(engine) { + let start = Date.now(); + let clientTree = await PlacesUtils.promiseBookmarksTree("", { + includeItemIds: true, + }); + let serverState = await this._getServerState(engine); + let serverRecordCount = serverState.length; + let result = await this.compareServerWithClient(serverState, clientTree); + let end = Date.now(); + let duration = end - start; + + engine._log.debug(`Validated bookmarks in ${duration}ms`); + engine._log.debug(`Problem summary`); + for (let { name, count } of result.problemData.getSummary()) { + engine._log.debug(` ${name}: ${count}`); + } + + return { + duration, + version: this.version, + problems: result.problemData, + recordCount: serverRecordCount, + }; + } +} + +BookmarkValidator.prototype.version = BOOKMARK_VALIDATOR_VERSION; diff --git a/services/sync/modules/bridged_engine.js b/services/sync/modules/bridged_engine.js new file mode 100644 index 0000000000..c317f37b36 --- /dev/null +++ b/services/sync/modules/bridged_engine.js @@ -0,0 +1,457 @@ +/* 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/. */ + +/** + * This file has all the machinery for hooking up bridged engines implemented + * in Rust. It's the JavaScript side of the Golden Gate bridge that connects + * Desktop Sync to a Rust `BridgedEngine`, via the `mozIBridgedSyncEngine` + * XPCOM interface. + * + * Creating a bridged engine only takes a few lines of code, since most of the + * hard work is done on the Rust side. On the JS side, you'll need to subclass + * `BridgedEngine` (instead of `SyncEngine`), supply a `mozIBridgedSyncEngine` + * for your subclass to wrap, and optionally implement and override the tracker. + */ + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Changeset, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { RawCryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Async: "resource://services-common/async.js", + Log: "resource://gre/modules/Log.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +var EXPORTED_SYMBOLS = ["BridgedEngine", "LogAdapter"]; + +/** + * A stub store that converts between raw decrypted incoming records and + * envelopes. Since the interface we need is so minimal, this class doesn't + * inherit from the base `Store` implementation...it would take more code to + * override all those behaviors! + * + * This class isn't meant to be subclassed, because bridged engines shouldn't + * override their store classes in `_storeObj`. + */ +class BridgedStore { + constructor(name, engine) { + if (!engine) { + throw new Error("Store must be associated with an Engine instance."); + } + this.engine = engine; + this._log = Log.repository.getLogger(`Sync.Engine.${name}.Store`); + this._batchChunkSize = 500; + } + + async applyIncomingBatch(records) { + for (let chunk of PlacesUtils.chunkArray(records, this._batchChunkSize)) { + let incomingEnvelopesAsJSON = chunk.map(record => + JSON.stringify(record.toIncomingEnvelope()) + ); + this._log.trace("incoming envelopes", incomingEnvelopesAsJSON); + await promisify( + this.engine._bridge.storeIncoming, + incomingEnvelopesAsJSON + ); + } + // Array of failed records. + return []; + } + + async wipe() { + await promisify(this.engine._bridge.wipe); + } +} + +/** + * A wrapper class to convert between BSOs on the JS side, and envelopes on the + * Rust side. This class intentionally subclasses `RawCryptoWrapper`, because we + * don't want the stringification and parsing machinery in `CryptoWrapper`. + * + * This class isn't meant to be subclassed, because bridged engines shouldn't + * override their record classes in `_recordObj`. + */ +class BridgedRecord extends RawCryptoWrapper { + /** + * Creates an outgoing record from an envelope returned by a bridged engine. + * This must be kept in sync with `sync15_traits::OutgoingEnvelope`. + * + * @param {String} collection The collection name. + * @param {Object} envelope The outgoing envelope, returned from + * `mozIBridgedSyncEngine::apply`. + * @return {BridgedRecord} A Sync record ready to encrypt and upload. + */ + static fromOutgoingEnvelope(collection, envelope) { + if (typeof envelope.id != "string") { + throw new TypeError("Outgoing envelope missing ID"); + } + if (typeof envelope.cleartext != "string") { + throw new TypeError("Outgoing envelope missing cleartext"); + } + let record = new BridgedRecord(collection, envelope.id); + record.cleartext = envelope.cleartext; + return record; + } + + transformBeforeEncrypt(cleartext) { + if (typeof cleartext != "string") { + throw new TypeError("Outgoing bridged engine records must be strings"); + } + return cleartext; + } + + transformAfterDecrypt(cleartext) { + if (typeof cleartext != "string") { + throw new TypeError("Incoming bridged engine records must be strings"); + } + return cleartext; + } + + /* + * Converts this incoming record into an envelope to pass to a bridged engine. + * This object must be kept in sync with `sync15_traits::IncomingEnvelope`. + * + * @return {Object} The incoming envelope, to pass to + * `mozIBridgedSyncEngine::storeIncoming`. + */ + toIncomingEnvelope() { + return { + id: this.data.id, + modified: this.data.modified, + cleartext: this.cleartext, + }; + } +} + +class BridgeError extends Error { + constructor(code, message) { + super(message); + this.name = "BridgeError"; + // TODO: We may want to use a different name for this, since errors with + // a `result` property are treated specially by telemetry, discarding the + // message...but, unlike other `nserror`s, the message is actually useful, + // and we still want to capture it. + this.result = code; + } +} + +class InterruptedError extends Error { + constructor(message) { + super(message); + this.name = "InterruptedError"; + } +} + +/** + * Adapts a `Log.jsm` logger to a `mozIServicesLogSink`. This class is copied + * from `SyncedBookmarksMirror.jsm`. + */ +class LogAdapter { + constructor(log) { + this.log = log; + } + + get maxLevel() { + let level = this.log.level; + if (level <= Log.Level.All) { + return Ci.mozIServicesLogSink.LEVEL_TRACE; + } + if (level <= Log.Level.Info) { + return Ci.mozIServicesLogSink.LEVEL_DEBUG; + } + if (level <= Log.Level.Warn) { + return Ci.mozIServicesLogSink.LEVEL_WARN; + } + if (level <= Log.Level.Error) { + return Ci.mozIServicesLogSink.LEVEL_ERROR; + } + return Ci.mozIServicesLogSink.LEVEL_OFF; + } + + trace(message) { + this.log.trace(message); + } + + debug(message) { + this.log.debug(message); + } + + warn(message) { + this.log.warn(message); + } + + error(message) { + this.log.error(message); + } +} + +/** + * A base class used to plug a Rust engine into Sync, and have it work like any + * other engine. The constructor takes a bridge as its first argument, which is + * an instance of an XPCOM component class that implements + * `mozIBridgedSyncEngine`. + * + * This class inherits from `SyncEngine`, which has a lot of machinery that we + * don't need, but that's fairly easy to override. It would be harder to + * reimplement the machinery that we _do_ need here. However, because of that, + * this class has lots of methods that do nothing, or return empty data. The + * docs above each method explain what it's overriding, and why. + * + * This class is designed to be subclassed, but the only part that your engine + * may want to override is `_trackerObj`. Even then, using the default (no-op) + * tracker is fine, because the shape of the `Tracker` interface may not make + * sense for all engines. + */ +function BridgedEngine(bridge, name, service) { + SyncEngine.call(this, name, service); + + this._bridge = bridge; +} + +BridgedEngine.prototype = { + __proto__: SyncEngine.prototype, + + /** + * The tracker class for this engine. Subclasses may want to override this + * with their own tracker, though using the default `Tracker` is fine. + */ + _trackerObj: Tracker, + + /** Returns the record class for all bridged engines. */ + get _recordObj() { + return BridgedRecord; + }, + + set _recordObj(obj) { + throw new TypeError("Don't override the record class for bridged engines"); + }, + + /** Returns the store class for all bridged engines. */ + get _storeObj() { + return BridgedStore; + }, + + set _storeObj(obj) { + throw new TypeError("Don't override the store class for bridged engines"); + }, + + /** Returns the storage version for this engine. */ + get version() { + return this._bridge.storageVersion; + }, + + // Legacy engines allow sync to proceed if some records are too large to + // upload (eg, a payload that's bigger than the server's published limits). + // If this returns true, we will just skip the record without even attempting + // to upload. If this is false, we'll abort the entire batch. + // If the engine allows this, it will need to detect this scenario by noticing + // the ID is not in the 'success' records reported to `setUploaded`. + // (Note that this is not to be confused with the fact server's can currently + // reject records as part of a POST - but we hope to remove this ability from + // the server API. Note also that this is not bullet-proof - if the count of + // records is high, it's possible that we will have committed a previous + // batch before we hit the relevant limits, so things might have been written. + // We hope to fix this by ensuring batch limits are such that this is + // impossible) + get allowSkippedRecord() { + return this._bridge.allowSkippedRecord; + }, + + /** + * Returns the sync ID for this engine. This is exposed for tests, but + * Sync code always calls `resetSyncID()` and `ensureCurrentSyncID()`, + * not this. + * + * @returns {String?} The sync ID, or `null` if one isn't set. + */ + async getSyncID() { + // Note that all methods on an XPCOM class instance are automatically bound, + // so we don't need to write `this._bridge.getSyncId.bind(this._bridge)`. + let syncID = await promisify(this._bridge.getSyncId); + return syncID; + }, + + async resetSyncID() { + await this._deleteServerCollection(); + let newSyncID = await this.resetLocalSyncID(); + return newSyncID; + }, + + async resetLocalSyncID() { + let newSyncID = await promisify(this._bridge.resetSyncId); + return newSyncID; + }, + + async ensureCurrentSyncID(newSyncID) { + let assignedSyncID = await promisify( + this._bridge.ensureCurrentSyncId, + newSyncID + ); + return assignedSyncID; + }, + + async getLastSync() { + // The bridge defines lastSync as integer ms, but sync itself wants to work + // in a float seconds with 2 decimal places. + let lastSyncMS = await promisify(this._bridge.getLastSync); + return Math.round(lastSyncMS / 10) / 100; + }, + + async setLastSync(lastSyncSeconds) { + await promisify( + this._bridge.setLastSync, + Math.round(lastSyncSeconds * 1000) + ); + }, + + /** + * Returns the initial changeset for the sync. Bridged engines handle + * reconciliation internally, so we don't know what changed until after we've + * stored and applied all incoming records. So we return an empty changeset + * here, and replace it with the real one in `_processIncoming`. + */ + async pullChanges() { + return {}; + }, + + async trackRemainingChanges() { + await promisify(this._bridge.syncFinished); + }, + + /** + * Marks a record for a hard-`DELETE` at the end of the sync. The base method + * also removes it from the tracker, but we don't use the tracker for that, + * so we override the method to just mark. + */ + _deleteId(id) { + this._noteDeletedId(id); + }, + + /** + * Always stage incoming records, bypassing the base engine's reconciliation + * machinery. + */ + async _reconcile() { + return true; + }, + + async _syncStartup() { + await super._syncStartup(); + await promisify(this._bridge.syncStarted); + }, + + async _processIncoming(newitems) { + await super._processIncoming(newitems); + + let outgoingEnvelopesAsJSON = await promisify(this._bridge.apply); + let changeset = {}; + for (let envelopeAsJSON of outgoingEnvelopesAsJSON) { + this._log.trace("outgoing envelope", envelopeAsJSON); + let record = BridgedRecord.fromOutgoingEnvelope( + this.name, + JSON.parse(envelopeAsJSON) + ); + changeset[record.id] = { + synced: false, + record, + }; + } + this._modified.replace(changeset); + }, + + /** + * Notify the bridged engine that we've successfully uploaded a batch, so + * that it can update its local state. For example, if the engine uses a + * mirror and a temp table for outgoing records, it can write the uploaded + * records from the outgoing table back to the mirror. + */ + async _onRecordsWritten(succeeded, failed, serverModifiedTime) { + await promisify(this._bridge.setUploaded, serverModifiedTime, succeeded); + }, + + async _createTombstone() { + throw new Error("Bridged engines don't support weak uploads"); + }, + + async _createRecord(id) { + let change = this._modified.changes[id]; + if (!change) { + throw new TypeError("Can't create record for unchanged item"); + } + return change.record; + }, + + async _resetClient() { + await super._resetClient(); + await promisify(this._bridge.reset); + }, +}; + +function transformError(code, message) { + switch (code) { + case Cr.NS_ERROR_ABORT: + return new InterruptedError(message); + + default: + return new BridgeError(code, message); + } +} + +// Converts a bridged function that takes a callback into one that returns a +// promise. +function promisify(func, ...params) { + return new Promise((resolve, reject) => { + func(...params, { + // This object implicitly implements all three callback interfaces + // (`mozIBridgedSyncEngine{Apply, Result}Callback`), because they have + // the same methods. The only difference is the type of the argument + // passed to `handleSuccess`, which doesn't matter in JS. + handleSuccess: resolve, + handleError(code, message) { + reject(transformError(code, message)); + }, + }); + }); +} + +class BridgedChangeset extends Changeset { + // Only `_reconcile` calls `getModifiedTimestamp` and `has`, and the buffered + // engine does its own reconciliation. + getModifiedTimestamp(id) { + throw new Error("Don't use timestamps to resolve bridged engine conflicts"); + } + + has(id) { + throw new Error( + "Don't use the changeset to resolve bridged engine conflicts" + ); + } + + delete(id) { + let change = this.changes[id]; + if (change) { + // Mark the change as synced without removing it from the set. Depending + // on how we implement `trackRemainingChanges`, this may not be necessary. + // It's copied from the bookmarks changeset now. + change.synced = true; + } + } + + ids() { + let results = []; + for (let id in this.changes) { + if (!this.changes[id].synced) { + results.push(id); + } + } + return results; + } +} diff --git a/services/sync/modules/browserid_identity.js b/services/sync/modules/browserid_identity.js new file mode 100644 index 0000000000..36b39ee458 --- /dev/null +++ b/services/sync/modules/browserid_identity.js @@ -0,0 +1,714 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["BrowserIDManager", "AuthenticationError"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { fxAccounts } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" +); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { TokenServerClient } = ChromeUtils.import( + "resource://services-common/tokenserverclient.js" +); +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { + LOGIN_FAILED_LOGIN_REJECTED, + LOGIN_FAILED_NETWORK_ERROR, + LOGIN_FAILED_NO_USERNAME, + LOGIN_SUCCEEDED, + MASTER_PASSWORD_LOCKED, + STATUS_OK, +} = ChromeUtils.import("resource://services-sync/constants.js"); + +// Lazy imports to prevent unnecessary load on startup. +ChromeUtils.defineModuleGetter( + this, + "Weave", + "resource://services-sync/main.js" +); + +ChromeUtils.defineModuleGetter( + this, + "BulkKeyBundle", + "resource://services-sync/keys.js" +); + +ChromeUtils.defineModuleGetter( + this, + "fxAccounts", + "resource://gre/modules/FxAccounts.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "CommonUtils", + "resource://services-common/utils.js" +); + +XPCOMUtils.defineLazyGetter(this, "log", function() { + let log = Log.repository.getLogger("Sync.BrowserIDManager"); + log.manageLevelFromPref("services.sync.log.logger.identity"); + return log; +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "IGNORE_CACHED_AUTH_CREDENTIALS", + "services.sync.debug.ignoreCachedAuthCredentials" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "USE_OAUTH_FOR_SYNC_TOKEN", + "identity.sync.useOAuthForSyncToken" +); + +// FxAccountsCommon.js doesn't use a "namespace", so create one here. +var fxAccountsCommon = {}; +ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js", + fxAccountsCommon +); + +const SCOPE_OLD_SYNC = fxAccountsCommon.SCOPE_OLD_SYNC; + +const OBSERVER_TOPICS = [ + fxAccountsCommon.ONLOGIN_NOTIFICATION, + fxAccountsCommon.ONVERIFIED_NOTIFICATION, + fxAccountsCommon.ONLOGOUT_NOTIFICATION, + fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION, + "weave:connected", +]; + +/* + General authentication error for abstracting authentication + errors from multiple sources (e.g., from FxAccounts, TokenServer). + details is additional details about the error - it might be a string, or + some other error object (which should do the right thing when toString() is + called on it) +*/ +function AuthenticationError(details, source) { + this.details = details; + this.source = source; +} + +AuthenticationError.prototype = { + toString() { + return "AuthenticationError(" + this.details + ")"; + }, +}; + +function BrowserIDManager() { + // NOTE: _fxaService and _tokenServerClient are replaced with mocks by + // the test suite. + this._fxaService = fxAccounts; + this._tokenServerClient = new TokenServerClient(); + this._tokenServerClient.observerPrefix = "weave:service"; + this._log = log; + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_username", + "services.sync.username" + ); + + this.asyncObserver = Async.asyncObserver(this, log); + for (let topic of OBSERVER_TOPICS) { + Services.obs.addObserver(this.asyncObserver, topic); + } +} + +this.BrowserIDManager.prototype = { + _fxaService: null, + _tokenServerClient: null, + // https://docs.services.mozilla.com/token/apis.html + _token: null, + // protection against the user changing underneath us - the uid + // of the current user. + _userUid: null, + + hashedUID() { + const id = this._fxaService.telemetry.getSanitizedUID(); + if (!id) { + throw new Error("hashedUID: Don't seem to have previously seen a token"); + } + return id; + }, + + // Return a hashed version of a deviceID, suitable for telemetry. + hashedDeviceID(deviceID) { + const id = this._fxaService.telemetry.sanitizeDeviceId(deviceID); + if (!id) { + throw new Error("hashedUID: Don't seem to have previously seen a token"); + } + return id; + }, + + // The "node type" reported to telemetry or null if not specified. + get telemetryNodeType() { + return this._token && this._token.node_type ? this._token.node_type : null; + }, + + finalize() { + // After this is called, we can expect Service.identity != this. + for (let topic of OBSERVER_TOPICS) { + Services.obs.removeObserver(this.asyncObserver, topic); + } + this.resetCredentials(); + this._userUid = null; + }, + + async getSignedInUser() { + let data = await this._fxaService.getSignedInUser(); + if (!data) { + this._userUid = null; + return null; + } + if (this._userUid == null) { + this._userUid = data.uid; + } else if (this._userUid != data.uid) { + throw new Error("The signed in user has changed"); + } + return data; + }, + + logout() { + // This will be called when sync fails (or when the account is being + // unlinked etc). It may have failed because we got a 401 from a sync + // server, so we nuke the token. Next time sync runs and wants an + // authentication header, we will notice the lack of the token and fetch a + // new one. + this._token = null; + }, + + async observe(subject, topic, data) { + this._log.debug("observed " + topic); + if (!this.username) { + this._log.info("Sync is not configured, so ignoring the notification"); + return; + } + switch (topic) { + case "weave:connected": + case fxAccountsCommon.ONLOGIN_NOTIFICATION: { + this._log.info("Sync has been connected to a logged in user"); + this.resetCredentials(); + let accountData = await this.getSignedInUser(); + + if (!accountData.verified) { + // wait for a verified notification before we kick sync off. + this._log.info("The user is not verified"); + break; + } + } + // We've been configured with an already verified user, so fall-through. + // intentional fall-through - the user is verified. + case fxAccountsCommon.ONVERIFIED_NOTIFICATION: { + this._log.info("The user became verified"); + Weave.Status.login = LOGIN_SUCCEEDED; + + // And actually sync. If we've never synced before, we force a full sync. + // If we have, then we are probably just reauthenticating so it's a normal sync. + // We can use any pref that must be set if we've synced before, and check + // the sync lock state because we might already be doing that first sync. + let isFirstSync = + !Weave.Service.locked && !Svc.Prefs.get("client.syncID", null); + if (isFirstSync) { + this._log.info("Doing initial sync actions"); + Svc.Prefs.set("firstSync", "resetClient"); + Services.obs.notifyObservers(null, "weave:service:setup-complete"); + } + // There's no need to wait for sync to complete and it would deadlock + // our AsyncObserver. + if (!Svc.Prefs.get("testing.tps", false)) { + Weave.Service.sync({ why: "login" }); + } + break; + } + + case fxAccountsCommon.ONLOGOUT_NOTIFICATION: + Weave.Service.startOver() + .then(() => { + this._log.trace("startOver completed"); + }) + .catch(err => { + this._log.warn("Failed to reset sync", err); + }); + // startOver will cause this instance to be thrown away, so there's + // nothing else to do. + break; + + case fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION: + // throw away token forcing us to fetch a new one later. + this.resetCredentials(); + break; + } + }, + + /** + * Provide override point for testing token expiration. + */ + _now() { + return this._fxaService._internal.now(); + }, + + get _localtimeOffsetMsec() { + return this._fxaService._internal.localtimeOffsetMsec; + }, + + get syncKeyBundle() { + return this._syncKeyBundle; + }, + + get username() { + return this._username; + }, + + /** + * Set the username value. + * + * Changing the username has the side-effect of wiping credentials. + */ + set username(value) { + // setting .username is an old throwback, but it should no longer happen. + throw new Error("don't set the username"); + }, + + /** + * Resets all calculated credentials we hold for the current user. This will + * *not* force the user to reauthenticate, but instead will force us to + * calculate a new key bundle, fetch a new token, etc. + */ + resetCredentials() { + this._syncKeyBundle = null; + this._token = null; + // The cluster URL comes from the token, so resetting it to empty will + // force Sync to not accidentally use a value from an earlier token. + Weave.Service.clusterURL = null; + }, + + /** + * Pre-fetches any information that might help with migration away from this + * identity. Called after every sync and is really just an optimization that + * allows us to avoid a network request for when we actually need the + * migration info. + */ + prefetchMigrationSentinel(service) { + // nothing to do here until we decide to migrate away from FxA. + }, + + /** + * Verify the current auth state, unlocking the master-password if necessary. + * + * Returns a promise that resolves with the current auth state after + * attempting to unlock. + */ + async unlockAndVerifyAuthState() { + let data = await this.getSignedInUser(); + const fxa = this._fxaService; + if (!data) { + log.debug("unlockAndVerifyAuthState has no FxA user"); + return LOGIN_FAILED_NO_USERNAME; + } + if (!this.username) { + log.debug("unlockAndVerifyAuthState finds that sync isn't configured"); + return LOGIN_FAILED_NO_USERNAME; + } + if (!data.verified) { + // Treat not verified as if the user needs to re-auth, so the browser + // UI reflects the state. + log.debug("unlockAndVerifyAuthState has an unverified user"); + return LOGIN_FAILED_LOGIN_REJECTED; + } + if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) { + log.debug( + "unlockAndVerifyAuthState already has (or can fetch) sync keys" + ); + return STATUS_OK; + } + // so no keys - ensure MP unlocked. + if (!Utils.ensureMPUnlocked()) { + // user declined to unlock, so we don't know if they are stored there. + log.debug( + "unlockAndVerifyAuthState: user declined to unlock master-password" + ); + return MASTER_PASSWORD_LOCKED; + } + // If we still can't get keys it probably means the user authenticated + // without unlocking the MP or cleared the saved logins, so we've now + // lost them - the user will need to reauth before continuing. + let result; + if (await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC)) { + result = STATUS_OK; + } else { + result = LOGIN_FAILED_LOGIN_REJECTED; + } + log.debug( + "unlockAndVerifyAuthState re-fetched credentials and is returning", + result + ); + return result; + }, + + /** + * Do we have a non-null, not yet expired token for the user currently + * signed in? + */ + _hasValidToken() { + // If pref is set to ignore cached authentication credentials for debugging, + // then return false to force the fetching of a new token. + if (IGNORE_CACHED_AUTH_CREDENTIALS) { + return false; + } + if (!this._token) { + return false; + } + if (this._token.expiration < this._now()) { + return false; + } + return true; + }, + + // Get our tokenServerURL - a private helper. Returns a string. + get _tokenServerUrl() { + // We used to support services.sync.tokenServerURI but this was a + // pain-point for people using non-default servers as Sync may auto-reset + // all services.sync prefs. So if that still exists, it wins. + let url = Svc.Prefs.get("tokenServerURI"); // Svc.Prefs "root" is services.sync + if (!url) { + url = Services.prefs.getCharPref("identity.sync.tokenserver.uri"); + } + while (url.endsWith("/")) { + // trailing slashes cause problems... + url = url.slice(0, -1); + } + return url; + }, + + // Refresh the sync token for our user. Returns a promise that resolves + // with a token, or rejects with an error. + async _fetchTokenForUser() { + const fxa = this._fxaService; + // We need keys for things to work. If we don't have them, just + // return null for the token - sync calling unlockAndVerifyAuthState() + // before actually syncing will setup the error states if necessary. + if (!(await fxa.keys.canGetKeyForScope(SCOPE_OLD_SYNC))) { + this._log.info( + "Unable to fetch keys (master-password locked?), so aborting token fetch" + ); + throw new Error("Can't fetch a token as we can't get keys"); + } + + // Do the assertion/certificate/token dance, with a retry. + let getToken = async key => { + this._log.info("Getting a sync token from", this._tokenServerUrl); + let token; + if (USE_OAUTH_FOR_SYNC_TOKEN) { + token = await this._fetchTokenUsingOAuth(key); + } else { + token = await this._fetchTokenUsingBrowserID(key); + } + this._log.trace("Successfully got a token"); + return token; + }; + + try { + let token, key; + try { + this._log.info("Getting sync key"); + key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + if (!key) { + throw new Error("browser does not have the sync key, cannot sync"); + } + token = await getToken(key); + } catch (err) { + // If we get a 401 fetching the token it may be that our auth tokens needed + // to be regenerated; retry exactly once. + if (!err.response || err.response.status !== 401) { + throw err; + } + this._log.warn( + "Token server returned 401, retrying token fetch with fresh credentials" + ); + key = await fxa.keys.getKeyForScope(SCOPE_OLD_SYNC); + token = await getToken(key); + } + // TODO: Make it be only 80% of the duration, so refresh the token + // before it actually expires. This is to avoid sync storage errors + // otherwise, we may briefly enter a "needs reauthentication" state. + // (XXX - the above may no longer be true - someone should check ;) + token.expiration = this._now() + token.duration * 1000 * 0.8; + if (!this._syncKeyBundle) { + this._syncKeyBundle = BulkKeyBundle.fromJWK(key); + } + Weave.Status.login = LOGIN_SUCCEEDED; + this._token = token; + return token; + } catch (caughtErr) { + let err = caughtErr; // The error we will rethrow. + // TODO: unify these errors - we need to handle errors thrown by + // both tokenserverclient and hawkclient. + // A tokenserver error thrown based on a bad response. + if (err.response && err.response.status === 401) { + err = new AuthenticationError(err, "tokenserver"); + // A hawkclient error. + } else if (err.code && err.code === 401) { + err = new AuthenticationError(err, "hawkclient"); + // An FxAccounts.jsm error. + } else if (err.message == fxAccountsCommon.ERROR_AUTH_ERROR) { + err = new AuthenticationError(err, "fxaccounts"); + } + + // TODO: write tests to make sure that different auth error cases are handled here + // properly: auth error getting assertion, auth error getting token (invalid generation + // and client-state error) + if (err instanceof AuthenticationError) { + this._log.error("Authentication error in _fetchTokenForUser", err); + // set it to the "fatal" LOGIN_FAILED_LOGIN_REJECTED reason. + Weave.Status.login = LOGIN_FAILED_LOGIN_REJECTED; + } else { + this._log.error("Non-authentication error in _fetchTokenForUser", err); + // for now assume it is just a transient network related problem + // (although sadly, it might also be a regular unhandled exception) + Weave.Status.login = LOGIN_FAILED_NETWORK_ERROR; + } + throw err; + } + }, + + /** + * Fetches an OAuth token using the OLD_SYNC scope and exchanges it + * for a TokenServer token. + * + * @returns {Promise} + * @private + */ + async _fetchTokenUsingOAuth(key) { + this._log.debug("Getting a token using OAuth"); + const fxa = this._fxaService; + const ttl = fxAccountsCommon.OAUTH_TOKEN_FOR_SYNC_LIFETIME_SECONDS; + const accessToken = await fxa.getOAuthToken({ scope: SCOPE_OLD_SYNC, ttl }); + const headers = { + "X-KeyId": key.kid, + }; + + return this._tokenServerClient + .getTokenFromOAuthToken(this._tokenServerUrl, accessToken, headers) + .catch(async err => { + if (err.response && err.response.status === 401) { + // remove the cached token if we cannot authorize with it. + // we have to do this here because we know which `token` to remove + // from cache. + console.log("REMOVE CACHED", accessToken); + await fxa.removeCachedOAuthToken({ token: accessToken }); + } + + // continue the error chain, so other handlers can deal with the error. + throw err; + }); + }, + + /** + * Exchanges a BrowserID assertion for a TokenServer token. + * + * This is a legacy access method that we're in the process of deprecating; + * if you have a choice you should use `_fetchTokenUsingOAuth` above. + * + * @returns {Promise} + * @private + */ + async _fetchTokenUsingBrowserID(key) { + this._log.debug("Getting a token using BrowserID"); + const fxa = this._fxaService; + const audience = Services.io.newURI(this._tokenServerUrl).prePath; + const assertion = await fxa._internal.getAssertion(audience); + const headers = { + "X-Client-State": fxa._internal.keys.kidAsHex(key), + }; + return this._tokenServerClient + .getTokenFromBrowserIDAssertion(this._tokenServerUrl, assertion, headers) + .catch(async err => { + if (err.response && err.response.status === 401) { + this._log.warn( + "Token server returned 401, refreshing certificate and retrying token fetch" + ); + await fxa._internal.invalidateCertificate(); + } + + // continue the error chain, so other handlers can deal with the error. + throw err; + }); + }, + + // Returns a promise that is resolved with a valid token for the current + // user, or rejects if one can't be obtained. + // NOTE: This does all the authentication for Sync - it both sets the + // key bundle (ie, decryption keys) and does the token fetch. These 2 + // concepts could be decoupled, but there doesn't seem any value in that + // currently. + async _ensureValidToken(forceNewToken = false) { + let signedInUser = await this.getSignedInUser(); + if (!signedInUser) { + throw new Error("no user is logged in"); + } + if (!signedInUser.verified) { + throw new Error("user is not verified"); + } + + await this.asyncObserver.promiseObserversComplete(); + + if (!forceNewToken && this._hasValidToken()) { + this._log.trace("_ensureValidToken already has one"); + return this._token; + } + + // We are going to grab a new token - re-use the same promise if we are + // already fetching one. + if (!this._ensureValidTokenPromise) { + this._ensureValidTokenPromise = this.__ensureValidToken().finally(() => { + this._ensureValidTokenPromise = null; + }); + } + return this._ensureValidTokenPromise; + }, + + async __ensureValidToken() { + // reset this._token as a safety net to reduce the possibility of us + // repeatedly attempting to use an invalid token if _fetchTokenForUser throws. + this._token = null; + try { + let token = await this._fetchTokenForUser(); + this._token = token; + // This is a little bit of a hack. The tokenserver tells us a HMACed version + // of the FxA uid which we can use for metrics purposes without revealing the + // user's true uid. It conceptually belongs to FxA but we get it from tokenserver + // for legacy reasons. Hand it back to the FxA client code to deal with. + this._fxaService.telemetry._setHashedUID(token.hashed_fxa_uid); + return token; + } finally { + Services.obs.notifyObservers(null, "weave:service:login:change"); + } + }, + + getResourceAuthenticator() { + return this._getAuthenticationHeader.bind(this); + }, + + /** + * @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri + * of a RESTRequest or AsyncResponse object. + */ + async _getAuthenticationHeader(httpObject, method) { + // Note that in failure states we return null, causing the request to be + // made without authorization headers, thereby presumably causing a 401, + // which causes Sync to log out. If we throw, this may not happen as + // expected. + try { + await this._ensureValidToken(); + } catch (ex) { + this._log.error("Failed to fetch a token for authentication", ex); + return null; + } + if (!this._token) { + return null; + } + let credentials = { id: this._token.id, key: this._token.key }; + method = method || httpObject.method; + + // Get the local clock offset from the Firefox Accounts server. This should + // be close to the offset from the storage server. + let options = { + now: this._now(), + localtimeOffsetMsec: this._localtimeOffsetMsec, + credentials, + }; + + let headerValue = await CryptoUtils.computeHAWK( + httpObject.uri, + method, + options + ); + return { headers: { authorization: headerValue.field } }; + }, + + /** + * Determine the cluster for the current user and update state. + * Returns true if a new cluster URL was found and it is different from + * the existing cluster URL, false otherwise. + */ + async setCluster() { + // Make sure we didn't get some unexpected response for the cluster. + let cluster = await this._findCluster(); + this._log.debug("Cluster value = " + cluster); + if (cluster == null) { + return false; + } + + // Convert from the funky "String object with additional properties" that + // resource.js returns to a plain-old string. + cluster = cluster.toString(); + // Don't update stuff if we already have the right cluster + if (cluster == Weave.Service.clusterURL) { + return false; + } + + this._log.debug("Setting cluster to " + cluster); + Weave.Service.clusterURL = cluster; + + return true; + }, + + async _findCluster() { + try { + // Ensure we are ready to authenticate and have a valid token. + // We need to handle node reassignment here. If we are being asked + // for a clusterURL while the service already has a clusterURL, then + // it's likely a 401 was received using the existing token - in which + // case we just discard the existing token and fetch a new one. + let forceNewToken = false; + if (Weave.Service.clusterURL) { + this._log.debug( + "_findCluster has a pre-existing clusterURL, so fetching a new token token" + ); + forceNewToken = true; + } + let token = await this._ensureValidToken(forceNewToken); + let endpoint = token.endpoint; + // For Sync 1.5 storage endpoints, we use the base endpoint verbatim. + // However, it should end in "/" because we will extend it with + // well known path components. So we add a "/" if it's missing. + if (!endpoint.endsWith("/")) { + endpoint += "/"; + } + this._log.debug("_findCluster returning " + endpoint); + return endpoint; + } catch (err) { + this._log.info("Failed to fetch the cluster URL", err); + // service.js's verifyLogin() method will attempt to fetch a cluster + // URL when it sees a 401. If it gets null, it treats it as a "real" + // auth error and sets Status.login to LOGIN_FAILED_LOGIN_REJECTED, which + // in turn causes a notification bar to appear informing the user they + // need to re-authenticate. + // On the other hand, if fetching the cluster URL fails with an exception, + // verifyLogin() assumes it is a transient error, and thus doesn't show + // the notification bar under the assumption the issue will resolve + // itself. + // Thus: + // * On a real 401, we must return null. + // * On any other problem we must let an exception bubble up. + if (err instanceof AuthenticationError) { + return null; + } + throw err; + } + }, +}; diff --git a/services/sync/modules/collection_validator.js b/services/sync/modules/collection_validator.js new file mode 100644 index 0000000000..b5455d5b11 --- /dev/null +++ b/services/sync/modules/collection_validator.js @@ -0,0 +1,271 @@ +/* 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/. */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Async", + "resource://services-common/async.js" +); + +var EXPORTED_SYMBOLS = ["CollectionValidator", "CollectionProblemData"]; + +class CollectionProblemData { + constructor() { + this.missingIDs = 0; + this.clientDuplicates = []; + this.duplicates = []; + this.clientMissing = []; + this.serverMissing = []; + this.serverDeleted = []; + this.serverUnexpected = []; + this.differences = []; + } + + /** + * Produce a list summarizing problems found. Each entry contains {name, count}, + * where name is the field name for the problem, and count is the number of times + * the problem was encountered. + * + * Validation has failed if all counts are not 0. + */ + getSummary() { + return [ + { name: "clientMissing", count: this.clientMissing.length }, + { name: "serverMissing", count: this.serverMissing.length }, + { name: "serverDeleted", count: this.serverDeleted.length }, + { name: "serverUnexpected", count: this.serverUnexpected.length }, + { name: "differences", count: this.differences.length }, + { name: "missingIDs", count: this.missingIDs }, + { name: "clientDuplicates", count: this.clientDuplicates.length }, + { name: "duplicates", count: this.duplicates.length }, + ]; + } +} + +class CollectionValidator { + // Construct a generic collection validator. This is intended to be called by + // subclasses. + // - name: Name of the engine + // - idProp: Property that identifies a record. That is, if a client and server + // record have the same value for the idProp property, they should be + // compared against eachother. + // - props: Array of properties that should be compared + constructor(name, idProp, props) { + this.name = name; + this.props = props; + this.idProp = idProp; + + // This property deals with the fact that form history records are never + // deleted from the server. The FormValidator subclass needs to ignore the + // client missing records, and it uses this property to achieve it - + // (Bug 1354016). + this.ignoresMissingClients = false; + } + + // Should a custom ProblemData type be needed, return it here. + emptyProblemData() { + return new CollectionProblemData(); + } + + async getServerItems(engine) { + let collection = engine.itemSource(); + let collectionKey = engine.service.collectionKeys.keyForCollection( + engine.name + ); + collection.full = true; + let result = await collection.getBatched(); + if (!result.response.success) { + throw result.response; + } + let cleartexts = []; + + await Async.yieldingForEach(result.records, async record => { + await record.decrypt(collectionKey); + cleartexts.push(record.cleartext); + }); + + return cleartexts; + } + + // Should return a promise that resolves to an array of client items. + getClientItems() { + return Promise.reject("Must implement"); + } + + /** + * Can we guarantee validation will fail with a reason that isn't actually a + * problem? For example, if we know there are pending changes left over from + * the last sync, this should resolve to false. By default resolves to true. + */ + async canValidate() { + return true; + } + + // Turn the client item into something that can be compared with the server item, + // and is also safe to mutate. + normalizeClientItem(item) { + return Cu.cloneInto(item, {}); + } + + // Turn the server item into something that can be easily compared with the client + // items. + async normalizeServerItem(item) { + return item; + } + + // Return whether or not a server item should be present on the client. Expected + // to be overridden. + clientUnderstands(item) { + return true; + } + + // Return whether or not a client item should be present on the server. Expected + // to be overridden + async syncedByClient(item) { + return true; + } + + // Compare the server item and the client item, and return a list of property + // names that are different. Can be overridden if needed. + getDifferences(client, server) { + let differences = []; + for (let prop of this.props) { + let clientProp = client[prop]; + let serverProp = server[prop]; + if ((clientProp || "") !== (serverProp || "")) { + differences.push(prop); + } + } + return differences; + } + + // Returns an object containing + // problemData: an instance of the class returned by emptyProblemData(), + // clientRecords: Normalized client records + // records: Normalized server records, + // deletedRecords: Array of ids that were marked as deleted by the server. + async compareClientWithServer(clientItems, serverItems) { + const yieldState = Async.yieldState(); + + const clientRecords = []; + + await Async.yieldingForEach( + clientItems, + item => { + clientRecords.push(this.normalizeClientItem(item)); + }, + yieldState + ); + + const serverRecords = []; + await Async.yieldingForEach( + serverItems, + async item => { + serverRecords.push(await this.normalizeServerItem(item)); + }, + yieldState + ); + + let problems = this.emptyProblemData(); + let seenServer = new Map(); + let serverDeleted = new Set(); + let allRecords = new Map(); + + for (let record of serverRecords) { + let id = record[this.idProp]; + if (!id) { + ++problems.missingIDs; + continue; + } + if (record.deleted) { + serverDeleted.add(record); + } else { + let serverHasPossibleDupe = seenServer.has(id); + if (serverHasPossibleDupe) { + problems.duplicates.push(id); + } else { + seenServer.set(id, record); + allRecords.set(id, { server: record, client: null }); + } + record.understood = this.clientUnderstands(record); + } + } + + let seenClient = new Map(); + for (let record of clientRecords) { + let id = record[this.idProp]; + record.shouldSync = await this.syncedByClient(record); + let clientHasPossibleDupe = seenClient.has(id); + if (clientHasPossibleDupe && record.shouldSync) { + // Only report duplicate client IDs for syncable records. + problems.clientDuplicates.push(id); + continue; + } + seenClient.set(id, record); + let combined = allRecords.get(id); + if (combined) { + combined.client = record; + } else { + allRecords.set(id, { client: record, server: null }); + } + } + + for (let [id, { server, client }] of allRecords) { + if (!client && !server) { + throw new Error("Impossible: no client or server record for " + id); + } else if (server && !client) { + if (!this.ignoresMissingClients && server.understood) { + problems.clientMissing.push(id); + } + } else if (client && !server) { + if (client.shouldSync) { + problems.serverMissing.push(id); + } + } else { + if (!client.shouldSync) { + if (!problems.serverUnexpected.includes(id)) { + problems.serverUnexpected.push(id); + } + continue; + } + let differences = this.getDifferences(client, server); + if (differences && differences.length) { + problems.differences.push({ id, differences }); + } + } + } + return { + problemData: problems, + clientRecords, + records: serverRecords, + deletedRecords: [...serverDeleted], + }; + } + + async validate(engine) { + let start = Cu.now(); + let clientItems = await this.getClientItems(); + let serverItems = await this.getServerItems(engine); + let serverRecordCount = serverItems.length; + let result = await this.compareClientWithServer(clientItems, serverItems); + let end = Cu.now(); + let duration = end - start; + engine._log.debug(`Validated ${this.name} in ${duration}ms`); + engine._log.debug(`Problem summary`); + for (let { name, count } of result.problemData.getSummary()) { + engine._log.debug(` ${name}: ${count}`); + } + return { + duration, + version: this.version, + problems: result.problemData, + recordCount: serverRecordCount, + }; + } +} + +// Default to 0, some engines may override. +CollectionValidator.prototype.version = 0; diff --git a/services/sync/modules/constants.js b/services/sync/modules/constants.js new file mode 100644 index 0000000000..1e210d21a1 --- /dev/null +++ b/services/sync/modules/constants.js @@ -0,0 +1,136 @@ +/* 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/. */ + +// Process each item in the "constants hash" to add to "global" and give a name +var EXPORTED_SYMBOLS = []; +for (let [key, val] of Object.entries({ + // Don't manually modify this line, as it is automatically replaced on merge day + // by the gecko_migration.py script. + WEAVE_VERSION: "1.88.0", + + // Sync Server API version that the client supports. + SYNC_API_VERSION: "1.5", + + // Version of the data format this client supports. The data format describes + // how records are packaged; this is separate from the Server API version and + // the per-engine cleartext formats. + STORAGE_VERSION: 5, + PREFS_BRANCH: "services.sync.", + + // Put in [] because those aren't allowed in a collection name. + DEFAULT_KEYBUNDLE_NAME: "[default]", + + // Key dimensions. + SYNC_KEY_ENCODED_LENGTH: 26, + SYNC_KEY_DECODED_LENGTH: 16, + + NO_SYNC_NODE_INTERVAL: 10 * 60 * 1000, // 10 minutes + + MAX_ERROR_COUNT_BEFORE_BACKOFF: 3, + + // Backoff intervals + MINIMUM_BACKOFF_INTERVAL: 15 * 60 * 1000, // 15 minutes + MAXIMUM_BACKOFF_INTERVAL: 8 * 60 * 60 * 1000, // 8 hours + + // HMAC event handling timeout. + // 10 minutes: a compromise between the multi-desktop sync interval + // and the mobile sync interval. + HMAC_EVENT_INTERVAL: 600000, + + // How long to wait between sync attempts if the Master Password is locked. + MASTER_PASSWORD_LOCKED_RETRY_INTERVAL: 15 * 60 * 1000, // 15 minutes + + // 50 is hardcoded here because of URL length restrictions. + // (GUIDs can be up to 64 chars long.) + // Individual engines can set different values for their limit if their + // identifiers are shorter. + DEFAULT_GUID_FETCH_BATCH_SIZE: 50, + + // Default batch size for download batching + // (how many records are fetched at a time from the server when batching is used). + DEFAULT_DOWNLOAD_BATCH_SIZE: 1000, + + // score thresholds for early syncs + SINGLE_USER_THRESHOLD: 1000, + MULTI_DEVICE_THRESHOLD: 300, + + // Other score increment constants + SCORE_INCREMENT_SMALL: 1, + SCORE_INCREMENT_MEDIUM: 10, + + // Instant sync score increment + SCORE_INCREMENT_XLARGE: 300 + 1, //MULTI_DEVICE_THRESHOLD + 1 + + // Delay before incrementing global score + SCORE_UPDATE_DELAY: 100, + + // Delay for the back observer debouncer. This is chosen to be longer than any + // observed spurious idle/back events and short enough to pre-empt user activity. + IDLE_OBSERVER_BACK_DELAY: 100, + + // Duplicate URI_LENGTH_MAX from Places (from nsNavHistory.h), used to discard + // tabs with huge uris during tab sync. + URI_LENGTH_MAX: 65536, + + MAX_HISTORY_UPLOAD: 5000, + MAX_HISTORY_DOWNLOAD: 5000, + + // Top-level statuses: + STATUS_OK: "success.status_ok", + SYNC_FAILED: "error.sync.failed", + LOGIN_FAILED: "error.login.failed", + SYNC_FAILED_PARTIAL: "error.sync.failed_partial", + CLIENT_NOT_CONFIGURED: "service.client_not_configured", + STATUS_DISABLED: "service.disabled", + MASTER_PASSWORD_LOCKED: "service.master_password_locked", + + // success states + LOGIN_SUCCEEDED: "success.login", + SYNC_SUCCEEDED: "success.sync", + ENGINE_SUCCEEDED: "success.engine", + + // login failure status codes: + LOGIN_FAILED_NO_USERNAME: "error.login.reason.no_username", + LOGIN_FAILED_NO_PASSPHRASE: "error.login.reason.no_recoverykey", + LOGIN_FAILED_NETWORK_ERROR: "error.login.reason.network", + LOGIN_FAILED_SERVER_ERROR: "error.login.reason.server", + LOGIN_FAILED_INVALID_PASSPHRASE: "error.login.reason.recoverykey", + LOGIN_FAILED_LOGIN_REJECTED: "error.login.reason.account", + + // sync failure status codes + METARECORD_DOWNLOAD_FAIL: "error.sync.reason.metarecord_download_fail", + VERSION_OUT_OF_DATE: "error.sync.reason.version_out_of_date", + CREDENTIALS_CHANGED: "error.sync.reason.credentials_changed", + ABORT_SYNC_COMMAND: "aborting sync, process commands said so", + NO_SYNC_NODE_FOUND: "error.sync.reason.no_node_found", + OVER_QUOTA: "error.sync.reason.over_quota", + SERVER_MAINTENANCE: "error.sync.reason.serverMaintenance", + + RESPONSE_OVER_QUOTA: "14", + + // engine failure status codes + ENGINE_UPLOAD_FAIL: "error.engine.reason.record_upload_fail", + ENGINE_DOWNLOAD_FAIL: "error.engine.reason.record_download_fail", + ENGINE_UNKNOWN_FAIL: "error.engine.reason.unknown_fail", + ENGINE_APPLY_FAIL: "error.engine.reason.apply_fail", + // an upload failure where the batch was interrupted with a 412 + ENGINE_BATCH_INTERRUPTED: "error.engine.reason.batch_interrupted", + + // Ways that a sync can be disabled (messages only to be printed in debug log) + kSyncMasterPasswordLocked: "User elected to leave Master Password locked", + kSyncWeaveDisabled: "Weave is disabled", + kSyncNetworkOffline: "Network is offline", + kSyncBackoffNotMet: "Trying to sync before the server said it's okay", + kFirstSyncChoiceNotMade: "User has not selected an action for first sync", + kSyncNotConfigured: "Sync is not configured", + kFirefoxShuttingDown: "Firefox is about to shut down", + + DEVICE_TYPE_DESKTOP: "desktop", + DEVICE_TYPE_MOBILE: "mobile", + + SQLITE_MAX_VARIABLE_NUMBER: 999, +})) { + this[key] = val; + this.EXPORTED_SYMBOLS.push(key); +} diff --git a/services/sync/modules/doctor.js b/services/sync/modules/doctor.js new file mode 100644 index 0000000000..bb7864c47a --- /dev/null +++ b/services/sync/modules/doctor.js @@ -0,0 +1,197 @@ +/* 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/. */ + +// A doctor for our collections. She can be asked to make a consultation, and +// may just diagnose an issue without attempting to cure it, may diagnose and +// attempt to cure, or may decide she is overworked and underpaid. +// Or something - naming is hard :) + +"use strict"; + +var EXPORTED_SYMBOLS = ["Doctor"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { Service } = ChromeUtils.import("resource://services-sync/service.js"); +const { Resource } = ChromeUtils.import("resource://services-sync/resource.js"); + +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +const log = Log.repository.getLogger("Sync.Doctor"); + +var Doctor = { + async consult(recentlySyncedEngines) { + if (!Services.telemetry.canRecordBase) { + log.info("Skipping consultation: telemetry reporting is disabled"); + return; + } + + let engineInfos = this._getEnginesToValidate(recentlySyncedEngines); + + await this._runValidators(engineInfos); + }, + + _getEnginesToValidate(recentlySyncedEngines) { + let result = {}; + for (let e of recentlySyncedEngines) { + let prefPrefix = `engine.${e.name}.`; + if (!Svc.Prefs.get(prefPrefix + "validation.enabled", false)) { + log.info(`Skipping check of ${e.name} - disabled via preferences`); + continue; + } + // Check the last validation time for the engine. + let lastValidation = Svc.Prefs.get(prefPrefix + "validation.lastTime", 0); + let validationInterval = Svc.Prefs.get( + prefPrefix + "validation.interval" + ); + let nowSeconds = this._now(); + + if (nowSeconds - lastValidation < validationInterval) { + log.info( + `Skipping validation of ${e.name}: too recent since last validation attempt` + ); + continue; + } + // Update the time now, even if we decline to actually perform a + // validation. We don't want to check the rest of these more frequently + // than once a day. + Svc.Prefs.set(prefPrefix + "validation.lastTime", Math.floor(nowSeconds)); + + // Validation only occurs a certain percentage of the time. + let validationProbability = + Svc.Prefs.get(prefPrefix + "validation.percentageChance", 0) / 100.0; + if (validationProbability < Math.random()) { + log.info( + `Skipping validation of ${e.name}: Probability threshold not met` + ); + continue; + } + + let maxRecords = Svc.Prefs.get(prefPrefix + "validation.maxRecords"); + if (!maxRecords) { + log.info(`Skipping validation of ${e.name}: No maxRecords specified`); + continue; + } + // OK, so this is a candidate - the final decision will be based on the + // number of records actually found. + result[e.name] = { engine: e, maxRecords }; + } + return result; + }, + + async _runValidators(engineInfos) { + if (Object.keys(engineInfos).length == 0) { + log.info("Skipping validation: no engines qualify"); + return; + } + + if ( + Object.values(engineInfos).filter(i => i.maxRecords != -1).length != 0 + ) { + // at least some of the engines have maxRecord restrictions which require + // us to ask the server for the counts. + let countInfo = await this._fetchCollectionCounts(); + for (let [engineName, recordCount] of Object.entries(countInfo)) { + if (engineName in engineInfos) { + engineInfos[engineName].recordCount = recordCount; + } + } + } + + for (let [ + engineName, + { engine, maxRecords, recordCount }, + ] of Object.entries(engineInfos)) { + // maxRecords of -1 means "any number", so we can skip asking the server. + // Used for tests. + if (maxRecords >= 0 && recordCount > maxRecords) { + log.debug( + `Skipping validation for ${engineName} because ` + + `the number of records (${recordCount}) is greater ` + + `than the maximum allowed (${maxRecords}).` + ); + continue; + } + let validator = engine.getValidator(); + if (!validator) { + // This is probably only possible in profile downgrade cases. + log.warn( + `engine.getValidator returned null for ${engineName} but the pref that controls validation is enabled.` + ); + continue; + } + + if (!(await validator.canValidate())) { + log.debug( + `Skipping validation for ${engineName} because validator.canValidate() is false` + ); + continue; + } + + // Let's do it! + Services.console.logStringMessage( + `Sync is about to run a consistency check of ${engine.name}. This may be slow, and ` + + `can be controlled using the pref "services.sync.${engine.name}.validation.enabled".\n` + + `If you encounter any problems because of this, please file a bug.` + ); + + try { + log.info(`Running validator for ${engine.name}`); + let result = await validator.validate(engine); + let { problems, version, duration, recordCount } = result; + Observers.notify( + "weave:engine:validate:finish", + { + version, + checked: recordCount, + took: duration, + problems: problems ? problems.getSummary(true) : null, + }, + engine.name + ); + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + log.error(`Failed to run validation on ${engine.name}!`, ex); + Observers.notify("weave:engine:validate:error", ex, engine.name); + // Keep validating -- there's no reason to think that a failure for one + // validator would mean the others will fail. + } + } + }, + + // mainly for mocking. + async _fetchCollectionCounts() { + let collectionCountsURL = Service.userBaseURL + "info/collection_counts"; + try { + let infoResp = await Service._fetchInfo(collectionCountsURL); + if (!infoResp.success) { + log.error( + "Can't fetch collection counts: request to info/collection_counts responded with " + + infoResp.status + ); + return {}; + } + return infoResp.obj; // might throw because obj is a getter which parses json. + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + // Not running validation is totally fine, so we just write an error log and return. + log.error("Caught error when fetching counts", ex); + return {}; + } + }, + + // functions used so tests can mock them + _now() { + // We use the server time, which is SECONDS + return Resource.serverTime; + }, +}; diff --git a/services/sync/modules/engines.js b/services/sync/modules/engines.js new file mode 100644 index 0000000000..3fb54f534a --- /dev/null +++ b/services/sync/modules/engines.js @@ -0,0 +1,2280 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = [ + "EngineManager", + "SyncEngine", + "Tracker", + "LegacyTracker", + "Store", + "Changeset", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { JSONFile } = ChromeUtils.import("resource://gre/modules/JSONFile.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { + DEFAULT_DOWNLOAD_BATCH_SIZE, + DEFAULT_GUID_FETCH_BATCH_SIZE, + ENGINE_BATCH_INTERRUPTED, + ENGINE_DOWNLOAD_FAIL, + ENGINE_UPLOAD_FAIL, + VERSION_OUT_OF_DATE, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Collection, CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Resource } = ChromeUtils.import("resource://services-sync/resource.js"); +const { SerializableSet, Svc, Utils } = ChromeUtils.import( + "resource://services-sync/util.js" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + fxAccounts: "resource://gre/modules/FxAccounts.jsm", + OS: "resource://gre/modules/osfile.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +function ensureDirectory(path) { + let basename = OS.Path.dirname(path); + return OS.File.makeDir(basename, { from: OS.Constants.Path.profileDir }); +} + +/** + * Trackers are associated with a single engine and deal with + * listening for changes to their particular data type. + * + * The base `Tracker` only supports listening for changes, and bumping the score + * to indicate how urgently the engine wants to sync. It does not persist any + * data. Engines that track changes directly in the storage layer (like + * bookmarks, bridged engines, addresses, and credit cards) or only upload a + * single record (tabs and preferences) should subclass `Tracker`. + */ +function Tracker(name, engine) { + if (!engine) { + throw new Error("Tracker must be associated with an Engine instance."); + } + + name = name || "Unnamed"; + this.name = name.toLowerCase(); + this.engine = engine; + + this._log = Log.repository.getLogger(`Sync.Engine.${name}.Tracker`); + + this._score = 0; + + this.asyncObserver = Async.asyncObserver(this, this._log); +} + +Tracker.prototype = { + // New-style trackers use change sources to filter out changes made by Sync in + // observer notifications, so we don't want to let the engine ignore all + // changes during a sync. + get ignoreAll() { + return false; + }, + + // Define an empty setter so that the engine doesn't throw a `TypeError` + // setting a read-only property. + set ignoreAll(value) {}, + + /* + * Score can be called as often as desired to decide which engines to sync + * + * Valid values for score: + * -1: Do not sync unless the user specifically requests it (almost disabled) + * 0: Nothing has changed + * 100: Please sync me ASAP! + * + * Setting it to other values should (but doesn't currently) throw an exception + */ + get score() { + return this._score; + }, + + set score(value) { + this._score = value; + Observers.notify("weave:engine:score:updated", this.name); + }, + + // Should be called by service everytime a sync has been done for an engine + resetScore() { + this._score = 0; + }, + + // Unsupported, and throws a more descriptive error to ensure callers aren't + // accidentally using persistence. + async getChangedIDs() { + throw new TypeError("This tracker doesn't store changed IDs"); + }, + + // Also unsupported. + async addChangedID(id, when) { + throw new TypeError("Can't add changed ID to this tracker"); + }, + + // Ditto. + async removeChangedID(...ids) { + throw new TypeError("Can't remove changed IDs from this tracker"); + }, + + // This method is called at various times, so we override with a no-op + // instead of throwing. + clearChangedIDs() {}, + + _now() { + return Date.now() / 1000; + }, + + _isTracking: false, + + start() { + if (!this.engineIsEnabled()) { + return; + } + this._log.trace("start()."); + if (!this._isTracking) { + this.onStart(); + this._isTracking = true; + } + }, + + async stop() { + this._log.trace("stop()."); + if (this._isTracking) { + await this.asyncObserver.promiseObserversComplete(); + this.onStop(); + this._isTracking = false; + } + }, + + // Override these in your subclasses. + onStart() {}, + onStop() {}, + async observe(subject, topic, data) {}, + + engineIsEnabled() { + if (!this.engine) { + // Can't tell -- we must be running in a test! + return true; + } + return this.engine.enabled; + }, + + /** + * Starts or stops listening for changes depending on the associated engine's + * enabled state. + * + * @param {Boolean} engineEnabled Whether the engine was enabled. + */ + async onEngineEnabledChanged(engineEnabled) { + if (engineEnabled == this._isTracking) { + return; + } + + if (engineEnabled) { + this.start(); + } else { + await this.stop(); + this.clearChangedIDs(); + } + }, + + async finalize() { + await this.stop(); + }, +}; + +/* + * A tracker that persists a list of IDs for all changed items that need to be + * synced. This is 🚨 _extremely deprecated_ 🚨 and only kept around for current + * engines. ⚠️ Please **don't use it** for new engines! ⚠️ + * + * Why is this kind of external change tracking deprecated? Because it causes + * consistency issues due to missed notifications, interrupted syncs, and the + * tracker's view of what changed diverging from the data store's. + */ +function LegacyTracker(name, engine) { + Tracker.call(this, name, engine); + + this._ignored = []; + this.file = this.name; + this._storage = new JSONFile({ + path: Utils.jsonFilePath("changes/" + this.file), + dataPostProcessor: json => this._dataPostProcessor(json), + beforeSave: () => this._beforeSave(), + }); + this._ignoreAll = false; +} + +LegacyTracker.prototype = { + __proto__: Tracker.prototype, + + get ignoreAll() { + return this._ignoreAll; + }, + + set ignoreAll(value) { + this._ignoreAll = value; + }, + + // Default to an empty object if the file doesn't exist. + _dataPostProcessor(json) { + return (typeof json == "object" && json) || {}; + }, + + // Ensure the Weave storage directory exists before writing the file. + _beforeSave() { + return ensureDirectory(this._storage.path); + }, + + async getChangedIDs() { + await this._storage.load(); + return this._storage.data; + }, + + _saveChangedIDs() { + this._storage.saveSoon(); + }, + + // ignore/unignore specific IDs. Useful for ignoring items that are + // being processed, or that shouldn't be synced. + // But note: not persisted to disk + + ignoreID(id) { + this.unignoreID(id); + this._ignored.push(id); + }, + + unignoreID(id) { + let index = this._ignored.indexOf(id); + if (index != -1) { + this._ignored.splice(index, 1); + } + }, + + async _saveChangedID(id, when) { + this._log.trace(`Adding changed ID: ${id}, ${JSON.stringify(when)}`); + const changedIDs = await this.getChangedIDs(); + changedIDs[id] = when; + this._saveChangedIDs(); + }, + + async addChangedID(id, when) { + if (!id) { + this._log.warn("Attempted to add undefined ID to tracker"); + return false; + } + + if (this.ignoreAll || this._ignored.includes(id)) { + return false; + } + + // Default to the current time in seconds if no time is provided. + if (when == null) { + when = this._now(); + } + + const changedIDs = await this.getChangedIDs(); + // Add/update the entry if we have a newer time. + if ((changedIDs[id] || -Infinity) < when) { + await this._saveChangedID(id, when); + } + + return true; + }, + + async removeChangedID(...ids) { + if (!ids.length || this.ignoreAll) { + return false; + } + for (let id of ids) { + if (!id) { + this._log.warn("Attempted to remove undefined ID from tracker"); + continue; + } + if (this._ignored.includes(id)) { + this._log.debug(`Not removing ignored ID ${id} from tracker`); + continue; + } + const changedIDs = await this.getChangedIDs(); + if (changedIDs[id] != null) { + this._log.trace("Removing changed ID " + id); + delete changedIDs[id]; + } + } + this._saveChangedIDs(); + return true; + }, + + clearChangedIDs() { + this._log.trace("Clearing changed ID list"); + this._storage.data = {}; + this._saveChangedIDs(); + }, + + async finalize() { + // Persist all pending tracked changes to disk, and wait for the final write + // to finish. + await super.finalize(); + this._saveChangedIDs(); + await this._storage.finalize(); + }, +}; + +/** + * The Store serves as the interface between Sync and stored data. + * + * The name "store" is slightly a misnomer because it doesn't actually "store" + * anything. Instead, it serves as a gateway to something that actually does + * the "storing." + * + * The store is responsible for record management inside an engine. It tells + * Sync what items are available for Sync, converts items to and from Sync's + * record format, and applies records from Sync into changes on the underlying + * store. + * + * Store implementations require a number of functions to be implemented. These + * are all documented below. + * + * For stores that deal with many records or which have expensive store access + * routines, it is highly recommended to implement a custom applyIncomingBatch + * and/or applyIncoming function on top of the basic APIs. + */ + +function Store(name, engine) { + if (!engine) { + throw new Error("Store must be associated with an Engine instance."); + } + + name = name || "Unnamed"; + this.name = name.toLowerCase(); + this.engine = engine; + + this._log = Log.repository.getLogger(`Sync.Engine.${name}.Store`); + + XPCOMUtils.defineLazyGetter(this, "_timer", function() { + return Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + }); +} +Store.prototype = { + /** + * Apply multiple incoming records against the store. + * + * This is called with a set of incoming records to process. The function + * should look at each record, reconcile with the current local state, and + * make the local changes required to bring its state in alignment with the + * record. + * + * The default implementation simply iterates over all records and calls + * applyIncoming(). Store implementations may overwrite this function + * if desired. + * + * @param records Array of records to apply + * @return Array of record IDs which did not apply cleanly + */ + async applyIncomingBatch(records) { + let failed = []; + + await Async.yieldingForEach(records, async record => { + try { + await this.applyIncoming(record); + } catch (ex) { + if (ex.code == SyncEngine.prototype.eEngineAbortApplyIncoming) { + // This kind of exception should have a 'cause' attribute, which is an + // originating exception. + // ex.cause will carry its stack with it when rethrown. + throw ex.cause; + } + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.warn("Failed to apply incoming record " + record.id, ex); + failed.push(record.id); + } + }); + + return failed; + }, + + /** + * Apply a single record against the store. + * + * This takes a single record and makes the local changes required so the + * local state matches what's in the record. + * + * The default implementation calls one of remove(), create(), or update() + * depending on the state obtained from the store itself. Store + * implementations may overwrite this function if desired. + * + * @param record + * Record to apply + */ + async applyIncoming(record) { + if (record.deleted) { + await this.remove(record); + } else if (!(await this.itemExists(record.id))) { + await this.create(record); + } else { + await this.update(record); + } + }, + + // override these in derived objects + + /** + * Create an item in the store from a record. + * + * This is called by the default implementation of applyIncoming(). If using + * applyIncomingBatch(), this won't be called unless your store calls it. + * + * @param record + * The store record to create an item from + */ + async create(record) { + throw new Error("override create in a subclass"); + }, + + /** + * Remove an item in the store from a record. + * + * This is called by the default implementation of applyIncoming(). If using + * applyIncomingBatch(), this won't be called unless your store calls it. + * + * @param record + * The store record to delete an item from + */ + async remove(record) { + throw new Error("override remove in a subclass"); + }, + + /** + * Update an item from a record. + * + * This is called by the default implementation of applyIncoming(). If using + * applyIncomingBatch(), this won't be called unless your store calls it. + * + * @param record + * The record to use to update an item from + */ + async update(record) { + throw new Error("override update in a subclass"); + }, + + /** + * Determine whether a record with the specified ID exists. + * + * Takes a string record ID and returns a booleans saying whether the record + * exists. + * + * @param id + * string record ID + * @return boolean indicating whether record exists locally + */ + async itemExists(id) { + throw new Error("override itemExists in a subclass"); + }, + + /** + * Create a record from the specified ID. + * + * If the ID is known, the record should be populated with metadata from + * the store. If the ID is not known, the record should be created with the + * delete field set to true. + * + * @param id + * string record ID + * @param collection + * Collection to add record to. This is typically passed into the + * constructor for the newly-created record. + * @return record type for this engine + */ + async createRecord(id, collection) { + throw new Error("override createRecord in a subclass"); + }, + + /** + * Change the ID of a record. + * + * @param oldID + * string old/current record ID + * @param newID + * string new record ID + */ + async changeItemID(oldID, newID) { + throw new Error("override changeItemID in a subclass"); + }, + + /** + * Obtain the set of all known record IDs. + * + * @return Object with ID strings as keys and values of true. The values + * are ignored. + */ + async getAllIDs() { + throw new Error("override getAllIDs in a subclass"); + }, + + /** + * Wipe all data in the store. + * + * This function is called during remote wipes or when replacing local data + * with remote data. + * + * This function should delete all local data that the store is managing. It + * can be thought of as clearing out all state and restoring the "new + * browser" state. + */ + async wipe() { + throw new Error("override wipe in a subclass"); + }, +}; + +function EngineManager(service) { + this.service = service; + + this._engines = {}; + + this._altEngineInfo = {}; + + // This will be populated by Service on startup. + this._declined = new Set(); + this._log = Log.repository.getLogger("Sync.EngineManager"); + this._log.manageLevelFromPref("services.sync.log.logger.service.engines"); + // define the default level for all engine logs here (although each engine + // allows its level to be controlled via a specific, non-default pref) + Log.repository + .getLogger(`Sync.Engine`) + .manageLevelFromPref("services.sync.log.logger.engine"); +} +EngineManager.prototype = { + get(name) { + // Return an array of engines if we have an array of names + if (Array.isArray(name)) { + let engines = []; + name.forEach(function(name) { + let engine = this.get(name); + if (engine) { + engines.push(engine); + } + }, this); + return engines; + } + + return this._engines[name]; // Silently returns undefined for unknown names. + }, + + getAll() { + let engines = []; + for (let [, engine] of Object.entries(this._engines)) { + engines.push(engine); + } + return engines; + }, + + /** + * If a user has changed a pref that controls which variant of a sync engine + * for a given collection we use, unregister the old engine and register the + * new one. + * + * This is called by EngineSynchronizer before every sync. + */ + async switchAlternatives() { + for (let [name, info] of Object.entries(this._altEngineInfo)) { + let prefValue = info.prefValue; + if (prefValue === info.lastValue) { + this._log.trace( + `No change for engine ${name} (${info.pref} is still ${prefValue})` + ); + continue; + } + // Unregister the old engine, register the new one. + this._log.info( + `Switching ${name} engine ("${info.pref}" went from ${info.lastValue} => ${prefValue})` + ); + try { + await this._removeAndFinalize(name); + } catch (e) { + this._log.warn(`Failed to remove previous ${name} engine...`, e); + } + let engineType = prefValue ? info.whenTrue : info.whenFalse; + try { + // If register throws, we'll try again next sync, but until then there + // won't be an engine registered for this collection. + await this.register(engineType); + info.lastValue = prefValue; + // Note: engineType.name is using Function.prototype.name. + this._log.info(`Switched the ${name} engine to use ${engineType.name}`); + } catch (e) { + this._log.warn( + `Switching the ${name} engine to use ${engineType.name} failed (couldn't register)`, + e + ); + } + } + }, + + async registerAlternatives(name, pref, whenTrue, whenFalse) { + let info = { name, pref, whenTrue, whenFalse }; + + XPCOMUtils.defineLazyPreferenceGetter(info, "prefValue", pref, false); + + let chosen = info.prefValue ? info.whenTrue : info.whenFalse; + info.lastValue = info.prefValue; + this._altEngineInfo[name] = info; + + await this.register(chosen); + }, + + /** + * N.B., does not pay attention to the declined list. + */ + getEnabled() { + return this.getAll() + .filter(engine => engine.enabled) + .sort((a, b) => a.syncPriority - b.syncPriority); + }, + + get enabledEngineNames() { + return this.getEnabled().map(e => e.name); + }, + + persistDeclined() { + Svc.Prefs.set("declinedEngines", [...this._declined].join(",")); + }, + + /** + * Returns an array. + */ + getDeclined() { + return [...this._declined]; + }, + + setDeclined(engines) { + this._declined = new Set(engines); + this.persistDeclined(); + }, + + isDeclined(engineName) { + return this._declined.has(engineName); + }, + + /** + * Accepts a Set or an array. + */ + decline(engines) { + for (let e of engines) { + this._declined.add(e); + } + this.persistDeclined(); + }, + + undecline(engines) { + for (let e of engines) { + this._declined.delete(e); + } + this.persistDeclined(); + }, + + /** + * Register an Engine to the service. Alternatively, give an array of engine + * objects to register. + * + * @param engineObject + * Engine object used to get an instance of the engine + * @return The engine object if anything failed + */ + async register(engineObject) { + if (Array.isArray(engineObject)) { + for (const e of engineObject) { + await this.register(e); + } + return; + } + + try { + let engine = new engineObject(this.service); + let name = engine.name; + if (name in this._engines) { + this._log.error("Engine '" + name + "' is already registered!"); + } else { + if (engine.initialize) { + await engine.initialize(); + } + this._engines[name] = engine; + } + } catch (ex) { + let name = engineObject || ""; + name = name.prototype || ""; + name = name.name || ""; + + this._log.error(`Could not initialize engine ${name}`, ex); + } + }, + + async unregister(val) { + let name = val; + if (val instanceof SyncEngine) { + name = val.name; + } + await this._removeAndFinalize(name); + delete this._altEngineInfo[name]; + }, + + // Common code for disabling an engine by name, that doesn't complain if the + // engine doesn't exist. Doesn't touch the engine's alternative info (if any + // exists). + async _removeAndFinalize(name) { + if (name in this._engines) { + let engine = this._engines[name]; + delete this._engines[name]; + await engine.finalize(); + } + }, + + async clear() { + for (let name in this._engines) { + let engine = this._engines[name]; + delete this._engines[name]; + await engine.finalize(); + } + this._altEngineInfo = {}; + }, +}; + +function SyncEngine(name, service) { + if (!service) { + throw new Error("SyncEngine must be associated with a Service instance."); + } + + this.Name = name || "Unnamed"; + this.name = name.toLowerCase(); + this.service = service; + + this._notify = Utils.notify("weave:engine:"); + this._log = Log.repository.getLogger("Sync.Engine." + this.Name); + this._log.manageLevelFromPref(`services.sync.log.logger.engine.${this.name}`); + + this._modified = this.emptyChangeset(); + this._tracker; // initialize tracker to load previously changed IDs + this._log.debug("Engine constructed"); + + this._toFetchStorage = new JSONFile({ + path: Utils.jsonFilePath("toFetch/" + this.name), + dataPostProcessor: json => this._metadataPostProcessor(json), + beforeSave: () => this._beforeSaveMetadata(), + }); + + this._previousFailedStorage = new JSONFile({ + path: Utils.jsonFilePath("failed/" + this.name), + dataPostProcessor: json => this._metadataPostProcessor(json), + beforeSave: () => this._beforeSaveMetadata(), + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_enabled", + `services.sync.engine.${this.prefName}`, + false + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_syncID", + `services.sync.${this.name}.syncID`, + "" + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_lastSync", + `services.sync.${this.name}.lastSync`, + "0", + null, + v => parseFloat(v) + ); + // Async initializations can be made in the initialize() method. + + // The map of ids => metadata for records needing a weak upload. + // + // Currently the "metadata" fields are: + // + // - forceTombstone: whether or not we should ignore the local information + // about the record, and write a tombstone for it anyway -- e.g. in the case + // of records that should exist locally, but should never be uploaded to the + // server (note that not all sync engines support tombstones) + // + // The difference between this and a "normal" upload is that these records + // are only tracked in memory, and if the upload attempt fails (shutdown, + // 412, etc), we abort uploading the "weak" set (by clearing the map). + // + // The rationale here is for the cases where we receive a record from the + // server that we know is wrong in some (small) way. For example, the + // dateAdded field on bookmarks -- maybe we have a better date, or the server + // record is entirely missing the date, etc. + // + // In these cases, we fix our local copy of the record, and mark it for + // weak upload. A normal ("strong") upload is problematic here because + // in the case of a conflict from the server, there's a window where our + // record would be marked as modified more recently than a change that occurs + // on another device change, and we lose data from the user. + this._needWeakUpload = new Map(); + + this.asyncObserver = Async.asyncObserver(this, this._log); +} + +// Enumeration to define approaches to handling bad records. +// Attached to the constructor to allow use as a kind of static enumeration. +SyncEngine.kRecoveryStrategy = { + ignore: "ignore", + retry: "retry", + error: "error", +}; + +SyncEngine.prototype = { + _recordObj: CryptoWrapper, + // _storeObj, and _trackerObj should to be overridden in subclasses + _storeObj: Store, + _trackerObj: Tracker, + version: 1, + + // Local 'constant'. + // Signal to the engine that processing further records is pointless. + eEngineAbortApplyIncoming: "error.engine.abort.applyincoming", + + // Should we keep syncing if we find a record that cannot be uploaded (ever)? + // If this is false, we'll throw, otherwise, we'll ignore the record and + // continue. This currently can only happen due to the record being larger + // than the record upload limit. + allowSkippedRecord: true, + + // Which sortindex to use when retrieving records for this engine. + _defaultSort: undefined, + + _hasSyncedThisSession: false, + + _metadataPostProcessor(json) { + if (Array.isArray(json)) { + // Pre-`JSONFile` storage stored an array, but `JSONFile` defaults to + // an object, so we wrap the array for consistency. + json = { ids: json }; + } + if (!json.ids) { + json.ids = []; + } + // The set serializes the same way as an array, but offers more efficient + // methods of manipulation. + json.ids = new SerializableSet(json.ids); + return json; + }, + + async _beforeSaveMetadata() { + await ensureDirectory(this._toFetchStorage.path); + await ensureDirectory(this._previousFailedStorage.path); + }, + + // A relative priority to use when computing an order + // for engines to be synced. Higher-priority engines + // (lower numbers) are synced first. + // It is recommended that a unique value be used for each engine, + // in order to guarantee a stable sequence. + syncPriority: 0, + + // How many records to pull in a single sync. This is primarily to avoid very + // long first syncs against profiles with many history records. + downloadLimit: null, + + // How many records to pull at one time when specifying IDs. This is to avoid + // URI length limitations. + guidFetchBatchSize: DEFAULT_GUID_FETCH_BATCH_SIZE, + + downloadBatchSize: DEFAULT_DOWNLOAD_BATCH_SIZE, + + async initialize() { + await this._toFetchStorage.load(); + await this._previousFailedStorage.load(); + Svc.Prefs.observe(`engine.${this.prefName}`, this.asyncObserver); + this._log.debug("SyncEngine initialized", this.name); + }, + + get prefName() { + return this.name; + }, + + get enabled() { + return this._enabled; + }, + + set enabled(val) { + if (!!val != this._enabled) { + Svc.Prefs.set("engine." + this.prefName, !!val); + } + }, + + get score() { + return this._tracker.score; + }, + + get _store() { + let store = new this._storeObj(this.Name, this); + this.__defineGetter__("_store", () => store); + return store; + }, + + get _tracker() { + let tracker = new this._trackerObj(this.Name, this); + this.__defineGetter__("_tracker", () => tracker); + return tracker; + }, + + get storageURL() { + return this.service.storageURL; + }, + + get engineURL() { + return this.storageURL + this.name; + }, + + get cryptoKeysURL() { + return this.storageURL + "crypto/keys"; + }, + + get metaURL() { + return this.storageURL + "meta/global"; + }, + + startTracking() { + this._tracker.start(); + }, + + // Returns a promise + stopTracking() { + return this._tracker.stop(); + }, + + // Listens for engine enabled state changes, and updates the tracker's state. + // This is an async observer because the tracker waits on all its async + // observers to finish when it's stopped. + async observe(subject, topic, data) { + if ( + topic == "nsPref:changed" && + data == `services.sync.engine.${this.prefName}` + ) { + await this._tracker.onEngineEnabledChanged(this._enabled); + } + }, + + async sync() { + if (!this.enabled) { + return false; + } + + if (!this._sync) { + throw new Error("engine does not implement _sync method"); + } + + return this._notify("sync", this.name, this._sync)(); + }, + + // Override this method to return a new changeset type. + emptyChangeset() { + return new Changeset(); + }, + + /** + * Returns the local sync ID for this engine, or `""` if the engine hasn't + * synced for the first time. This is exposed for tests. + * + * @return the current sync ID. + */ + async getSyncID() { + return this._syncID; + }, + + /** + * Ensures that the local sync ID for the engine matches the sync ID for the + * collection on the server. A mismatch indicates that another client wiped + * the collection; we're syncing after a node reassignment, and another + * client synced before us; or the store was replaced since the last sync. + * In case of a mismatch, we need to reset all local Sync state and start + * over as a first sync. + * + * In most cases, this method should return the new sync ID as-is. However, an + * engine may ignore the given ID and assign a different one, if it determines + * that the sync ID on the server is out of date. The bookmarks engine uses + * this to wipe the server and other clients on the first sync after the user + * restores from a backup. + * + * @param newSyncID + * The new sync ID for the collection from `meta/global`. + * @return The assigned sync ID. If this doesn't match `newSyncID`, we'll + * replace the sync ID in `meta/global` with the assigned ID. + */ + async ensureCurrentSyncID(newSyncID) { + let existingSyncID = this._syncID; + if (existingSyncID == newSyncID) { + return existingSyncID; + } + this._log.debug("Engine syncIDs: " + [newSyncID, existingSyncID]); + Svc.Prefs.set(this.name + ".syncID", newSyncID); + Svc.Prefs.set(this.name + ".lastSync", "0"); + return newSyncID; + }, + + /** + * Resets the local sync ID for the engine, wipes the server, and resets all + * local Sync state to start over as a first sync. + * + * @return the new sync ID. + */ + async resetSyncID() { + let newSyncID = await this.resetLocalSyncID(); + await this.wipeServer(); + return newSyncID; + }, + + /** + * Resets the local sync ID for the engine, signaling that we're starting over + * as a first sync. + * + * @return the new sync ID. + */ + async resetLocalSyncID() { + return this.ensureCurrentSyncID(Utils.makeGUID()); + }, + + /** + * Allows overriding scheduler logic -- added to help reduce kinto server + * getting hammered because our scheduler never got tuned for it. + * + * Note: Overriding engines must take resyncs into account -- score will not + * be cleared. + */ + shouldSkipSync(syncReason) { + return false; + }, + + /* + * lastSync is a timestamp in server time. + */ + async getLastSync() { + return this._lastSync; + }, + async setLastSync(lastSync) { + // Store the value as a string to keep floating point precision + Svc.Prefs.set(this.name + ".lastSync", lastSync.toString()); + }, + async resetLastSync() { + this._log.debug("Resetting " + this.name + " last sync time"); + await this.setLastSync(0); + }, + + get hasSyncedThisSession() { + return this._hasSyncedThisSession; + }, + + set hasSyncedThisSession(hasSynced) { + this._hasSyncedThisSession = hasSynced; + }, + + get toFetch() { + this._toFetchStorage.ensureDataReady(); + return this._toFetchStorage.data.ids; + }, + + set toFetch(ids) { + if (ids.constructor.name != "SerializableSet") { + throw new Error( + "Bug: Attempted to set toFetch to something that isn't a SerializableSet" + ); + } + this._toFetchStorage.data = { ids }; + this._toFetchStorage.saveSoon(); + }, + + get previousFailed() { + this._previousFailedStorage.ensureDataReady(); + return this._previousFailedStorage.data.ids; + }, + + set previousFailed(ids) { + if (ids.constructor.name != "SerializableSet") { + throw new Error( + "Bug: Attempted to set previousFailed to something that isn't a SerializableSet" + ); + } + this._previousFailedStorage.data = { ids }; + this._previousFailedStorage.saveSoon(); + }, + + /* + * Returns a changeset for this sync. Engine implementations can override this + * method to bypass the tracker for certain or all changed items. + */ + async getChangedIDs() { + return this._tracker.getChangedIDs(); + }, + + // Create a new record using the store and add in metadata. + async _createRecord(id) { + let record = await this._store.createRecord(id, this.name); + record.id = id; + record.collection = this.name; + return record; + }, + + // Creates a tombstone Sync record with additional metadata. + _createTombstone(id) { + let tombstone = new this._recordObj(this.name, id); + tombstone.id = id; + tombstone.collection = this.name; + tombstone.deleted = true; + return tombstone; + }, + + addForWeakUpload(id, { forceTombstone = false } = {}) { + this._needWeakUpload.set(id, { forceTombstone }); + }, + + // Any setup that needs to happen at the beginning of each sync. + async _syncStartup() { + // Determine if we need to wipe on outdated versions + let metaGlobal = await this.service.recordManager.get(this.metaURL); + let engines = metaGlobal.payload.engines || {}; + let engineData = engines[this.name] || {}; + + // Assume missing versions are 0 and wipe the server + if ((engineData.version || 0) < this.version) { + this._log.debug("Old engine data: " + [engineData.version, this.version]); + + // Clear the server and reupload everything on bad version or missing + // meta. Note that we don't regenerate per-collection keys here. + let newSyncID = await this.resetSyncID(); + + // Set the newer version and newly generated syncID + engineData.version = this.version; + engineData.syncID = newSyncID; + + // Put the new data back into meta/global and mark for upload + engines[this.name] = engineData; + metaGlobal.payload.engines = engines; + metaGlobal.changed = true; + } else if (engineData.version > this.version) { + // Don't sync this engine if the server has newer data + + let error = new Error("New data: " + [engineData.version, this.version]); + error.failureCode = VERSION_OUT_OF_DATE; + throw error; + } else { + // Changes to syncID mean we'll need to upload everything + let assignedSyncID = await this.ensureCurrentSyncID(engineData.syncID); + if (assignedSyncID != engineData.syncID) { + engineData.syncID = assignedSyncID; + metaGlobal.changed = true; + } + } + + // Save objects that need to be uploaded in this._modified. As we + // successfully upload objects we remove them from this._modified. If an + // error occurs or any objects fail to upload, they will remain in + // this._modified. At the end of a sync, or after an error, we add all + // objects remaining in this._modified to the tracker. + let initialChanges = await this.pullChanges(); + this._modified.replace(initialChanges); + // Clear the tracker now. If the sync fails we'll add the ones we failed + // to upload back. + this._tracker.clearChangedIDs(); + this._tracker.resetScore(); + + this._log.info( + this._modified.count() + " outgoing items pre-reconciliation" + ); + + // Keep track of what to delete at the end of sync + this._delete = {}; + }, + + async pullChanges() { + let lastSync = await this.getLastSync(); + if (lastSync) { + return this.pullNewChanges(); + } + this._log.debug("First sync, uploading all items"); + return this.pullAllChanges(); + }, + + /** + * A tiny abstraction to make it easier to test incoming record + * application. + */ + itemSource() { + return new Collection(this.engineURL, this._recordObj, this.service); + }, + + /** + * Download and apply remote records changed since the last sync. This + * happens in three stages. + * + * In the first stage, we fetch full records for all changed items, newest + * first, up to the download limit. The limit lets us make progress for large + * collections, where the sync is likely to be interrupted before we + * can fetch everything. + * + * In the second stage, we fetch the IDs of any remaining records changed + * since the last sync, add them to our backlog, and fast-forward our last + * sync time. + * + * In the third stage, we fetch and apply records for all backlogged IDs, + * as well as any records that failed to apply during the last sync. We + * request records for the IDs in chunks, to avoid exceeding URL length + * limits, then remove successfully applied records from the backlog, and + * record IDs of any records that failed to apply to retry on the next sync. + */ + async _processIncoming() { + this._log.trace("Downloading & applying server changes"); + + let newitems = this.itemSource(); + let lastSync = await this.getLastSync(); + + newitems.newer = lastSync; + newitems.full = true; + + let downloadLimit = Infinity; + if (this.downloadLimit) { + // Fetch new records up to the download limit. Currently, only the history + // engine sets a limit, since the history collection has the highest volume + // of changed records between syncs. The other engines fetch all records + // changed since the last sync. + if (this._defaultSort) { + // A download limit with a sort order doesn't make sense: we won't know + // which records to backfill. + throw new Error("Can't specify download limit with default sort order"); + } + newitems.sort = "newest"; + downloadLimit = newitems.limit = this.downloadLimit; + } else if (this._defaultSort) { + // The bookmarks engine fetches records by sort index; other engines leave + // the order unspecified. We can remove `_defaultSort` entirely after bug + // 1305563: the sort index won't matter because we'll buffer all bookmarks + // before applying. + newitems.sort = this._defaultSort; + } + + // applied => number of items that should be applied. + // failed => number of items that failed in this sync. + // newFailed => number of items that failed for the first time in this sync. + // reconciled => number of items that were reconciled. + let count = { applied: 0, failed: 0, newFailed: 0, reconciled: 0 }; + let recordsToApply = []; + let failedInCurrentSync = new SerializableSet(); + + let oldestModified = this.lastModified; + let downloadedIDs = new Set(); + + // Stage 1: Fetch new records from the server, up to the download limit. + if (this.lastModified == null || this.lastModified > lastSync) { + let { response, records } = await newitems.getBatched( + this.downloadBatchSize + ); + if (!response.success) { + response.failureCode = ENGINE_DOWNLOAD_FAIL; + throw response; + } + + await Async.yieldingForEach(records, async record => { + downloadedIDs.add(record.id); + + if (record.modified < oldestModified) { + oldestModified = record.modified; + } + + let { shouldApply, error } = await this._maybeReconcile(record); + if (error) { + failedInCurrentSync.add(record.id); + count.failed++; + return; + } + if (!shouldApply) { + count.reconciled++; + return; + } + recordsToApply.push(record); + }); + + let failedToApply = await this._applyRecords(recordsToApply); + Utils.setAddAll(failedInCurrentSync, failedToApply); + + // `applied` is a bit of a misnomer: it counts records that *should* be + // applied, so it also includes records that we tried to apply and failed. + // `recordsToApply.length - failedToApply.length` is the number of records + // that we *successfully* applied. + count.failed += failedToApply.length; + count.applied += recordsToApply.length; + } + + // Stage 2: If we reached our download limit, we might still have records + // on the server that changed since the last sync. Fetch the IDs for the + // remaining records, and add them to the backlog. Note that this stage + // only runs for engines that set a download limit. + if (downloadedIDs.size == downloadLimit) { + let guidColl = this.itemSource(); + + guidColl.newer = lastSync; + guidColl.older = oldestModified; + guidColl.sort = "oldest"; + + let guids = await guidColl.get(); + if (!guids.success) { + throw guids; + } + + // Filtering out already downloaded IDs here isn't necessary. We only do + // that in case the Sync server doesn't support `older` (bug 1316110). + let remainingIDs = guids.obj.filter(id => !downloadedIDs.has(id)); + if (remainingIDs.length > 0) { + this.toFetch = Utils.setAddAll(this.toFetch, remainingIDs); + } + } + + // Fast-foward the lastSync timestamp since we have backlogged the + // remaining items. + if (lastSync < this.lastModified) { + lastSync = this.lastModified; + await this.setLastSync(lastSync); + } + + // Stage 3: Backfill records from the backlog, and those that failed to + // decrypt or apply during the last sync. We only backfill up to the + // download limit, to prevent a large backlog for one engine from blocking + // the others. We'll keep processing the backlog on subsequent engine syncs. + let failedInPreviousSync = this.previousFailed; + let idsToBackfill = Array.from( + Utils.setAddAll( + Utils.subsetOfSize(this.toFetch, downloadLimit), + failedInPreviousSync + ) + ); + + // Note that we intentionally overwrite the previously failed list here. + // Records that fail to decrypt or apply in two consecutive syncs are likely + // corrupt; we remove them from the list because retrying and failing on + // every subsequent sync just adds noise. + this.previousFailed = failedInCurrentSync; + + let backfilledItems = this.itemSource(); + + backfilledItems.sort = "newest"; + backfilledItems.full = true; + + // `getBatched` includes the list of IDs as a query parameter, so we need to fetch + // records in chunks to avoid exceeding URI length limits. + if (this.guidFetchBatchSize) { + for (let ids of PlacesUtils.chunkArray( + idsToBackfill, + this.guidFetchBatchSize + )) { + backfilledItems.ids = ids; + + let { response, records } = await backfilledItems.getBatched( + this.downloadBatchSize + ); + if (!response.success) { + response.failureCode = ENGINE_DOWNLOAD_FAIL; + throw response; + } + + let backfilledRecordsToApply = []; + let failedInBackfill = []; + + await Async.yieldingForEach(records, async record => { + let { shouldApply, error } = await this._maybeReconcile(record); + if (error) { + failedInBackfill.push(record.id); + count.failed++; + return; + } + if (!shouldApply) { + count.reconciled++; + return; + } + backfilledRecordsToApply.push(record); + }); + + let failedToApply = await this._applyRecords(backfilledRecordsToApply); + failedInBackfill.push(...failedToApply); + + count.failed += failedToApply.length; + count.applied += backfilledRecordsToApply.length; + + this.toFetch = Utils.setDeleteAll(this.toFetch, ids); + this.previousFailed = Utils.setAddAll( + this.previousFailed, + failedInBackfill + ); + + if (lastSync < this.lastModified) { + lastSync = this.lastModified; + await this.setLastSync(lastSync); + } + } + } + + count.newFailed = 0; + for (let item of this.previousFailed) { + if (!failedInPreviousSync.has(item)) { + ++count.newFailed; + } + } + + count.succeeded = Math.max(0, count.applied - count.failed); + this._log.info( + [ + "Records:", + count.applied, + "applied,", + count.succeeded, + "successfully,", + count.failed, + "failed to apply,", + count.newFailed, + "newly failed to apply,", + count.reconciled, + "reconciled.", + ].join(" ") + ); + Observers.notify("weave:engine:sync:applied", count, this.name); + }, + + async _maybeReconcile(item) { + let key = this.service.collectionKeys.keyForCollection(this.name); + + // Grab a later last modified if possible + if (this.lastModified == null || item.modified > this.lastModified) { + this.lastModified = item.modified; + } + + try { + try { + await item.decrypt(key); + } catch (ex) { + if (!Utils.isHMACMismatch(ex)) { + throw ex; + } + let strategy = await this.handleHMACMismatch(item, true); + if (strategy == SyncEngine.kRecoveryStrategy.retry) { + // You only get one retry. + try { + // Try decrypting again, typically because we've got new keys. + this._log.info("Trying decrypt again..."); + key = this.service.collectionKeys.keyForCollection(this.name); + await item.decrypt(key); + strategy = null; + } catch (ex) { + if (!Utils.isHMACMismatch(ex)) { + throw ex; + } + strategy = await this.handleHMACMismatch(item, false); + } + } + + switch (strategy) { + case null: + // Retry succeeded! No further handling. + break; + case SyncEngine.kRecoveryStrategy.retry: + this._log.debug("Ignoring second retry suggestion."); + // Fall through to error case. + case SyncEngine.kRecoveryStrategy.error: + this._log.warn("Error decrypting record", ex); + return { shouldApply: false, error: ex }; + case SyncEngine.kRecoveryStrategy.ignore: + this._log.debug( + "Ignoring record " + item.id + " with bad HMAC: already handled." + ); + return { shouldApply: false, error: null }; + } + } + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.warn("Error decrypting record", ex); + return { shouldApply: false, error: ex }; + } + + if (this._shouldDeleteRemotely(item)) { + this._log.trace("Deleting item from server without applying", item); + await this._deleteId(item.id); + return { shouldApply: false, error: null }; + } + + let shouldApply; + try { + shouldApply = await this._reconcile(item); + } catch (ex) { + if (ex.code == SyncEngine.prototype.eEngineAbortApplyIncoming) { + this._log.warn("Reconciliation failed: aborting incoming processing."); + throw ex.cause; + } else if (!Async.isShutdownException(ex)) { + this._log.warn("Failed to reconcile incoming record " + item.id, ex); + return { shouldApply: false, error: ex }; + } else { + throw ex; + } + } + + if (!shouldApply) { + this._log.trace("Skipping reconciled incoming item " + item.id); + } + + return { shouldApply, error: null }; + }, + + async _applyRecords(records) { + this._tracker.ignoreAll = true; + try { + let failedIDs = await this._store.applyIncomingBatch(records); + return failedIDs; + } catch (ex) { + // Catch any error that escapes from applyIncomingBatch. At present + // those will all be abort events. + this._log.warn("Got exception, aborting processIncoming", ex); + throw ex; + } finally { + this._tracker.ignoreAll = false; + } + }, + + // Indicates whether an incoming item should be deleted from the server at + // the end of the sync. Engines can override this method to clean up records + // that shouldn't be on the server. + _shouldDeleteRemotely(remoteItem) { + return false; + }, + + /** + * Find a GUID of an item that is a duplicate of the incoming item but happens + * to have a different GUID + * + * @return GUID of the similar item; falsy otherwise + */ + async _findDupe(item) { + // By default, assume there's no dupe items for the engine + }, + + /** + * Called before a remote record is discarded due to failed reconciliation. + * Used by bookmark sync to merge folder child orders. + */ + beforeRecordDiscard(localRecord, remoteRecord, remoteIsNewer) {}, + + // Called when the server has a record marked as deleted, but locally we've + // changed it more recently than the deletion. If we return false, the + // record will be deleted locally. If we return true, we'll reupload the + // record to the server -- any extra work that's needed as part of this + // process should be done at this point (such as mark the record's parent + // for reuploading in the case of bookmarks). + async _shouldReviveRemotelyDeletedRecord(remoteItem) { + return true; + }, + + async _deleteId(id) { + await this._tracker.removeChangedID(id); + this._noteDeletedId(id); + }, + + // Marks an ID for deletion at the end of the sync. + _noteDeletedId(id) { + if (this._delete.ids == null) { + this._delete.ids = [id]; + } else { + this._delete.ids.push(id); + } + }, + + async _switchItemToDupe(localDupeGUID, incomingItem) { + // The local, duplicate ID is always deleted on the server. + await this._deleteId(localDupeGUID); + + // We unconditionally change the item's ID in case the engine knows of + // an item but doesn't expose it through itemExists. If the API + // contract were stronger, this could be changed. + this._log.debug( + "Switching local ID to incoming: " + + localDupeGUID + + " -> " + + incomingItem.id + ); + return this._store.changeItemID(localDupeGUID, incomingItem.id); + }, + + /** + * Reconcile incoming record with local state. + * + * This function essentially determines whether to apply an incoming record. + * + * @param item + * Record from server to be tested for application. + * @return boolean + * Truthy if incoming record should be applied. False if not. + */ + async _reconcile(item) { + if (this._log.level <= Log.Level.Trace) { + this._log.trace("Incoming: " + item); + } + + // We start reconciling by collecting a bunch of state. We do this here + // because some state may change during the course of this function and we + // need to operate on the original values. + let existsLocally = await this._store.itemExists(item.id); + let locallyModified = this._modified.has(item.id); + + // TODO Handle clock drift better. Tracked in bug 721181. + let remoteAge = Resource.serverTime - item.modified; + let localAge = locallyModified + ? Date.now() / 1000 - this._modified.getModifiedTimestamp(item.id) + : null; + let remoteIsNewer = remoteAge < localAge; + + this._log.trace( + "Reconciling " + + item.id + + ". exists=" + + existsLocally + + "; modified=" + + locallyModified + + "; local age=" + + localAge + + "; incoming age=" + + remoteAge + ); + + // We handle deletions first so subsequent logic doesn't have to check + // deleted flags. + if (item.deleted) { + // If the item doesn't exist locally, there is nothing for us to do. We + // can't check for duplicates because the incoming record has no data + // which can be used for duplicate detection. + if (!existsLocally) { + this._log.trace( + "Ignoring incoming item because it was deleted and " + + "the item does not exist locally." + ); + return false; + } + + // We decide whether to process the deletion by comparing the record + // ages. If the item is not modified locally, the remote side wins and + // the deletion is processed. If it is modified locally, we take the + // newer record. + if (!locallyModified) { + this._log.trace( + "Applying incoming delete because the local item " + + "exists and isn't modified." + ); + return true; + } + this._log.trace("Incoming record is deleted but we had local changes."); + + if (remoteIsNewer) { + this._log.trace("Remote record is newer -- deleting local record."); + return true; + } + // If the local record is newer, we defer to individual engines for + // how to handle this. By default, we revive the record. + let willRevive = await this._shouldReviveRemotelyDeletedRecord(item); + this._log.trace("Local record is newer -- reviving? " + willRevive); + + return !willRevive; + } + + // At this point the incoming record is not for a deletion and must have + // data. If the incoming record does not exist locally, we check for a local + // duplicate existing under a different ID. The default implementation of + // _findDupe() is empty, so engines have to opt in to this functionality. + // + // If we find a duplicate, we change the local ID to the incoming ID and we + // refresh the metadata collected above. See bug 710448 for the history + // of this logic. + if (!existsLocally) { + let localDupeGUID = await this._findDupe(item); + if (localDupeGUID) { + this._log.trace( + "Local item " + + localDupeGUID + + " is a duplicate for " + + "incoming item " + + item.id + ); + + // The current API contract does not mandate that the ID returned by + // _findDupe() actually exists. Therefore, we have to perform this + // check. + existsLocally = await this._store.itemExists(localDupeGUID); + + // If the local item was modified, we carry its metadata forward so + // appropriate reconciling can be performed. + if (this._modified.has(localDupeGUID)) { + locallyModified = true; + localAge = + this._tracker._now() - + this._modified.getModifiedTimestamp(localDupeGUID); + remoteIsNewer = remoteAge < localAge; + + this._modified.changeID(localDupeGUID, item.id); + } else { + locallyModified = false; + localAge = null; + } + + // Tell the engine to do whatever it needs to switch the items. + await this._switchItemToDupe(localDupeGUID, item); + + this._log.debug( + "Local item after duplication: age=" + + localAge + + "; modified=" + + locallyModified + + "; exists=" + + existsLocally + ); + } else { + this._log.trace("No duplicate found for incoming item: " + item.id); + } + } + + // At this point we've performed duplicate detection. But, nothing here + // should depend on duplicate detection as the above should have updated + // state seamlessly. + + if (!existsLocally) { + // If the item doesn't exist locally and we have no local modifications + // to the item (implying that it was not deleted), always apply the remote + // item. + if (!locallyModified) { + this._log.trace( + "Applying incoming because local item does not exist " + + "and was not deleted." + ); + return true; + } + + // If the item was modified locally but isn't present, it must have + // been deleted. If the incoming record is younger, we restore from + // that record. + if (remoteIsNewer) { + this._log.trace( + "Applying incoming because local item was deleted " + + "before the incoming item was changed." + ); + this._modified.delete(item.id); + return true; + } + + this._log.trace( + "Ignoring incoming item because the local item's " + + "deletion is newer." + ); + return false; + } + + // If the remote and local records are the same, there is nothing to be + // done, so we don't do anything. In the ideal world, this logic wouldn't + // be here and the engine would take a record and apply it. The reason we + // want to defer this logic is because it would avoid a redundant and + // possibly expensive dip into the storage layer to query item state. + // This should get addressed in the async rewrite, so we ignore it for now. + let localRecord = await this._createRecord(item.id); + let recordsEqual = Utils.deepEquals(item.cleartext, localRecord.cleartext); + + // If the records are the same, we don't need to do anything. This does + // potentially throw away a local modification time. But, if the records + // are the same, does it matter? + if (recordsEqual) { + this._log.trace( + "Ignoring incoming item because the local item is identical." + ); + + this._modified.delete(item.id); + return false; + } + + // At this point the records are different. + + // If we have no local modifications, always take the server record. + if (!locallyModified) { + this._log.trace("Applying incoming record because no local conflicts."); + return true; + } + + // At this point, records are different and the local record is modified. + // We resolve conflicts by record age, where the newest one wins. This does + // result in data loss and should be handled by giving the engine an + // opportunity to merge the records. Bug 720592 tracks this feature. + this._log.warn( + "DATA LOSS: Both local and remote changes to record: " + item.id + ); + if (!remoteIsNewer) { + this.beforeRecordDiscard(localRecord, item, remoteIsNewer); + } + return remoteIsNewer; + }, + + // Upload outgoing records. + async _uploadOutgoing() { + this._log.trace("Uploading local changes to server."); + + // collection we'll upload + let up = new Collection(this.engineURL, null, this.service); + let modifiedIDs = new Set(this._modified.ids()); + for (let id of this._needWeakUpload.keys()) { + modifiedIDs.add(id); + } + let counts = { failed: 0, sent: 0 }; + if (modifiedIDs.size) { + this._log.trace("Preparing " + modifiedIDs.size + " outgoing records"); + + counts.sent = modifiedIDs.size; + + let failed = []; + let successful = []; + let lastSync = await this.getLastSync(); + let handleResponse = async (postQueue, resp, batchOngoing) => { + // Note: We don't want to update this.lastSync, or this._modified until + // the batch is complete, however we want to remember success/failure + // indicators for when that happens. + if (!resp.success) { + this._log.debug(`Uploading records failed: ${resp.status}`); + resp.failureCode = + resp.status == 412 ? ENGINE_BATCH_INTERRUPTED : ENGINE_UPLOAD_FAIL; + throw resp; + } + + // Update server timestamp from the upload. + failed = failed.concat(Object.keys(resp.obj.failed)); + successful = successful.concat(resp.obj.success); + + if (batchOngoing) { + // Nothing to do yet + return; + } + + if (failed.length && this._log.level <= Log.Level.Debug) { + this._log.debug( + "Records that will be uploaded again because " + + "the server couldn't store them: " + + failed.join(", ") + ); + } + + counts.failed += failed.length; + + for (let id of successful) { + this._modified.delete(id); + } + + await this._onRecordsWritten( + successful, + failed, + postQueue.lastModified + ); + + // Advance lastSync since we've finished the batch. + if (postQueue.lastModified > lastSync) { + lastSync = postQueue.lastModified; + await this.setLastSync(lastSync); + } + + // clear for next batch + failed.length = 0; + successful.length = 0; + }; + + let postQueue = up.newPostQueue(this._log, lastSync, handleResponse); + + for (let id of modifiedIDs) { + let out; + let ok = false; + try { + let { forceTombstone = false } = this._needWeakUpload.get(id) || {}; + if (forceTombstone) { + out = await this._createTombstone(id); + } else { + out = await this._createRecord(id); + } + if (this._log.level <= Log.Level.Trace) { + this._log.trace("Outgoing: " + out); + } + await out.encrypt( + this.service.collectionKeys.keyForCollection(this.name) + ); + ok = true; + } catch (ex) { + this._log.warn("Error creating record", ex); + ++counts.failed; + if (Async.isShutdownException(ex) || !this.allowSkippedRecord) { + if (!this.allowSkippedRecord) { + // Don't bother for shutdown errors + Observers.notify("weave:engine:sync:uploaded", counts, this.name); + } + throw ex; + } + } + if (ok) { + let { enqueued, error } = await postQueue.enqueue(out); + if (!enqueued) { + ++counts.failed; + if (!this.allowSkippedRecord) { + Observers.notify("weave:engine:sync:uploaded", counts, this.name); + this._log.warn( + `Failed to enqueue record "${id}" (aborting)`, + error + ); + throw error; + } + this._modified.delete(id); + this._log.warn( + `Failed to enqueue record "${id}" (skipping)`, + error + ); + } + } + await Async.promiseYield(); + } + await postQueue.flush(true); + } + this._needWeakUpload.clear(); + + if (counts.sent || counts.failed) { + Observers.notify("weave:engine:sync:uploaded", counts, this.name); + } + }, + + async _onRecordsWritten(succeeded, failed, serverModifiedTime) { + // Implement this method to take specific actions against successfully + // uploaded records and failed records. + }, + + // Any cleanup necessary. + // Save the current snapshot so as to calculate changes at next sync + async _syncFinish() { + this._log.trace("Finishing up sync"); + + let doDelete = async (key, val) => { + let coll = new Collection(this.engineURL, this._recordObj, this.service); + coll[key] = val; + await coll.delete(); + }; + + for (let [key, val] of Object.entries(this._delete)) { + // Remove the key for future uses + delete this._delete[key]; + + this._log.trace("doing post-sync deletions", { key, val }); + // Send a simple delete for the property + if (key != "ids" || val.length <= 100) { + await doDelete(key, val); + } else { + // For many ids, split into chunks of at most 100 + while (val.length > 0) { + await doDelete(key, val.slice(0, 100)); + val = val.slice(100); + } + } + } + this.hasSyncedThisSession = true; + await this._tracker.asyncObserver.promiseObserversComplete(); + }, + + async _syncCleanup() { + this._needWeakUpload.clear(); + try { + // Mark failed WBOs as changed again so they are reuploaded next time. + await this.trackRemainingChanges(); + } finally { + this._modified.clear(); + } + }, + + async _sync() { + try { + Async.checkAppReady(); + await this._syncStartup(); + Async.checkAppReady(); + Observers.notify("weave:engine:sync:status", "process-incoming"); + await this._processIncoming(); + Async.checkAppReady(); + Observers.notify("weave:engine:sync:status", "upload-outgoing"); + try { + await this._uploadOutgoing(); + Async.checkAppReady(); + await this._syncFinish(); + } catch (ex) { + if (!ex.status || ex.status != 412) { + throw ex; + } + // a 412 posting just means another client raced - but we don't want + // to treat that as a sync error - the next sync is almost certain + // to work. + this._log.warn("412 error during sync - will retry."); + } + } finally { + await this._syncCleanup(); + } + }, + + async canDecrypt() { + // Report failure even if there's nothing to decrypt + let canDecrypt = false; + + // Fetch the most recently uploaded record and try to decrypt it + let test = new Collection(this.engineURL, this._recordObj, this.service); + test.limit = 1; + test.sort = "newest"; + test.full = true; + + let key = this.service.collectionKeys.keyForCollection(this.name); + + // Any failure fetching/decrypting will just result in false + try { + this._log.trace("Trying to decrypt a record from the server.."); + let json = (await test.get()).obj[0]; + let record = new this._recordObj(); + record.deserialize(json); + await record.decrypt(key); + canDecrypt = true; + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.debug("Failed test decrypt", ex); + } + + return canDecrypt; + }, + + /** + * Deletes the collection for this engine on the server, and removes all local + * Sync metadata for this engine. This does *not* remove any existing data on + * other clients. This is called when we reset the sync ID. + */ + async wipeServer() { + await this._deleteServerCollection(); + await this._resetClient(); + }, + + /** + * Deletes the collection for this engine on the server, without removing + * any local Sync metadata or user data. Deleting the collection will not + * remove any user data on other clients, but will force other clients to + * start over as a first sync. + */ + async _deleteServerCollection() { + let response = await this.service.resource(this.engineURL).delete(); + if (response.status != 200 && response.status != 404) { + throw response; + } + }, + + async removeClientData() { + // Implement this method in engines that store client specific data + // on the server. + }, + + /* + * Decide on (and partially effect) an error-handling strategy. + * + * Asks the Service to respond to an HMAC error, which might result in keys + * being downloaded. That call returns true if an action which might allow a + * retry to occur. + * + * If `mayRetry` is truthy, and the Service suggests a retry, + * handleHMACMismatch returns kRecoveryStrategy.retry. Otherwise, it returns + * kRecoveryStrategy.error. + * + * Subclasses of SyncEngine can override this method to allow for different + * behavior -- e.g., to delete and ignore erroneous entries. + * + * All return values will be part of the kRecoveryStrategy enumeration. + */ + async handleHMACMismatch(item, mayRetry) { + // By default we either try again, or bail out noisily. + return (await this.service.handleHMACEvent()) && mayRetry + ? SyncEngine.kRecoveryStrategy.retry + : SyncEngine.kRecoveryStrategy.error; + }, + + /** + * Returns a changeset containing all items in the store. The default + * implementation returns a changeset with timestamps from long ago, to + * ensure we always use the remote version if one exists. + * + * This function is only called for the first sync. Subsequent syncs call + * `pullNewChanges`. + * + * @return A `Changeset` object. + */ + async pullAllChanges() { + let changes = {}; + let ids = await this._store.getAllIDs(); + for (let id in ids) { + changes[id] = 0; + } + return changes; + }, + + /* + * Returns a changeset containing entries for all currently tracked items. + * The default implementation returns a changeset with timestamps indicating + * when the item was added to the tracker. + * + * @return A `Changeset` object. + */ + async pullNewChanges() { + await this._tracker.asyncObserver.promiseObserversComplete(); + return this.getChangedIDs(); + }, + + /** + * Adds all remaining changeset entries back to the tracker, typically for + * items that failed to upload. This method is called at the end of each sync. + * + */ + async trackRemainingChanges() { + for (let [id, change] of this._modified.entries()) { + await this._tracker.addChangedID(id, change); + } + }, + + /** + * Removes all local Sync metadata for this engine, but keeps all existing + * local user data. + */ + async resetClient() { + return this._notify("reset-client", this.name, this._resetClient)(); + }, + + async _resetClient() { + await this.resetLastSync(); + this.hasSyncedThisSession = false; + this.previousFailed = new SerializableSet(); + this.toFetch = new SerializableSet(); + this._needWeakUpload.clear(); + }, + + /** + * Removes all local Sync metadata and user data for this engine. + */ + async wipeClient() { + return this._notify("wipe-client", this.name, this._wipeClient)(); + }, + + async _wipeClient() { + await this.resetClient(); + this._log.debug("Deleting all local data"); + this._tracker.ignoreAll = true; + await this._store.wipe(); + this._tracker.ignoreAll = false; + this._tracker.clearChangedIDs(); + }, + + /** + * If one exists, initialize and return a validator for this engine (which + * must have a `validate(engine)` method that returns a promise to an object + * with a getSummary method). Otherwise return null. + */ + getValidator() { + return null; + }, + + async finalize() { + Svc.Prefs.ignore(`engine.${this.prefName}`, this.asyncObserver); + await this.asyncObserver.promiseObserversComplete(); + await this._tracker.finalize(); + await this._toFetchStorage.finalize(); + await this._previousFailedStorage.finalize(); + }, + + // Returns a new watchdog. Exposed for tests. + _newWatchdog() { + return Async.watchdog(); + }, +}; + +/** + * A changeset is created for each sync in `Engine::get{Changed, All}IDs`, + * and stores opaque change data for tracked IDs. The default implementation + * only records timestamps, though engines can extend this to store additional + * data for each entry. + */ +class Changeset { + // Creates an empty changeset. + constructor() { + this.changes = {}; + } + + // Returns the last modified time, in seconds, for an entry in the changeset. + // `id` is guaranteed to be in the set. + getModifiedTimestamp(id) { + return this.changes[id]; + } + + // Adds a change for a tracked ID to the changeset. + set(id, change) { + this.changes[id] = change; + } + + // Adds multiple entries to the changeset, preserving existing entries. + insert(changes) { + Object.assign(this.changes, changes); + } + + // Overwrites the existing set of tracked changes with new entries. + replace(changes) { + this.changes = changes; + } + + // Indicates whether an entry is in the changeset. + has(id) { + return id in this.changes; + } + + // Deletes an entry from the changeset. Used to clean up entries for + // reconciled and successfully uploaded records. + delete(id) { + delete this.changes[id]; + } + + // Changes the ID of an entry in the changeset. Used when reconciling + // duplicates that have local changes. + changeID(oldID, newID) { + this.changes[newID] = this.changes[oldID]; + delete this.changes[oldID]; + } + + // Returns an array of all tracked IDs in this changeset. + ids() { + return Object.keys(this.changes); + } + + // Returns an array of `[id, change]` tuples. Used to repopulate the tracker + // with entries for failed uploads at the end of a sync. + entries() { + return Object.entries(this.changes); + } + + // Returns the number of entries in this changeset. + count() { + return this.ids().length; + } + + // Clears the changeset. + clear() { + this.changes = {}; + } +} diff --git a/services/sync/modules/engines/addons.js b/services/sync/modules/engines/addons.js new file mode 100644 index 0000000000..d3c79967b2 --- /dev/null +++ b/services/sync/modules/engines/addons.js @@ -0,0 +1,833 @@ +/* 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/. */ + +/* + * This file defines the add-on sync functionality. + * + * There are currently a number of known limitations: + * - We only sync XPI extensions and themes available from addons.mozilla.org. + * We hope to expand support for other add-ons eventually. + * - We only attempt syncing of add-ons between applications of the same type. + * This means add-ons will not synchronize between Firefox desktop and + * Firefox mobile, for example. This is because of significant add-on + * incompatibility between application types. + * + * Add-on records exist for each known {add-on, app-id} pair in the Sync client + * set. Each record has a randomly chosen GUID. The records then contain + * basic metadata about the add-on. + * + * We currently synchronize: + * + * - Installations + * - Uninstallations + * - User enabling and disabling + * + * Synchronization is influenced by the following preferences: + * + * - services.sync.addons.ignoreUserEnabledChanges + * - services.sync.addons.trustedSourceHostnames + * + * and also influenced by whether addons have repository caching enabled and + * whether they allow installation of addons from insecure options (both of + * which are themselves influenced by the "extensions." pref branch) + * + * See the documentation in all.js for the behavior of these prefs. + */ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +const { AddonUtils } = ChromeUtils.import( + "resource://services-sync/addonutils.js" +); +const { AddonsReconciler } = ChromeUtils.import( + "resource://services-sync/addonsreconciler.js" +); +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); +const { CollectionValidator } = ChromeUtils.import( + "resource://services-sync/collection_validator.js" +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "AddonRepository", + "resource://gre/modules/addons/AddonRepository.jsm" +); + +var EXPORTED_SYMBOLS = ["AddonsEngine", "AddonValidator"]; + +// 7 days in milliseconds. +const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000; + +/** + * AddonRecord represents the state of an add-on in an application. + * + * Each add-on has its own record for each application ID it is installed + * on. + * + * The ID of add-on records is a randomly-generated GUID. It is random instead + * of deterministic so the URIs of the records cannot be guessed and so + * compromised server credentials won't result in disclosure of the specific + * add-ons present in a Sync account. + * + * The record contains the following fields: + * + * addonID + * ID of the add-on. This correlates to the "id" property on an Addon type. + * + * applicationID + * The application ID this record is associated with. + * + * enabled + * Boolean stating whether add-on is enabled or disabled by the user. + * + * source + * String indicating where an add-on is from. Currently, we only support + * the value "amo" which indicates that the add-on came from the official + * add-ons repository, addons.mozilla.org. In the future, we may support + * installing add-ons from other sources. This provides a future-compatible + * mechanism for clients to only apply records they know how to handle. + */ +function AddonRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} +AddonRecord.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Record.Addon", +}; + +Utils.deferGetSet(AddonRecord, "cleartext", [ + "addonID", + "applicationID", + "enabled", + "source", +]); + +/** + * The AddonsEngine handles synchronization of add-ons between clients. + * + * The engine maintains an instance of an AddonsReconciler, which is the entity + * maintaining state for add-ons. It provides the history and tracking APIs + * that AddonManager doesn't. + * + * The engine instance overrides a handful of functions on the base class. The + * rationale for each is documented by that function. + */ +function AddonsEngine(service) { + SyncEngine.call(this, "Addons", service); + + this._reconciler = new AddonsReconciler(this._tracker.asyncObserver); +} +AddonsEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: AddonsStore, + _trackerObj: AddonsTracker, + _recordObj: AddonRecord, + version: 1, + + syncPriority: 5, + + _reconciler: null, + + async initialize() { + await SyncEngine.prototype.initialize.call(this); + await this._reconciler.ensureStateLoaded(); + }, + + /** + * Override parent method to find add-ons by their public ID, not Sync GUID. + */ + async _findDupe(item) { + let id = item.addonID; + + // The reconciler should have been updated at the top of the sync, so we + // can assume it is up to date when this function is called. + let addons = this._reconciler.addons; + if (!(id in addons)) { + return null; + } + + let addon = addons[id]; + if (addon.guid != item.id) { + return addon.guid; + } + + return null; + }, + + /** + * Override getChangedIDs to pull in tracker changes plus changes from the + * reconciler log. + */ + async getChangedIDs() { + let changes = {}; + const changedIDs = await this._tracker.getChangedIDs(); + for (let [id, modified] of Object.entries(changedIDs)) { + changes[id] = modified; + } + + let lastSync = await this.getLastSync(); + let lastSyncDate = new Date(lastSync * 1000); + + // The reconciler should have been refreshed at the beginning of a sync and + // we assume this function is only called from within a sync. + let reconcilerChanges = this._reconciler.getChangesSinceDate(lastSyncDate); + let addons = this._reconciler.addons; + for (let change of reconcilerChanges) { + let changeTime = change[0]; + let id = change[2]; + + if (!(id in addons)) { + continue; + } + + // Keep newest modified time. + if (id in changes && changeTime < changes[id]) { + continue; + } + + if (!(await this.isAddonSyncable(addons[id]))) { + continue; + } + + this._log.debug("Adding changed add-on from changes log: " + id); + let addon = addons[id]; + changes[addon.guid] = changeTime.getTime() / 1000; + } + + return changes; + }, + + /** + * Override start of sync function to refresh reconciler. + * + * Many functions in this class assume the reconciler is refreshed at the + * top of a sync. If this ever changes, those functions should be revisited. + * + * Technically speaking, we don't need to refresh the reconciler on every + * sync since it is installed as an AddonManager listener. However, add-ons + * are complicated and we force a full refresh, just in case the listeners + * missed something. + */ + async _syncStartup() { + // We refresh state before calling parent because syncStartup in the parent + // looks for changed IDs, which is dependent on add-on state being up to + // date. + await this._refreshReconcilerState(); + return SyncEngine.prototype._syncStartup.call(this); + }, + + /** + * Override end of sync to perform a little housekeeping on the reconciler. + * + * We prune changes to prevent the reconciler state from growing without + * bound. Even if it grows unbounded, there would have to be many add-on + * changes (thousands) for it to slow things down significantly. This is + * highly unlikely to occur. Still, we exercise defense just in case. + */ + async _syncCleanup() { + let lastSync = await this.getLastSync(); + let ms = 1000 * lastSync - PRUNE_ADDON_CHANGES_THRESHOLD; + this._reconciler.pruneChangesBeforeDate(new Date(ms)); + return SyncEngine.prototype._syncCleanup.call(this); + }, + + /** + * Helper function to ensure reconciler is up to date. + * + * This will load the reconciler's state from the file + * system (if needed) and refresh the state of the reconciler. + */ + async _refreshReconcilerState() { + this._log.debug("Refreshing reconciler state"); + return this._reconciler.refreshGlobalState(); + }, + + // Returns a promise + isAddonSyncable(addon, ignoreRepoCheck) { + return this._store.isAddonSyncable(addon, ignoreRepoCheck); + }, +}; + +/** + * This is the primary interface between Sync and the Addons Manager. + * + * In addition to the core store APIs, we provide convenience functions to wrap + * Add-on Manager APIs with Sync-specific semantics. + */ +function AddonsStore(name, engine) { + Store.call(this, name, engine); +} +AddonsStore.prototype = { + __proto__: Store.prototype, + + // Define the add-on types (.type) that we support. + _syncableTypes: ["extension", "theme"], + + _extensionsPrefs: new Preferences("extensions."), + + get reconciler() { + return this.engine._reconciler; + }, + + /** + * Override applyIncoming to filter out records we can't handle. + */ + async applyIncoming(record) { + // The fields we look at aren't present when the record is deleted. + if (!record.deleted) { + // Ignore records not belonging to our application ID because that is the + // current policy. + if (record.applicationID != Services.appinfo.ID) { + this._log.info( + "Ignoring incoming record from other App ID: " + record.id + ); + return; + } + + // Ignore records that aren't from the official add-on repository, as that + // is our current policy. + if (record.source != "amo") { + this._log.info( + "Ignoring unknown add-on source (" + + record.source + + ")" + + " for " + + record.id + ); + return; + } + } + + // Ignore incoming records for which an existing non-syncable addon + // exists. Note that we do not insist that the addon manager already have + // metadata for this addon - it's possible our reconciler previously saw the + // addon but the addon-manager cache no longer has it - which is fine for a + // new incoming addon. + // (Note that most other cases where the addon-manager cache is invalid + // doesn't get this treatment because that cache self-repairs after some + // time - but it only re-populates addons which are currently installed.) + let existingMeta = this.reconciler.addons[record.addonID]; + if ( + existingMeta && + !(await this.isAddonSyncable(existingMeta, /* ignoreRepoCheck */ true)) + ) { + this._log.info( + "Ignoring incoming record for an existing but non-syncable addon", + record.addonID + ); + return; + } + + await Store.prototype.applyIncoming.call(this, record); + }, + + /** + * Provides core Store API to create/install an add-on from a record. + */ + async create(record) { + // This will throw if there was an error. This will get caught by the sync + // engine and the record will try to be applied later. + const results = await AddonUtils.installAddons([ + { + id: record.addonID, + syncGUID: record.id, + enabled: record.enabled, + requireSecureURI: this._extensionsPrefs.get( + "install.requireSecureOrigin", + true + ), + }, + ]); + + if (results.skipped.includes(record.addonID)) { + this._log.info("Add-on skipped: " + record.addonID); + // Just early-return for skipped addons - we don't want to arrange to + // try again next time because the condition that caused up to skip + // will remain true for this addon forever. + return; + } + + let addon; + for (let a of results.addons) { + if (a.id == record.addonID) { + addon = a; + break; + } + } + + // This should never happen, but is present as a fail-safe. + if (!addon) { + throw new Error("Add-on not found after install: " + record.addonID); + } + + this._log.info("Add-on installed: " + record.addonID); + }, + + /** + * Provides core Store API to remove/uninstall an add-on from a record. + */ + async remove(record) { + // If this is called, the payload is empty, so we have to find by GUID. + let addon = await this.getAddonByGUID(record.id); + if (!addon) { + // We don't throw because if the add-on could not be found then we assume + // it has already been uninstalled and there is nothing for this function + // to do. + return; + } + + this._log.info("Uninstalling add-on: " + addon.id); + await AddonUtils.uninstallAddon(addon); + }, + + /** + * Provides core Store API to update an add-on from a record. + */ + async update(record) { + let addon = await this.getAddonByID(record.addonID); + + // update() is called if !this.itemExists. And, since itemExists consults + // the reconciler only, we need to take care of some corner cases. + // + // First, the reconciler could know about an add-on that was uninstalled + // and no longer present in the add-ons manager. + if (!addon) { + await this.create(record); + return; + } + + // It's also possible that the add-on is non-restartless and has pending + // install/uninstall activity. + // + // We wouldn't get here if the incoming record was for a deletion. So, + // check for pending uninstall and cancel if necessary. + if (addon.pendingOperations & AddonManager.PENDING_UNINSTALL) { + addon.cancelUninstall(); + + // We continue with processing because there could be state or ID change. + } + + await this.updateUserDisabled(addon, !record.enabled); + }, + + /** + * Provide core Store API to determine if a record exists. + */ + async itemExists(guid) { + let addon = this.reconciler.getAddonStateFromSyncGUID(guid); + + return !!addon; + }, + + /** + * Create an add-on record from its GUID. + * + * @param guid + * Add-on GUID (from extensions DB) + * @param collection + * Collection to add record to. + * + * @return AddonRecord instance + */ + async createRecord(guid, collection) { + let record = new AddonRecord(collection, guid); + record.applicationID = Services.appinfo.ID; + + let addon = this.reconciler.getAddonStateFromSyncGUID(guid); + + // If we don't know about this GUID or if it has been uninstalled, we mark + // the record as deleted. + if (!addon || !addon.installed) { + record.deleted = true; + return record; + } + + record.modified = addon.modified.getTime() / 1000; + + record.addonID = addon.id; + record.enabled = addon.enabled; + + // This needs to be dynamic when add-ons don't come from AddonRepository. + record.source = "amo"; + + return record; + }, + + /** + * Changes the id of an add-on. + * + * This implements a core API of the store. + */ + async changeItemID(oldID, newID) { + // We always update the GUID in the reconciler because it will be + // referenced later in the sync process. + let state = this.reconciler.getAddonStateFromSyncGUID(oldID); + if (state) { + state.guid = newID; + await this.reconciler.saveState(); + } + + let addon = await this.getAddonByGUID(oldID); + if (!addon) { + this._log.debug( + "Cannot change item ID (" + + oldID + + ") in Add-on " + + "Manager because old add-on not present: " + + oldID + ); + return; + } + + addon.syncGUID = newID; + }, + + /** + * Obtain the set of all syncable add-on Sync GUIDs. + * + * This implements a core Store API. + */ + async getAllIDs() { + let ids = {}; + + let addons = this.reconciler.addons; + for (let id in addons) { + let addon = addons[id]; + if (await this.isAddonSyncable(addon)) { + ids[addon.guid] = true; + } + } + + return ids; + }, + + /** + * Wipe engine data. + * + * This uninstalls all syncable addons from the application. In case of + * error, it logs the error and keeps trying with other add-ons. + */ + async wipe() { + this._log.info("Processing wipe."); + + await this.engine._refreshReconcilerState(); + + // We only wipe syncable add-ons. Wipe is a Sync feature not a security + // feature. + let ids = await this.getAllIDs(); + for (let guid in ids) { + let addon = await this.getAddonByGUID(guid); + if (!addon) { + this._log.debug( + "Ignoring add-on because it couldn't be obtained: " + guid + ); + continue; + } + + this._log.info("Uninstalling add-on as part of wipe: " + addon.id); + await Utils.catch.call(this, () => addon.uninstall())(); + } + }, + + /** ************************************************************************* + * Functions below are unique to this store and not part of the Store API * + ***************************************************************************/ + + /** + * Obtain an add-on from its public ID. + * + * @param id + * Add-on ID + * @return Addon or undefined if not found + */ + async getAddonByID(id) { + return AddonManager.getAddonByID(id); + }, + + /** + * Obtain an add-on from its Sync GUID. + * + * @param guid + * Add-on Sync GUID + * @return DBAddonInternal or null + */ + async getAddonByGUID(guid) { + return AddonManager.getAddonBySyncGUID(guid); + }, + + /** + * Determines whether an add-on is suitable for Sync. + * + * @param addon + * Addon instance + * @param ignoreRepoCheck + * Should we skip checking the Addons repository (primarially useful + * for testing and validation). + * @return Boolean indicating whether it is appropriate for Sync + */ + async isAddonSyncable(addon, ignoreRepoCheck = false) { + // Currently, we limit syncable add-ons to those that are: + // 1) In a well-defined set of types + // 2) Installed in the current profile + // 3) Not installed by a foreign entity (i.e. installed by the app) + // since they act like global extensions. + // 4) Is not a hotfix. + // 5) The addons XPIProvider doesn't veto it (i.e not being installed in + // the profile directory, or any other reasons it says the addon can't + // be synced) + // 6) Are installed from AMO + + // We could represent the test as a complex boolean expression. We go the + // verbose route so the failure reason is logged. + if (!addon) { + this._log.debug("Null object passed to isAddonSyncable."); + return false; + } + + if (!this._syncableTypes.includes(addon.type)) { + this._log.debug( + addon.id + " not syncable: type not in whitelist: " + addon.type + ); + return false; + } + + if (!(addon.scope & AddonManager.SCOPE_PROFILE)) { + this._log.debug(addon.id + " not syncable: not installed in profile."); + return false; + } + + // If the addon manager says it's not syncable, we skip it. + if (!addon.isSyncable) { + this._log.debug(addon.id + " not syncable: vetoed by the addon manager."); + return false; + } + + // This may be too aggressive. If an add-on is downloaded from AMO and + // manually placed in the profile directory, foreignInstall will be set. + // Arguably, that add-on should be syncable. + // TODO Address the edge case and come up with more robust heuristics. + if (addon.foreignInstall) { + this._log.debug(addon.id + " not syncable: is foreign install."); + return false; + } + + // If the AddonRepository's cache isn't enabled (which it typically isn't + // in tests), getCachedAddonByID always returns null - so skip the check + // in that case. We also provide a way to specifically opt-out of the check + // even if the cache is enabled, which is used by the validators. + if (ignoreRepoCheck || !AddonRepository.cacheEnabled) { + return true; + } + + let result = await new Promise(res => { + AddonRepository.getCachedAddonByID(addon.id, res); + }); + + if (!result) { + this._log.debug( + addon.id + " not syncable: add-on not found in add-on repository." + ); + return false; + } + + return this.isSourceURITrusted(result.sourceURI); + }, + + /** + * Determine whether an add-on's sourceURI field is trusted and the add-on + * can be installed. + * + * This function should only ever be called from isAddonSyncable(). It is + * exposed as a separate function to make testing easier. + * + * @param uri + * nsIURI instance to validate + * @return bool + */ + isSourceURITrusted: function isSourceURITrusted(uri) { + // For security reasons, we currently limit synced add-ons to those + // installed from trusted hostname(s). We additionally require TLS with + // the add-ons site to help prevent forgeries. + let trustedHostnames = Svc.Prefs.get( + "addons.trustedSourceHostnames", + "" + ).split(","); + + if (!uri) { + this._log.debug("Undefined argument to isSourceURITrusted()."); + return false; + } + + // Scheme is validated before the hostname because uri.host may not be + // populated for certain schemes. It appears to always be populated for + // https, so we avoid the potential NS_ERROR_FAILURE on field access. + if (uri.scheme != "https") { + this._log.debug("Source URI not HTTPS: " + uri.spec); + return false; + } + + if (!trustedHostnames.includes(uri.host)) { + this._log.debug("Source hostname not trusted: " + uri.host); + return false; + } + + return true; + }, + + /** + * Update the userDisabled flag on an add-on. + * + * This will enable or disable an add-on. It has no return value and does + * not catch or handle exceptions thrown by the addon manager. If no action + * is needed it will return immediately. + * + * @param addon + * Addon instance to manipulate. + * @param value + * Boolean to which to set userDisabled on the passed Addon. + */ + async updateUserDisabled(addon, value) { + if (addon.userDisabled == value) { + return; + } + + // A pref allows changes to the enabled flag to be ignored. + if (Svc.Prefs.get("addons.ignoreUserEnabledChanges", false)) { + this._log.info( + "Ignoring enabled state change due to preference: " + addon.id + ); + return; + } + + AddonUtils.updateUserDisabled(addon, value); + // updating this flag doesn't send a notification for appDisabled addons, + // meaning the reconciler will not update its state and may resync the + // addon - so explicitly rectify the state (bug 1366994) + if (addon.appDisabled) { + await this.reconciler.rectifyStateFromAddon(addon); + } + }, +}; + +/** + * The add-ons tracker keeps track of real-time changes to add-ons. + * + * It hooks up to the reconciler and receives notifications directly from it. + */ +function AddonsTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +AddonsTracker.prototype = { + __proto__: LegacyTracker.prototype, + + get reconciler() { + return this.engine._reconciler; + }, + + get store() { + return this.engine._store; + }, + + /** + * This callback is executed whenever the AddonsReconciler sends out a change + * notification. See AddonsReconciler.addChangeListener(). + */ + async changeListener(date, change, addon) { + this._log.debug("changeListener invoked: " + change + " " + addon.id); + // Ignore changes that occur during sync. + if (this.ignoreAll) { + return; + } + + if (!(await this.store.isAddonSyncable(addon))) { + this._log.debug( + "Ignoring change because add-on isn't syncable: " + addon.id + ); + return; + } + + const added = await this.addChangedID(addon.guid, date.getTime() / 1000); + if (added) { + this.score += SCORE_INCREMENT_XLARGE; + } + }, + + onStart() { + this.reconciler.startListening(); + this.reconciler.addChangeListener(this); + }, + + onStop() { + this.reconciler.removeChangeListener(this); + this.reconciler.stopListening(); + }, +}; + +class AddonValidator extends CollectionValidator { + constructor(engine = null) { + super("addons", "id", ["addonID", "enabled", "applicationID", "source"]); + this.engine = engine; + } + + async getClientItems() { + return AddonManager.getAllAddons(); + } + + normalizeClientItem(item) { + let enabled = !item.userDisabled; + if (item.pendingOperations & AddonManager.PENDING_ENABLE) { + enabled = true; + } else if (item.pendingOperations & AddonManager.PENDING_DISABLE) { + enabled = false; + } + return { + enabled, + id: item.syncGUID, + addonID: item.id, + applicationID: Services.appinfo.ID, + source: "amo", // check item.foreignInstall? + original: item, + }; + } + + async normalizeServerItem(item) { + let guid = await this.engine._findDupe(item); + if (guid) { + item.id = guid; + } + return item; + } + + clientUnderstands(item) { + return item.applicationID === Services.appinfo.ID; + } + + async syncedByClient(item) { + return ( + !item.original.hidden && + !item.original.isSystem && + !(item.original.pendingOperations & AddonManager.PENDING_UNINSTALL) && + // No need to await the returned promise explicitely: + // |expr1 && expr2| evaluates to expr2 if expr1 is true. + this.engine.isAddonSyncable(item.original, true) + ); + } +} diff --git a/services/sync/modules/engines/bookmarks.js b/services/sync/modules/engines/bookmarks.js new file mode 100644 index 0000000000..2c28c9a7a0 --- /dev/null +++ b/services/sync/modules/engines/bookmarks.js @@ -0,0 +1,1000 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = [ + "BookmarksEngine", + "PlacesItem", + "Bookmark", + "BookmarkFolder", + "BookmarkQuery", + "Livemark", + "BookmarkSeparator", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); +const { Changeset, Store, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +XPCOMUtils.defineLazyModuleGetters(this, { + BookmarkValidator: "resource://services-sync/bookmark_validator.js", + Observers: "resource://services-common/observers.js", + OS: "resource://gre/modules/osfile.jsm", + PlacesBackups: "resource://gre/modules/PlacesBackups.jsm", + PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.jsm", + PlacesSyncUtils: "resource://gre/modules/PlacesSyncUtils.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", + Resource: "resource://services-sync/resource.js", + SyncedBookmarksMirror: "resource://gre/modules/SyncedBookmarksMirror.jsm", +}); + +XPCOMUtils.defineLazyGetter(this, "PlacesBundle", () => { + return Services.strings.createBundle( + "chrome://places/locale/places.properties" + ); +}); + +XPCOMUtils.defineLazyGetter(this, "ANNOS_TO_TRACK", () => [ + PlacesUtils.LMANNO_FEEDURI, + PlacesUtils.LMANNO_SITEURI, +]); + +const PLACES_MAINTENANCE_INTERVAL_SECONDS = 4 * 60 * 60; // 4 hours. + +const FOLDER_SORTINDEX = 1000000; + +// Roots that should be deleted from the server, instead of applied locally. +// This matches `AndroidBrowserBookmarksRepositorySession::forbiddenGUID`, +// but allows tags because we don't want to reparent tag folders or tag items +// to "unfiled". +const FORBIDDEN_INCOMING_IDS = ["pinned", "places", "readinglist"]; + +// Items with these parents should be deleted from the server. We allow +// children of the Places root, to avoid orphaning left pane queries and other +// descendants of custom roots. +const FORBIDDEN_INCOMING_PARENT_IDS = ["pinned", "readinglist"]; + +// The tracker ignores changes made by import and restore, to avoid bumping the +// score and triggering syncs during the process, as well as changes made by +// Sync. +XPCOMUtils.defineLazyGetter(this, "IGNORED_SOURCES", () => [ + PlacesUtils.bookmarks.SOURCES.SYNC, + PlacesUtils.bookmarks.SOURCES.IMPORT, + PlacesUtils.bookmarks.SOURCES.RESTORE, + PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + PlacesUtils.bookmarks.SOURCES.SYNC_REPARENT_REMOVED_FOLDER_CHILDREN, +]); + +// The validation telemetry version for the engine. Version 1 is collected +// by `bookmark_validator.js`, and checks value as well as structure +// differences. Version 2 is collected by the engine as part of building the +// remote tree, and checks structure differences only. +const BOOKMARK_VALIDATOR_VERSION = 2; + +// The maximum time that the engine should wait before aborting a bookmark +// merge. +const BOOKMARK_APPLY_TIMEOUT_MS = 5 * 60 * 60 * 1000; // 5 minutes + +// The default frecency value to use when not known. +const FRECENCY_UNKNOWN = -1; + +// Returns the constructor for a bookmark record type. +function getTypeObject(type) { + switch (type) { + case "bookmark": + return Bookmark; + case "query": + return BookmarkQuery; + case "folder": + return BookmarkFolder; + case "livemark": + return Livemark; + case "separator": + return BookmarkSeparator; + case "item": + return PlacesItem; + } + return null; +} + +function PlacesItem(collection, id, type) { + CryptoWrapper.call(this, collection, id); + this.type = type || "item"; +} +PlacesItem.prototype = { + async decrypt(keyBundle) { + // Do the normal CryptoWrapper decrypt, but change types before returning + let clear = await CryptoWrapper.prototype.decrypt.call(this, keyBundle); + + // Convert the abstract places item to the actual object type + if (!this.deleted) { + this.__proto__ = this.getTypeObject(this.type).prototype; + } + + return clear; + }, + + getTypeObject: function PlacesItem_getTypeObject(type) { + let recordObj = getTypeObject(type); + if (!recordObj) { + throw new Error("Unknown places item object type: " + type); + } + return recordObj; + }, + + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.PlacesItem", + + // Converts the record to a Sync bookmark object that can be passed to + // `PlacesSyncUtils.bookmarks.{insert, update}`. + toSyncBookmark() { + let result = { + kind: this.type, + recordId: this.id, + parentRecordId: this.parentid, + }; + let dateAdded = PlacesSyncUtils.bookmarks.ratchetTimestampBackwards( + this.dateAdded, + +this.modified * 1000 + ); + if (dateAdded > 0) { + result.dateAdded = dateAdded; + } + return result; + }, + + // Populates the record from a Sync bookmark object returned from + // `PlacesSyncUtils.bookmarks.fetch`. + fromSyncBookmark(item) { + this.parentid = item.parentRecordId; + this.parentName = item.parentTitle; + if (item.dateAdded) { + this.dateAdded = item.dateAdded; + } + }, +}; + +Utils.deferGetSet(PlacesItem, "cleartext", [ + "hasDupe", + "parentid", + "parentName", + "type", + "dateAdded", +]); + +function Bookmark(collection, id, type) { + PlacesItem.call(this, collection, id, type || "bookmark"); +} +Bookmark.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Bookmark", + + toSyncBookmark() { + let info = PlacesItem.prototype.toSyncBookmark.call(this); + info.title = this.title; + info.url = this.bmkUri; + info.description = this.description; + info.tags = this.tags; + info.keyword = this.keyword; + return info; + }, + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.title = item.title; + this.bmkUri = item.url.href; + this.description = item.description; + this.tags = item.tags; + this.keyword = item.keyword; + }, +}; + +Utils.deferGetSet(Bookmark, "cleartext", [ + "title", + "bmkUri", + "description", + "tags", + "keyword", +]); + +function BookmarkQuery(collection, id) { + Bookmark.call(this, collection, id, "query"); +} +BookmarkQuery.prototype = { + __proto__: Bookmark.prototype, + _logName: "Sync.Record.BookmarkQuery", + + toSyncBookmark() { + let info = Bookmark.prototype.toSyncBookmark.call(this); + info.folder = this.folderName || undefined; // empty string -> undefined + info.query = this.queryId; + return info; + }, + + fromSyncBookmark(item) { + Bookmark.prototype.fromSyncBookmark.call(this, item); + this.folderName = item.folder || undefined; // empty string -> undefined + this.queryId = item.query; + }, +}; + +Utils.deferGetSet(BookmarkQuery, "cleartext", ["folderName", "queryId"]); + +function BookmarkFolder(collection, id, type) { + PlacesItem.call(this, collection, id, type || "folder"); +} +BookmarkFolder.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Folder", + + toSyncBookmark() { + let info = PlacesItem.prototype.toSyncBookmark.call(this); + info.description = this.description; + info.title = this.title; + return info; + }, + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.title = item.title; + this.description = item.description; + this.children = item.childRecordIds; + }, +}; + +Utils.deferGetSet(BookmarkFolder, "cleartext", [ + "description", + "title", + "children", +]); + +function Livemark(collection, id) { + BookmarkFolder.call(this, collection, id, "livemark"); +} +Livemark.prototype = { + __proto__: BookmarkFolder.prototype, + _logName: "Sync.Record.Livemark", + + toSyncBookmark() { + let info = BookmarkFolder.prototype.toSyncBookmark.call(this); + info.feed = this.feedUri; + info.site = this.siteUri; + return info; + }, + + fromSyncBookmark(item) { + BookmarkFolder.prototype.fromSyncBookmark.call(this, item); + this.feedUri = item.feed.href; + if (item.site) { + this.siteUri = item.site.href; + } + }, +}; + +Utils.deferGetSet(Livemark, "cleartext", ["siteUri", "feedUri"]); + +function BookmarkSeparator(collection, id) { + PlacesItem.call(this, collection, id, "separator"); +} +BookmarkSeparator.prototype = { + __proto__: PlacesItem.prototype, + _logName: "Sync.Record.Separator", + + fromSyncBookmark(item) { + PlacesItem.prototype.fromSyncBookmark.call(this, item); + this.pos = item.index; + }, +}; + +Utils.deferGetSet(BookmarkSeparator, "cleartext", "pos"); + +/** + * The bookmarks engine uses a different store that stages downloaded bookmarks + * in a separate database, instead of writing directly to Places. The buffer + * handles reconciliation, so we stub out `_reconcile`, and wait to pull changes + * until we're ready to upload. + */ +function BookmarksEngine(service) { + SyncEngine.call(this, "Bookmarks", service); +} +BookmarksEngine.prototype = { + __proto__: SyncEngine.prototype, + _recordObj: PlacesItem, + _trackerObj: BookmarksTracker, + _storeObj: BookmarksStore, + version: 2, + // Used to override the engine name in telemetry, so that we can distinguish + // this engine from the old, now removed non-buffered engine. + overrideTelemetryName: "bookmarks-buffered", + + // Needed to ensure we don't miss items when resuming a sync that failed or + // aborted early. + _defaultSort: "oldest", + + syncPriority: 4, + allowSkippedRecord: false, + + async _ensureCurrentSyncID(newSyncID) { + await PlacesSyncUtils.bookmarks.ensureCurrentSyncId(newSyncID); + let buf = await this._store.ensureOpenMirror(); + await buf.ensureCurrentSyncId(newSyncID); + }, + + async ensureCurrentSyncID(newSyncID) { + let shouldWipeRemote = await PlacesSyncUtils.bookmarks.shouldWipeRemote(); + if (!shouldWipeRemote) { + this._log.debug( + "Checking if server sync ID ${newSyncID} matches existing", + { newSyncID } + ); + await this._ensureCurrentSyncID(newSyncID); + return newSyncID; + } + // We didn't take the new sync ID because we need to wipe the server + // and other clients after a restore. Send the command, wipe the + // server, and reset our sync ID to reupload everything. + this._log.debug( + "Ignoring server sync ID ${newSyncID} after restore; " + + "wiping server and resetting sync ID", + { newSyncID } + ); + await this.service.clientsEngine.sendCommand( + "wipeEngine", + [this.name], + null, + { reason: "bookmark-restore" } + ); + let assignedSyncID = await this.resetSyncID(); + return assignedSyncID; + }, + + async getSyncID() { + return PlacesSyncUtils.bookmarks.getSyncId(); + }, + + async resetSyncID() { + await this._deleteServerCollection(); + return this.resetLocalSyncID(); + }, + + async resetLocalSyncID() { + let newSyncID = await PlacesSyncUtils.bookmarks.resetSyncId(); + this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID }); + let buf = await this._store.ensureOpenMirror(); + await buf.ensureCurrentSyncId(newSyncID); + return newSyncID; + }, + + async getLastSync() { + let mirror = await this._store.ensureOpenMirror(); + return mirror.getCollectionHighWaterMark(); + }, + + async setLastSync(lastSync) { + let mirror = await this._store.ensureOpenMirror(); + await mirror.setCollectionLastModified(lastSync); + // Update the last sync time in Places so that reverting to the original + // bookmarks engine doesn't download records we've already applied. + await PlacesSyncUtils.bookmarks.setLastSync(lastSync); + }, + + async _syncStartup() { + await super._syncStartup(); + + try { + // For first syncs, back up the user's bookmarks. + let lastSync = await this.getLastSync(); + if (!lastSync) { + this._log.debug("Bookmarks backup starting"); + await PlacesBackups.create(null, true); + this._log.debug("Bookmarks backup done"); + } + } catch (ex) { + // Failure to create a backup is somewhat bad, but probably not bad + // enough to prevent syncing of bookmarks - so just log the error and + // continue. + this._log.warn( + "Error while backing up bookmarks, but continuing with sync", + ex + ); + } + }, + + async _sync() { + try { + await super._sync(); + if (this._ranMaintenanceOnLastSync) { + // If the last sync failed, we ran maintenance, and this sync succeeded, + // maintenance likely fixed the issue. + this._ranMaintenanceOnLastSync = false; + this.service.recordTelemetryEvent("maintenance", "fix", "bookmarks"); + } + } catch (ex) { + if ( + Async.isShutdownException(ex) || + ex.status > 0 || + ex.name == "MergeConflictError" || + ex.name == "InterruptedError" + ) { + // Don't run maintenance on shutdown or HTTP errors, or if we aborted + // the sync because the user changed their bookmarks during merging. + throw ex; + } + // Run Places maintenance periodically to try to recover from corruption + // that might have caused the sync to fail. We cap the interval because + // persistent failures likely indicate a problem that won't be fixed by + // running maintenance after every failed sync. + let elapsedSinceMaintenance = + Date.now() / 1000 - + Services.prefs.getIntPref("places.database.lastMaintenance", 0); + if (elapsedSinceMaintenance >= PLACES_MAINTENANCE_INTERVAL_SECONDS) { + this._log.error( + "Bookmark sync failed, ${elapsedSinceMaintenance}s " + + "elapsed since last run; running Places maintenance", + { elapsedSinceMaintenance } + ); + await PlacesDBUtils.maintenanceOnIdle(); + this._ranMaintenanceOnLastSync = true; + this.service.recordTelemetryEvent("maintenance", "run", "bookmarks"); + } else { + this._ranMaintenanceOnLastSync = false; + } + throw ex; + } + }, + + async _syncFinish() { + await SyncEngine.prototype._syncFinish.call(this); + await PlacesSyncUtils.bookmarks.ensureMobileQuery(); + }, + + async pullAllChanges() { + return this.pullNewChanges(); + }, + + async trackRemainingChanges() { + let changes = this._modified.changes; + await PlacesSyncUtils.bookmarks.pushChanges(changes); + }, + + _deleteId(id) { + this._noteDeletedId(id); + }, + + // The bookmarks engine rarely calls this method directly, except in tests or + // when handling a `reset{All, Engine}` command from another client. We + // usually reset local Sync metadata on a sync ID mismatch, which both engines + // override with logic that lives in Places and the mirror. + async _resetClient() { + await super._resetClient(); + await PlacesSyncUtils.bookmarks.reset(); + let buf = await this._store.ensureOpenMirror(); + await buf.reset(); + }, + + // Cleans up the Places root, reading list items (ignored in bug 762118, + // removed in bug 1155684), and pinned sites. + _shouldDeleteRemotely(incomingItem) { + return ( + FORBIDDEN_INCOMING_IDS.includes(incomingItem.id) || + FORBIDDEN_INCOMING_PARENT_IDS.includes(incomingItem.parentid) + ); + }, + + emptyChangeset() { + return new BookmarksChangeset(); + }, + + async _apply() { + let buf = await this._store.ensureOpenMirror(); + let watchdog = this._newWatchdog(); + watchdog.start(BOOKMARK_APPLY_TIMEOUT_MS); + + try { + let recordsToUpload = await buf.apply({ + remoteTimeSeconds: Resource.serverTime, + weakUpload: [...this._needWeakUpload.keys()], + signal: watchdog.signal, + }); + this._modified.replace(recordsToUpload); + } finally { + watchdog.stop(); + if (watchdog.abortReason) { + this._log.warn(`Aborting bookmark merge: ${watchdog.abortReason}`); + } + this._needWeakUpload.clear(); + } + }, + + async _processIncoming(newitems) { + await super._processIncoming(newitems); + await this._apply(); + }, + + async _reconcile(item) { + return true; + }, + + async _createRecord(id) { + let record = await this._doCreateRecord(id); + if (!record.deleted) { + // Set hasDupe on all (non-deleted) records since we don't use it and we + // want to minimize the risk of older clients corrupting records. Note + // that the SyncedBookmarksMirror sets it for all records that it created, + // but we would like to ensure that weakly uploaded records are marked as + // hasDupe as well. + record.hasDupe = true; + } + return record; + }, + + async _doCreateRecord(id) { + if (this._needWeakUpload.has(id)) { + return this._store.createRecord(id, this.name); + } + let change = this._modified.changes[id]; + if (!change) { + this._log.error( + "Creating record for item ${id} not in strong changeset", + { id } + ); + throw new TypeError("Can't create record for unchanged item"); + } + let record = this._recordFromCleartext(id, change.cleartext); + record.sortindex = await this._store._calculateIndex(record); + return record; + }, + + _recordFromCleartext(id, cleartext) { + let recordObj = getTypeObject(cleartext.type); + if (!recordObj) { + this._log.warn( + "Creating record for item ${id} with unknown type ${type}", + { id, type: cleartext.type } + ); + recordObj = PlacesItem; + } + let record = new recordObj(this.name, id); + record.cleartext = cleartext; + return record; + }, + + async pullChanges() { + return {}; + }, + + /** + * Writes successfully uploaded records back to the mirror, so that the + * mirror matches the server. We update the mirror before updating Places, + * which has implications for interrupted syncs. + * + * 1. Sync interrupted during upload; server doesn't support atomic uploads. + * We'll download and reapply everything that we uploaded before the + * interruption. All locally changed items retain their change counters. + * 2. Sync interrupted during upload; atomic uploads enabled. The server + * discards the batch. All changed local items retain their change + * counters, so the next sync resumes cleanly. + * 3. Sync interrupted during upload; outgoing records can't fit in a single + * batch. We'll download and reapply all records through the most recent + * committed batch. This is a variation of (1). + * 4. Sync interrupted after we update the mirror, but before cleanup. The + * mirror matches the server, but locally changed items retain their change + * counters. Reuploading them on the next sync should be idempotent, though + * unnecessary. If another client makes a conflicting remote change before + * we sync again, we may incorrectly prefer the local state. + * 5. Sync completes successfully. We'll update the mirror, and reset the + * change counters for all items. + */ + async _onRecordsWritten(succeeded, failed, serverModifiedTime) { + let records = []; + for (let id of succeeded) { + let change = this._modified.changes[id]; + if (!change) { + // TODO (Bug 1433178): Write weakly uploaded records back to the mirror. + this._log.info("Uploaded record not in strong changeset", id); + continue; + } + if (!change.synced) { + this._log.info("Record in strong changeset not uploaded", id); + continue; + } + let cleartext = change.cleartext; + if (!cleartext) { + this._log.error( + "Missing Sync record cleartext for ${id} in ${change}", + { id, change } + ); + throw new TypeError("Missing cleartext for uploaded Sync record"); + } + let record = this._recordFromCleartext(id, cleartext); + record.modified = serverModifiedTime; + records.push(record); + } + let buf = await this._store.ensureOpenMirror(); + await buf.store(records, { needsMerge: false }); + }, + + async finalize() { + await super.finalize(); + await this._store.finalize(); + }, +}; + +/** + * The bookmarks store delegates to the mirror for staging and applying + * records. Most `Store` methods intentionally remain abstract, so you can't use + * this store to create or update bookmarks in Places. All changes must go + * through the mirror, which takes care of merging and producing a valid tree. + */ +function BookmarksStore(name, engine) { + Store.call(this, name, engine); +} + +BookmarksStore.prototype = { + __proto__: Store.prototype, + + _openMirrorPromise: null, + + // For tests. + _batchChunkSize: 500, + + // Create a record starting from the weave id (places guid) + async createRecord(id, collection) { + let item = await PlacesSyncUtils.bookmarks.fetch(id); + if (!item) { + // deleted item + let record = new PlacesItem(collection, id); + record.deleted = true; + return record; + } + + let recordObj = getTypeObject(item.kind); + if (!recordObj) { + this._log.warn("Unknown item type, cannot serialize: " + item.kind); + recordObj = PlacesItem; + } + let record = new recordObj(collection, id); + record.fromSyncBookmark(item); + + record.sortindex = await this._calculateIndex(record); + + return record; + }, + + async _calculateIndex(record) { + // Ensure folders have a very high sort index so they're not synced last. + if (record.type == "folder") { + return FOLDER_SORTINDEX; + } + + // For anything directly under the toolbar, give it a boost of more than an + // unvisited bookmark + let index = 0; + if (record.parentid == "toolbar") { + index += 150; + } + + // Add in the bookmark's frecency if we have something. + if (record.bmkUri != null) { + let frecency = FRECENCY_UNKNOWN; + try { + frecency = await PlacesSyncUtils.history.fetchURLFrecency( + record.bmkUri + ); + } catch (ex) { + this._log.warn( + `Failed to fetch frecency for ${record.id}; assuming default`, + ex + ); + this._log.trace("Record {id} has invalid URL ${bmkUri}", record); + } + if (frecency != FRECENCY_UNKNOWN) { + index += frecency; + } + } + + return index; + }, + + async wipe() { + // Save a backup before clearing out all bookmarks. + await PlacesBackups.create(null, true); + await PlacesSyncUtils.bookmarks.wipe(); + }, + + ensureOpenMirror() { + if (!this._openMirrorPromise) { + this._openMirrorPromise = this._openMirror().catch(err => { + // We may have failed to open the mirror temporarily; for example, if + // the database is locked. Clear the promise so that subsequent + // `ensureOpenMirror` calls can try to open the mirror again. + this._openMirrorPromise = null; + throw err; + }); + } + return this._openMirrorPromise; + }, + + async _openMirror() { + let mirrorPath = OS.Path.join( + OS.Constants.Path.profileDir, + "weave", + "bookmarks.sqlite" + ); + await OS.File.makeDir(OS.Path.dirname(mirrorPath), { + from: OS.Constants.Path.profileDir, + }); + + return SyncedBookmarksMirror.open({ + path: mirrorPath, + recordStepTelemetry: (name, took, counts) => { + Observers.notify( + "weave:engine:sync:step", + { + name, + took, + counts, + }, + this.name + ); + }, + recordValidationTelemetry: (took, checked, problems) => { + Observers.notify( + "weave:engine:validate:finish", + { + version: BOOKMARK_VALIDATOR_VERSION, + took, + checked, + problems, + }, + this.name + ); + }, + }); + }, + + async applyIncomingBatch(records) { + let buf = await this.ensureOpenMirror(); + for (let chunk of PlacesUtils.chunkArray(records, this._batchChunkSize)) { + await buf.store(chunk); + } + // Array of failed records. + return []; + }, + + async applyIncoming(record) { + let buf = await this.ensureOpenMirror(); + await buf.store([record]); + }, + + async finalize() { + if (!this._openMirrorPromise) { + return; + } + let buf = await this._openMirrorPromise; + await buf.finalize(); + }, +}; + +// The bookmarks tracker is a special flower. Instead of listening for changes +// via observer notifications, it queries Places for the set of items that have +// changed since the last sync. Because it's a "pull-based" tracker, it ignores +// all concepts of "add a changed ID." However, it still registers an observer +// to bump the score, so that changed bookmarks are synced immediately. +function BookmarksTracker(name, engine) { + Tracker.call(this, name, engine); + this._batchDepth = 0; + this._batchSawScoreIncrement = false; +} +BookmarksTracker.prototype = { + __proto__: Tracker.prototype, + + onStart() { + PlacesUtils.bookmarks.addObserver(this, true); + this._placesListener = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesUtils.observers.addListener( + ["bookmark-added", "bookmark-removed"], + this._placesListener + ); + Svc.Obs.add("bookmarks-restore-begin", this); + Svc.Obs.add("bookmarks-restore-success", this); + Svc.Obs.add("bookmarks-restore-failed", this); + }, + + onStop() { + PlacesUtils.bookmarks.removeObserver(this); + PlacesUtils.observers.removeListener( + ["bookmark-added", "bookmark-removed"], + this._placesListener + ); + Svc.Obs.remove("bookmarks-restore-begin", this); + Svc.Obs.remove("bookmarks-restore-success", this); + Svc.Obs.remove("bookmarks-restore-failed", this); + }, + + async getChangedIDs() { + return PlacesSyncUtils.bookmarks.pullChanges(); + }, + + observe(subject, topic, data) { + switch (topic) { + case "bookmarks-restore-begin": + this._log.debug("Ignoring changes from importing bookmarks."); + break; + case "bookmarks-restore-success": + this._log.debug("Tracking all items on successful import."); + + if (data == "json") { + this._log.debug( + "Restore succeeded: wiping server and other clients." + ); + // Trigger an immediate sync. `ensureCurrentSyncID` will notice we + // restored, wipe the server and other clients, reset the sync ID, and + // upload the restored tree. + this.score += SCORE_INCREMENT_XLARGE; + } else { + // "html", "html-initial", or "json-append" + this._log.debug("Import succeeded."); + } + break; + case "bookmarks-restore-failed": + this._log.debug("Tracking all items on failed import."); + break; + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsINavBookmarkObserver", + "nsISupportsWeakReference", + ]), + + /* Every add/remove/change will trigger a sync for MULTI_DEVICE (except in + a batch operation, where we do it at the end of the batch) */ + _upScore: function BMT__upScore() { + if (this._batchDepth == 0) { + this.score += SCORE_INCREMENT_XLARGE; + } else { + this._batchSawScoreIncrement = true; + } + }, + + handlePlacesEvents(events) { + for (let event of events) { + switch (event.type) { + case "bookmark-added": + if (IGNORED_SOURCES.includes(event.source)) { + continue; + } + + this._log.trace("'bookmark-added': " + event.id); + this._upScore(); + break; + case "bookmark-removed": + if (IGNORED_SOURCES.includes(event.source)) { + continue; + } + + this._log.trace("'bookmark-removed': " + event.id); + this._upScore(); + break; + } + } + }, + + // This method is oddly structured, but the idea is to return as quickly as + // possible -- this handler gets called *every time* a bookmark changes, for + // *each change*. + onItemChanged: function BMT_onItemChanged( + itemId, + property, + isAnno, + value, + lastModified, + itemType, + parentId, + guid, + parentGuid, + oldValue, + source + ) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + if (isAnno && !ANNOS_TO_TRACK.includes(property)) { + // Ignore annotations except for the ones that we sync. + return; + } + + this._log.trace( + "onItemChanged: " + + itemId + + (", " + property + (isAnno ? " (anno)" : "")) + + (value ? ' = "' + value + '"' : "") + ); + this._upScore(); + }, + + onItemMoved: function BMT_onItemMoved( + itemId, + oldParent, + oldIndex, + newParent, + newIndex, + itemType, + guid, + oldParentGuid, + newParentGuid, + source + ) { + if (IGNORED_SOURCES.includes(source)) { + return; + } + + this._log.trace("onItemMoved: " + itemId); + this._upScore(); + }, + + onBeginUpdateBatch() { + ++this._batchDepth; + }, + onEndUpdateBatch() { + if (--this._batchDepth === 0 && this._batchSawScoreIncrement) { + this.score += SCORE_INCREMENT_XLARGE; + this._batchSawScoreIncrement = false; + } + }, +}; + +/** + * A changeset that stores extra metadata in a change record for each ID. The + * engine updates this metadata when uploading Sync records, and writes it back + * to Places in `BookmarksEngine#trackRemainingChanges`. + * + * The `synced` property on a change record means its corresponding item has + * been uploaded, and we should pretend it doesn't exist in the changeset. + */ +class BookmarksChangeset extends Changeset { + // Only `_reconcile` calls `getModifiedTimestamp` and `has`, and the engine + // does its own reconciliation. + getModifiedTimestamp(id) { + throw new Error("Don't use timestamps to resolve bookmark conflicts"); + } + + has(id) { + throw new Error("Don't use the changeset to resolve bookmark conflicts"); + } + + delete(id) { + let change = this.changes[id]; + if (change) { + // Mark the change as synced without removing it from the set. We do this + // so that we can update Places in `trackRemainingChanges`. + change.synced = true; + } + } + + ids() { + let results = new Set(); + for (let id in this.changes) { + if (!this.changes[id].synced) { + results.add(id); + } + } + return [...results]; + } +} diff --git a/services/sync/modules/engines/clients.js b/services/sync/modules/engines/clients.js new file mode 100644 index 0000000000..60715d73bd --- /dev/null +++ b/services/sync/modules/engines/clients.js @@ -0,0 +1,1186 @@ +/* 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/. */ + +/** + * How does the clients engine work? + * + * - We use 2 files - commands.json and commands-syncing.json. + * + * - At sync upload time, we attempt a rename of commands.json to + * commands-syncing.json, and ignore errors (helps for crash during sync!). + * - We load commands-syncing.json and stash the contents in + * _currentlySyncingCommands which lives for the duration of the upload process. + * - We use _currentlySyncingCommands to build the outgoing records + * - Immediately after successful upload, we delete commands-syncing.json from + * disk (and clear _currentlySyncingCommands). We reconcile our local records + * with what we just wrote in the server, and add failed IDs commands + * back in commands.json + * - Any time we need to "save" a command for future syncs, we load + * commands.json, update it, and write it back out. + */ + +var EXPORTED_SYMBOLS = ["ClientEngine", "ClientsRec"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { + DEVICE_TYPE_DESKTOP, + DEVICE_TYPE_MOBILE, + SCORE_INCREMENT_XLARGE, + SINGLE_USER_THRESHOLD, + SYNC_API_VERSION, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Resource } = ChromeUtils.import("resource://services-sync/resource.js"); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +ChromeUtils.defineModuleGetter( + this, + "fxAccounts", + "resource://gre/modules/FxAccounts.jsm" +); + +const { PREF_ACCOUNT_ROOT } = ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js" +); + +const CLIENTS_TTL = 1814400; // 21 days +const CLIENTS_TTL_REFRESH = 604800; // 7 days +const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days + +// TTL of the message sent to another device when sending a tab +const NOTIFY_TAB_SENT_TTL_SECS = 1 * 3600; // 1 hour + +// How often we force a refresh of the FxA device list. +const REFRESH_FXA_DEVICE_INTERVAL_MS = 2 * 60 * 60 * 1000; // 2 hours + +// Reasons behind sending collection_changed push notifications. +const COLLECTION_MODIFIED_REASON_SENDTAB = "sendtab"; +const COLLECTION_MODIFIED_REASON_FIRSTSYNC = "firstsync"; + +const SUPPORTED_PROTOCOL_VERSIONS = [SYNC_API_VERSION]; +const LAST_MODIFIED_ON_PROCESS_COMMAND_PREF = + "services.sync.clients.lastModifiedOnProcessCommands"; + +function hasDupeCommand(commands, action) { + if (!commands) { + return false; + } + return commands.some( + other => + other.command == action.command && + Utils.deepEquals(other.args, action.args) + ); +} + +function ClientsRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} +ClientsRec.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Clients", + ttl: CLIENTS_TTL, +}; + +Utils.deferGetSet(ClientsRec, "cleartext", [ + "name", + "type", + "commands", + "version", + "protocols", + "formfactor", + "os", + "appPackage", + "application", + "device", + "fxaDeviceId", +]); + +function ClientEngine(service) { + SyncEngine.call(this, "Clients", service); + + this.fxAccounts = fxAccounts; + this.addClientCommandQueue = Async.asyncQueueCaller(this._log); + Utils.defineLazyIDProperty(this, "localID", "services.sync.client.GUID"); +} +ClientEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: ClientStore, + _recordObj: ClientsRec, + _trackerObj: ClientsTracker, + allowSkippedRecord: false, + _knownStaleFxADeviceIds: null, + _lastDeviceCounts: null, + _lastFxaDeviceRefresh: 0, + + async initialize() { + // Reset the last sync timestamp on every startup so that we fetch all clients + await this.resetLastSync(); + }, + + // These two properties allow us to avoid replaying the same commands + // continuously if we cannot manage to upload our own record. + _localClientLastModified: 0, + get _lastModifiedOnProcessCommands() { + return Services.prefs.getIntPref(LAST_MODIFIED_ON_PROCESS_COMMAND_PREF, -1); + }, + + set _lastModifiedOnProcessCommands(value) { + Services.prefs.setIntPref(LAST_MODIFIED_ON_PROCESS_COMMAND_PREF, value); + }, + + get isFirstSync() { + return !this.lastRecordUpload; + }, + + // Always sync client data as it controls other sync behavior + get enabled() { + return true; + }, + + get lastRecordUpload() { + return Svc.Prefs.get(this.name + ".lastRecordUpload", 0); + }, + set lastRecordUpload(value) { + Svc.Prefs.set(this.name + ".lastRecordUpload", Math.floor(value)); + }, + + get remoteClients() { + // return all non-stale clients for external consumption. + return Object.values(this._store._remoteClients).filter(v => !v.stale); + }, + + remoteClient(id) { + let client = this._store._remoteClients[id]; + return client && !client.stale ? client : null; + }, + + remoteClientExists(id) { + return !!this.remoteClient(id); + }, + + // Aggregate some stats on the composition of clients on this account + get stats() { + let stats = { + hasMobile: this.localType == DEVICE_TYPE_MOBILE, + names: [this.localName], + numClients: 1, + }; + + for (let id in this._store._remoteClients) { + let { name, type, stale } = this._store._remoteClients[id]; + if (!stale) { + stats.hasMobile = stats.hasMobile || type == DEVICE_TYPE_MOBILE; + stats.names.push(name); + stats.numClients++; + } + } + + return stats; + }, + + /** + * Obtain information about device types. + * + * Returns a Map of device types to integer counts. Guaranteed to include + * "desktop" (which will have at least 1 - this device) and "mobile" (which + * may have zero) counts. It almost certainly will include only these 2. + */ + get deviceTypes() { + let counts = new Map(); + + counts.set(this.localType, 1); // currently this must be DEVICE_TYPE_DESKTOP + counts.set(DEVICE_TYPE_MOBILE, 0); + + for (let id in this._store._remoteClients) { + let record = this._store._remoteClients[id]; + if (record.stale) { + continue; // pretend "stale" records don't exist. + } + let type = record.type; + if (!counts.has(type)) { + counts.set(type, 0); + } + + counts.set(type, counts.get(type) + 1); + } + + return counts; + }, + + get brandName() { + let brand = Services.strings.createBundle( + "chrome://branding/locale/brand.properties" + ); + return brand.GetStringFromName("brandShortName"); + }, + + get localName() { + return this.fxAccounts.device.getLocalName(); + }, + set localName(value) { + this.fxAccounts.device.setLocalName(value); + }, + + get localType() { + return this.fxAccounts.device.getLocalType(); + }, + + getClientName(id) { + if (id == this.localID) { + return this.localName; + } + let client = this._store._remoteClients[id]; + return client ? client.name : ""; + }, + + getClientFxaDeviceId(id) { + if (this._store._remoteClients[id]) { + return this._store._remoteClients[id].fxaDeviceId; + } + return null; + }, + + getClientByFxaDeviceId(fxaDeviceId) { + for (let id in this._store._remoteClients) { + let client = this._store._remoteClients[id]; + if (client.stale) { + continue; + } + if (client.fxaDeviceId == fxaDeviceId) { + return client; + } + } + return null; + }, + + getClientType(id) { + const client = this._store._remoteClients[id]; + if (client.type == DEVICE_TYPE_DESKTOP) { + return "desktop"; + } + if (client.formfactor && client.formfactor.includes("tablet")) { + return "tablet"; + } + return "phone"; + }, + + async _readCommands() { + let commands = await Utils.jsonLoad("commands", this); + return commands || {}; + }, + + /** + * Low level function, do not use directly (use _addClientCommand instead). + */ + async _saveCommands(commands) { + try { + await Utils.jsonSave("commands", this, commands); + } catch (error) { + this._log.error("Failed to save JSON outgoing commands", error); + } + }, + + async _prepareCommandsForUpload() { + try { + await Utils.jsonMove("commands", "commands-syncing", this); + } catch (e) { + // Ignore errors + } + let commands = await Utils.jsonLoad("commands-syncing", this); + return commands || {}; + }, + + async _deleteUploadedCommands() { + delete this._currentlySyncingCommands; + try { + await Utils.jsonRemove("commands-syncing", this); + } catch (err) { + this._log.error("Failed to delete syncing-commands file", err); + } + }, + + // Gets commands for a client we are yet to write to the server. Doesn't + // include commands for that client which are already on the server. + // We should rename this! + async getClientCommands(clientId) { + const allCommands = await this._readCommands(); + return allCommands[clientId] || []; + }, + + async removeLocalCommand(command) { + // the implementation of this engine is such that adding a command to + // the local client is how commands are deleted! ¯\_(ツ)_/¯ + await this._addClientCommand(this.localID, command); + }, + + async _addClientCommand(clientId, command) { + this.addClientCommandQueue.enqueueCall(async () => { + try { + const localCommands = await this._readCommands(); + const localClientCommands = localCommands[clientId] || []; + const remoteClient = this._store._remoteClients[clientId]; + let remoteClientCommands = []; + if (remoteClient && remoteClient.commands) { + remoteClientCommands = remoteClient.commands; + } + const clientCommands = localClientCommands.concat(remoteClientCommands); + if (hasDupeCommand(clientCommands, command)) { + return false; + } + localCommands[clientId] = localClientCommands.concat(command); + await this._saveCommands(localCommands); + return true; + } catch (e) { + // Failing to save a command should not "break the queue" of pending operations. + this._log.error(e); + return false; + } + }); + + return this.addClientCommandQueue.promiseCallsComplete(); + }, + + async _removeClientCommands(clientId) { + const allCommands = await this._readCommands(); + delete allCommands[clientId]; + await this._saveCommands(allCommands); + }, + + async updateKnownStaleClients() { + this._log.debug("Updating the known stale clients"); + // _fetchFxADevices side effect updates this._knownStaleFxADeviceIds. + await this._fetchFxADevices(); + let localFxADeviceId = await fxAccounts.device.getLocalId(); + // Process newer records first, so that if we hit a record with a device ID + // we've seen before, we can mark it stale immediately. + let clientList = Object.values(this._store._remoteClients).sort( + (a, b) => b.serverLastModified - a.serverLastModified + ); + let seenDeviceIds = new Set([localFxADeviceId]); + for (let client of clientList) { + // Clients might not have an `fxaDeviceId` if they fail the FxA + // registration process. + if (!client.fxaDeviceId) { + continue; + } + if (this._knownStaleFxADeviceIds.includes(client.fxaDeviceId)) { + this._log.info( + `Hiding stale client ${client.id} - in known stale clients list` + ); + client.stale = true; + } else if (seenDeviceIds.has(client.fxaDeviceId)) { + this._log.info( + `Hiding stale client ${client.id}` + + ` - duplicate device id ${client.fxaDeviceId}` + ); + client.stale = true; + } else { + seenDeviceIds.add(client.fxaDeviceId); + } + } + }, + + async _fetchFxADevices() { + // We only force a refresh periodically to keep the load on the servers + // down, and because we expect FxA to have received a push message in + // most cases when the FxA device list would have changed. For this reason + // we still go ahead and check the stale list even if we didn't force a + // refresh. + let now = this.fxAccounts._internal.now(); // tests mock this .now() impl. + if (now - REFRESH_FXA_DEVICE_INTERVAL_MS > this._lastFxaDeviceRefresh) { + this._lastFxaDeviceRefresh = now; + try { + await this.fxAccounts.device.refreshDeviceList(); + } catch (e) { + this._log.error("Could not refresh the FxA device list", e); + } + } + + // We assume that clients not present in the FxA Device Manager list have been + // disconnected and so are stale + this._log.debug("Refreshing the known stale clients list"); + let localClients = Object.values(this._store._remoteClients) + .filter(client => client.fxaDeviceId) // iOS client records don't have fxaDeviceId + .map(client => client.fxaDeviceId); + const fxaClients = this.fxAccounts.device.recentDeviceList + ? this.fxAccounts.device.recentDeviceList.map(device => device.id) + : []; + this._knownStaleFxADeviceIds = Utils.arraySub(localClients, fxaClients); + }, + + async _syncStartup() { + // Reupload new client record periodically. + if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) { + await this._tracker.addChangedID(this.localID); + } + return SyncEngine.prototype._syncStartup.call(this); + }, + + async _processIncoming() { + // Fetch all records from the server. + await this.resetLastSync(); + this._incomingClients = {}; + try { + await SyncEngine.prototype._processIncoming.call(this); + // Update FxA Device list. + await this._fetchFxADevices(); + // Since clients are synced unconditionally, any records in the local store + // that don't exist on the server must be for disconnected clients. Remove + // them, so that we don't upload records with commands for clients that will + // never see them. We also do this to filter out stale clients from the + // tabs collection, since showing their list of tabs is confusing. + for (let id in this._store._remoteClients) { + if (!this._incomingClients[id]) { + this._log.info(`Removing local state for deleted client ${id}`); + await this._removeRemoteClient(id); + } + } + let localFxADeviceId = await fxAccounts.device.getLocalId(); + // Bug 1264498: Mobile clients don't remove themselves from the clients + // collection when the user disconnects Sync, so we mark as stale clients + // with the same name that haven't synced in over a week. + // (Note we can't simply delete them, or we re-apply them next sync - see + // bug 1287687) + this._localClientLastModified = Math.round( + this._incomingClients[this.localID] + ); + delete this._incomingClients[this.localID]; + let names = new Set([this.localName]); + let seenDeviceIds = new Set([localFxADeviceId]); + let idToLastModifiedList = Object.entries(this._incomingClients).sort( + (a, b) => b[1] - a[1] + ); + for (let [id, serverLastModified] of idToLastModifiedList) { + let record = this._store._remoteClients[id]; + // stash the server last-modified time on the record. + record.serverLastModified = serverLastModified; + if ( + record.fxaDeviceId && + this._knownStaleFxADeviceIds.includes(record.fxaDeviceId) + ) { + this._log.info( + `Hiding stale client ${id} - in known stale clients list` + ); + record.stale = true; + } + if (!names.has(record.name)) { + if (record.fxaDeviceId) { + seenDeviceIds.add(record.fxaDeviceId); + } + names.add(record.name); + continue; + } + let remoteAge = Resource.serverTime - this._incomingClients[id]; + if (remoteAge > STALE_CLIENT_REMOTE_AGE) { + this._log.info(`Hiding stale client ${id} with age ${remoteAge}`); + record.stale = true; + continue; + } + if (record.fxaDeviceId && seenDeviceIds.has(record.fxaDeviceId)) { + this._log.info( + `Hiding stale client ${record.id}` + + ` - duplicate device id ${record.fxaDeviceId}` + ); + record.stale = true; + } else if (record.fxaDeviceId) { + seenDeviceIds.add(record.fxaDeviceId); + } + } + } finally { + this._incomingClients = null; + } + }, + + async _uploadOutgoing() { + this._currentlySyncingCommands = await this._prepareCommandsForUpload(); + const clientWithPendingCommands = Object.keys( + this._currentlySyncingCommands + ); + for (let clientId of clientWithPendingCommands) { + if (this._store._remoteClients[clientId] || this.localID == clientId) { + this._modified.set(clientId, 0); + } + } + let updatedIDs = this._modified.ids(); + await SyncEngine.prototype._uploadOutgoing.call(this); + // Record the response time as the server time for each item we uploaded. + let lastSync = await this.getLastSync(); + for (let id of updatedIDs) { + if (id == this.localID) { + this.lastRecordUpload = lastSync; + } else { + this._store._remoteClients[id].serverLastModified = lastSync; + } + } + }, + + async _onRecordsWritten(succeeded, failed) { + // Reconcile the status of the local records with what we just wrote on the + // server + for (let id of succeeded) { + const commandChanges = this._currentlySyncingCommands[id]; + if (id == this.localID) { + if (this.isFirstSync) { + this._log.info( + "Uploaded our client record for the first time, notifying other clients." + ); + this._notifyClientRecordUploaded(); + } + if (this.localCommands) { + this.localCommands = this.localCommands.filter( + command => !hasDupeCommand(commandChanges, command) + ); + } + } else { + const clientRecord = this._store._remoteClients[id]; + if (!commandChanges || !clientRecord) { + // should be impossible, else we wouldn't have been writing it. + this._log.warn( + "No command/No record changes for a client we uploaded" + ); + continue; + } + // fixup the client record, so our copy of _remoteClients matches what we uploaded. + this._store._remoteClients[id] = await this._store.createRecord(id); + // we could do better and pass the reference to the record we just uploaded, + // but this will do for now + } + } + + // Re-add failed commands + for (let id of failed) { + const commandChanges = this._currentlySyncingCommands[id]; + if (!commandChanges) { + continue; + } + await this._addClientCommand(id, commandChanges); + } + + await this._deleteUploadedCommands(); + + // Notify other devices that their own client collection changed + const idsToNotify = succeeded.reduce((acc, id) => { + if (id == this.localID) { + return acc; + } + const fxaDeviceId = this.getClientFxaDeviceId(id); + return fxaDeviceId ? acc.concat(fxaDeviceId) : acc; + }, []); + if (idsToNotify.length > 0) { + this._notifyOtherClientsModified(idsToNotify); + } + }, + + _notifyOtherClientsModified(ids) { + // We are not waiting on this promise on purpose. + this._notifyCollectionChanged( + ids, + NOTIFY_TAB_SENT_TTL_SECS, + COLLECTION_MODIFIED_REASON_SENDTAB + ); + }, + + _notifyClientRecordUploaded() { + // We are not waiting on this promise on purpose. + this._notifyCollectionChanged( + null, + 0, + COLLECTION_MODIFIED_REASON_FIRSTSYNC + ); + }, + + /** + * @param {?string[]} ids FxA Client IDs to notify. null means everyone else. + * @param {number} ttl TTL of the push notification. + * @param {string} reason Reason for sending this push notification. + */ + async _notifyCollectionChanged(ids, ttl, reason) { + const message = { + version: 1, + command: "sync:collection_changed", + data: { + collections: ["clients"], + reason, + }, + }; + let excludedIds = null; + if (!ids) { + const localFxADeviceId = await fxAccounts.device.getLocalId(); + excludedIds = [localFxADeviceId]; + } + try { + await this.fxAccounts.notifyDevices(ids, excludedIds, message, ttl); + } catch (e) { + this._log.error("Could not notify of changes in the collection", e); + } + }, + + async _syncFinish() { + // Record histograms for our device types, and also write them to a pref + // so non-histogram telemetry (eg, UITelemetry) and the sync scheduler + // has easy access to them, and so they are accurate even before we've + // successfully synced the first time after startup. + let deviceTypeCounts = this.deviceTypes; + for (let [deviceType, count] of deviceTypeCounts) { + let hid; + let prefName = this.name + ".devices."; + switch (deviceType) { + case DEVICE_TYPE_DESKTOP: + hid = "WEAVE_DEVICE_COUNT_DESKTOP"; + prefName += "desktop"; + break; + case DEVICE_TYPE_MOBILE: + hid = "WEAVE_DEVICE_COUNT_MOBILE"; + prefName += "mobile"; + break; + default: + this._log.warn( + `Unexpected deviceType "${deviceType}" recording device telemetry.` + ); + continue; + } + Services.telemetry.getHistogramById(hid).add(count); + // Optimization: only write the pref if it changed since our last sync. + if ( + this._lastDeviceCounts == null || + this._lastDeviceCounts.get(prefName) != count + ) { + Svc.Prefs.set(prefName, count); + } + } + this._lastDeviceCounts = deviceTypeCounts; + return SyncEngine.prototype._syncFinish.call(this); + }, + + async _reconcile(item) { + // Every incoming record is reconciled, so we use this to track the + // contents of the collection on the server. + this._incomingClients[item.id] = item.modified; + + if (!(await this._store.itemExists(item.id))) { + return true; + } + // Clients are synced unconditionally, so we'll always have new records. + // Unfortunately, this will cause the scheduler to use the immediate sync + // interval for the multi-device case, instead of the active interval. We + // work around this by updating the record during reconciliation, and + // returning false to indicate that the record doesn't need to be applied + // later. + await this._store.update(item); + return false; + }, + + // Treat reset the same as wiping for locally cached clients + async _resetClient() { + await this._wipeClient(); + }, + + async _wipeClient() { + await SyncEngine.prototype._resetClient.call(this); + this._knownStaleFxADeviceIds = null; + delete this.localCommands; + await this._store.wipe(); + try { + await Utils.jsonRemove("commands", this); + } catch (err) { + this._log.warn("Could not delete commands.json", err); + } + try { + await Utils.jsonRemove("commands-syncing", this); + } catch (err) { + this._log.warn("Could not delete commands-syncing.json", err); + } + }, + + async removeClientData() { + let res = this.service.resource(this.engineURL + "/" + this.localID); + await res.delete(); + }, + + // Override the default behavior to delete bad records from the server. + async handleHMACMismatch(item, mayRetry) { + this._log.debug("Handling HMAC mismatch for " + item.id); + + let base = await SyncEngine.prototype.handleHMACMismatch.call( + this, + item, + mayRetry + ); + if (base != SyncEngine.kRecoveryStrategy.error) { + return base; + } + + // It's a bad client record. Save it to be deleted at the end of the sync. + this._log.debug("Bad client record detected. Scheduling for deletion."); + await this._deleteId(item.id); + + // Neither try again nor error; we're going to delete it. + return SyncEngine.kRecoveryStrategy.ignore; + }, + + /** + * A hash of valid commands that the client knows about. The key is a command + * and the value is a hash containing information about the command such as + * number of arguments, description, and importance (lower importance numbers + * indicate higher importance. + */ + _commands: { + resetAll: { + args: 0, + importance: 0, + desc: "Clear temporary local data for all engines", + }, + resetEngine: { + args: 1, + importance: 0, + desc: "Clear temporary local data for engine", + }, + wipeAll: { + args: 0, + importance: 0, + desc: "Delete all client data for all engines", + }, + wipeEngine: { + args: 1, + importance: 0, + desc: "Delete all client data for engine", + }, + logout: { args: 0, importance: 0, desc: "Log out client" }, + displayURI: { + args: 3, + importance: 1, + desc: "Instruct a client to display a URI", + }, + }, + + /** + * Sends a command+args pair to a specific client. + * + * @param command Command string + * @param args Array of arguments/data for command + * @param clientId Client to send command to + */ + async _sendCommandToClient(command, args, clientId, telemetryExtra) { + this._log.trace("Sending " + command + " to " + clientId); + + let client = this._store._remoteClients[clientId]; + if (!client) { + throw new Error("Unknown remote client ID: '" + clientId + "'."); + } + if (client.stale) { + throw new Error("Stale remote client ID: '" + clientId + "'."); + } + + let action = { + command, + args, + // We send the flowID to the other client so *it* can report it in its + // telemetry - we record it in ours below. + flowID: telemetryExtra.flowID, + }; + + if (await this._addClientCommand(clientId, action)) { + this._log.trace(`Client ${clientId} got a new action`, [command, args]); + await this._tracker.addChangedID(clientId); + try { + telemetryExtra.deviceID = this.service.identity.hashedDeviceID( + clientId + ); + } catch (_) {} + + this.service.recordTelemetryEvent( + "sendcommand", + command, + undefined, + telemetryExtra + ); + } else { + this._log.trace(`Client ${clientId} got a duplicate action`, [ + command, + args, + ]); + } + }, + + /** + * Check if the local client has any remote commands and perform them. + * + * @return false to abort sync + */ + async processIncomingCommands() { + return this._notify("clients:process-commands", "", async function() { + if ( + !this.localCommands || + (this._lastModifiedOnProcessCommands == this._localClientLastModified && + !this.ignoreLastModifiedOnProcessCommands) + ) { + return true; + } + this._lastModifiedOnProcessCommands = this._localClientLastModified; + + const clearedCommands = await this._readCommands()[this.localID]; + const commands = this.localCommands.filter( + command => !hasDupeCommand(clearedCommands, command) + ); + let didRemoveCommand = false; + let URIsToDisplay = []; + // Process each command in order. + for (let rawCommand of commands) { + let shouldRemoveCommand = true; // most commands are auto-removed. + let { command, args, flowID } = rawCommand; + this._log.debug("Processing command " + command, args); + + this.service.recordTelemetryEvent( + "processcommand", + command, + undefined, + { flowID } + ); + + let engines = [args[0]]; + switch (command) { + case "resetAll": + engines = null; + // Fallthrough + case "resetEngine": + await this.service.resetClient(engines); + break; + case "wipeAll": + engines = null; + // Fallthrough + case "wipeEngine": + await this.service.wipeClient(engines); + break; + case "logout": + this.service.logout(); + return false; + case "displayURI": + let [uri, clientId, title] = args; + URIsToDisplay.push({ uri, clientId, title }); + break; + default: + this._log.warn("Received an unknown command: " + command); + break; + } + // Add the command to the "cleared" commands list + if (shouldRemoveCommand) { + await this.removeLocalCommand(rawCommand); + didRemoveCommand = true; + } + } + if (didRemoveCommand) { + await this._tracker.addChangedID(this.localID); + } + + if (URIsToDisplay.length) { + this._handleDisplayURIs(URIsToDisplay); + } + + return true; + })(); + }, + + /** + * Validates and sends a command to a client or all clients. + * + * Calling this does not actually sync the command data to the server. If the + * client already has the command/args pair, it won't receive a duplicate + * command. + * This method is async since it writes the command to a file. + * + * @param command + * Command to invoke on remote clients + * @param args + * Array of arguments to give to the command + * @param clientId + * Client ID to send command to. If undefined, send to all remote + * clients. + * @param flowID + * A unique identifier used to track success for this operation across + * devices. + */ + async sendCommand(command, args, clientId = null, telemetryExtra = {}) { + let commandData = this._commands[command]; + // Don't send commands that we don't know about. + if (!commandData) { + this._log.error("Unknown command to send: " + command); + return; + } else if (!args || args.length != commandData.args) { + // Don't send a command with the wrong number of arguments. + this._log.error( + "Expected " + + commandData.args + + " args for '" + + command + + "', but got " + + args + ); + return; + } + + // We allocate a "flowID" here, so it is used for each client. + telemetryExtra = Object.assign({}, telemetryExtra); // don't clobber the caller's object + if (!telemetryExtra.flowID) { + telemetryExtra.flowID = Utils.makeGUID(); + } + + if (clientId) { + await this._sendCommandToClient(command, args, clientId, telemetryExtra); + } else { + for (let [id, record] of Object.entries(this._store._remoteClients)) { + if (!record.stale) { + await this._sendCommandToClient(command, args, id, telemetryExtra); + } + } + } + }, + + /** + * Send a URI to another client for display. + * + * A side effect is the score is increased dramatically to incur an + * immediate sync. + * + * If an unknown client ID is specified, sendCommand() will throw an + * Error object. + * + * @param uri + * URI (as a string) to send and display on the remote client + * @param clientId + * ID of client to send the command to. If not defined, will be sent + * to all remote clients. + * @param title + * Title of the page being sent. + */ + async sendURIToClientForDisplay(uri, clientId, title) { + this._log.trace( + "Sending URI to client: " + uri + " -> " + clientId + " (" + title + ")" + ); + await this.sendCommand("displayURI", [uri, this.localID, title], clientId); + + this._tracker.score += SCORE_INCREMENT_XLARGE; + }, + + /** + * Handle a bunch of received 'displayURI' commands. + * + * Interested parties should observe the "weave:engine:clients:display-uris" + * topic. The callback will receive an array as the subject parameter + * containing objects with the following keys: + * + * uri URI (string) that is requested for display. + * sender.id ID of client that sent the command. + * sender.name Name of client that sent the command. + * title Title of page that loaded URI (likely) corresponds to. + * + * The 'data' parameter to the callback will not be defined. + * + * @param uris + * An array containing URI objects to display + * @param uris[].uri + * String URI that was received + * @param uris[].clientId + * ID of client that sent URI + * @param uris[].title + * String title of page that URI corresponds to. Older clients may not + * send this. + */ + _handleDisplayURIs(uris) { + uris.forEach(uri => { + uri.sender = { + id: uri.clientId, + name: this.getClientName(uri.clientId), + }; + }); + Svc.Obs.notify("weave:engine:clients:display-uris", uris); + }, + + async _removeRemoteClient(id) { + delete this._store._remoteClients[id]; + await this._tracker.removeChangedID(id); + await this._removeClientCommands(id); + this._modified.delete(id); + }, +}; + +function ClientStore(name, engine) { + Store.call(this, name, engine); +} +ClientStore.prototype = { + __proto__: Store.prototype, + + _remoteClients: {}, + + async create(record) { + await this.update(record); + }, + + async update(record) { + if (record.id == this.engine.localID) { + // Only grab commands from the server; local name/type always wins + this.engine.localCommands = record.commands; + } else { + this._remoteClients[record.id] = record.cleartext; + } + }, + + async createRecord(id, collection) { + let record = new ClientsRec(collection, id); + + const commandsChanges = this.engine._currentlySyncingCommands + ? this.engine._currentlySyncingCommands[id] + : []; + + // Package the individual components into a record for the local client + if (id == this.engine.localID) { + try { + record.fxaDeviceId = await this.engine.fxAccounts.device.getLocalId(); + } catch (error) { + this._log.warn("failed to get fxa device id", error); + } + record.name = this.engine.localName; + record.type = this.engine.localType; + record.version = Services.appinfo.version; + record.protocols = SUPPORTED_PROTOCOL_VERSIONS; + + // Substract the commands we recorded that we've already executed + if ( + commandsChanges && + commandsChanges.length && + this.engine.localCommands && + this.engine.localCommands.length + ) { + record.commands = this.engine.localCommands.filter( + command => !hasDupeCommand(commandsChanges, command) + ); + } + + // Optional fields. + record.os = Services.appinfo.OS; // "Darwin" + record.appPackage = Services.appinfo.ID; + record.application = this.engine.brandName; // "Nightly" + + // We can't compute these yet. + // record.device = ""; // Bug 1100723 + // record.formfactor = ""; // Bug 1100722 + } else { + record.cleartext = Object.assign({}, this._remoteClients[id]); + delete record.cleartext.serverLastModified; // serverLastModified is a local only attribute. + + // Add the commands we have to send + if (commandsChanges && commandsChanges.length) { + const recordCommands = record.cleartext.commands || []; + const newCommands = commandsChanges.filter( + command => !hasDupeCommand(recordCommands, command) + ); + record.cleartext.commands = recordCommands.concat(newCommands); + } + + if (record.cleartext.stale) { + // It's almost certainly a logic error for us to upload a record we + // consider stale, so make log noise, but still remove the flag. + this._log.error( + `Preparing to upload record ${id} that we consider stale` + ); + delete record.cleartext.stale; + } + } + if (record.commands) { + const maxPayloadSize = this.engine.service.getMemcacheMaxRecordPayloadSize(); + let origOrder = new Map(record.commands.map((c, i) => [c, i])); + // we sort first by priority, and second by age (indicated by order in the + // original list) + let commands = record.commands.slice().sort((a, b) => { + let infoA = this.engine._commands[a.command]; + let infoB = this.engine._commands[b.command]; + // Treat unknown command types as highest priority, to allow us to add + // high priority commands in the future without worrying about clients + // removing them on each-other unnecessarially. + let importA = infoA ? infoA.importance : 0; + let importB = infoB ? infoB.importance : 0; + // Higher importantance numbers indicate that we care less, so they + // go to the end of the list where they'll be popped off. + let importDelta = importA - importB; + if (importDelta != 0) { + return importDelta; + } + let origIdxA = origOrder.get(a); + let origIdxB = origOrder.get(b); + // Within equivalent priorities, we put older entries near the end + // of the list, so that they are removed first. + return origIdxB - origIdxA; + }); + let truncatedCommands = Utils.tryFitItems(commands, maxPayloadSize); + if (truncatedCommands.length != record.commands.length) { + this._log.warn( + `Removing commands from client ${id} (from ${record.commands.length} to ${truncatedCommands.length})` + ); + // Restore original order. + record.commands = truncatedCommands.sort( + (a, b) => origOrder.get(a) - origOrder.get(b) + ); + } + } + return record; + }, + + async itemExists(id) { + return id in (await this.getAllIDs()); + }, + + async getAllIDs() { + let ids = {}; + ids[this.engine.localID] = true; + for (let id in this._remoteClients) { + ids[id] = true; + } + return ids; + }, + + async wipe() { + this._remoteClients = {}; + }, +}; + +function ClientsTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +ClientsTracker.prototype = { + __proto__: LegacyTracker.prototype, + + _enabled: false, + + onStart() { + Svc.Obs.add("fxaccounts:new_device_id", this.asyncObserver); + Services.prefs.addObserver( + PREF_ACCOUNT_ROOT + "device.name", + this.asyncObserver + ); + }, + onStop() { + Services.prefs.removeObserver( + PREF_ACCOUNT_ROOT + "device.name", + this.asyncObserver + ); + Svc.Obs.remove("fxaccounts:new_device_id", this.asyncObserver); + }, + + async observe(subject, topic, data) { + switch (topic) { + case "nsPref:changed": + this._log.debug("client.name preference changed"); + // Fallthrough intended. + case "fxaccounts:new_device_id": + await this.addChangedID(this.engine.localID); + this.score += SINGLE_USER_THRESHOLD + 1; // ALWAYS SYNC NOW. + break; + } + }, +}; diff --git a/services/sync/modules/engines/extension-storage.js b/services/sync/modules/engines/extension-storage.js new file mode 100644 index 0000000000..705091957e --- /dev/null +++ b/services/sync/modules/engines/extension-storage.js @@ -0,0 +1,295 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = [ + "ExtensionStorageEngineKinto", + "ExtensionStorageEngineBridge", +]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + BridgedEngine: "resource://services-sync/bridged_engine.js", + LogAdapter: "resource://services-sync/bridged_engine.js", + extensionStorageSync: "resource://gre/modules/ExtensionStorageSync.jsm", + Observers: "resource://services-common/observers.js", + Svc: "resource://services-sync/util.js", + SyncEngine: "resource://services-sync/engines.js", + Tracker: "resource://services-sync/engines.js", + SCORE_INCREMENT_MEDIUM: "resource://services-sync/constants.js", + MULTI_DEVICE_THRESHOLD: "resource://services-sync/constants.js", +}); + +XPCOMUtils.defineLazyModuleGetter( + this, + "extensionStorageSyncKinto", + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + "extensionStorageSync" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "StorageSyncService", + "@mozilla.org/extensions/storage/sync;1", + "nsIInterfaceRequestor" +); + +const PREF_FORCE_ENABLE = "engine.extension-storage.force"; + +// A helper to indicate whether extension-storage is enabled - it's based on +// the "addons" pref. The same logic is shared between both engine impls. +function getEngineEnabled() { + // By default, we sync extension storage if we sync addons. This + // lets us simplify the UX since users probably don't consider + // "extension preferences" a separate category of syncing. + // However, we also respect engine.extension-storage.force, which + // can be set to true or false, if a power user wants to customize + // the behavior despite the lack of UI. + const forced = Svc.Prefs.get(PREF_FORCE_ENABLE, undefined); + if (forced !== undefined) { + return forced; + } + return Svc.Prefs.get("engine.addons", false); +} + +function setEngineEnabled(enabled) { + // This will be called by the engine manager when declined on another device. + // Things will go a bit pear-shaped if the engine manager tries to end up + // with 'addons' and 'extension-storage' in different states - however, this + // *can* happen given we support the `engine.extension-storage.force` + // preference. So if that pref exists, we set it to this value. If that pref + // doesn't exist, we just ignore it and hope that the 'addons' engine is also + // going to be set to the same state. + if (Svc.Prefs.has(PREF_FORCE_ENABLE)) { + Svc.Prefs.set(PREF_FORCE_ENABLE, enabled); + } +} + +// A "bridged engine" to our webext-storage component. +function ExtensionStorageEngineBridge(service) { + let bridge = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + BridgedEngine.call(this, bridge, "Extension-Storage", service); + + let app_services_logger = Cc["@mozilla.org/appservices/logger;1"].getService( + Ci.mozIAppServicesLogger + ); + let logger_target = "app-services:webext_storage:sync"; + app_services_logger.register(logger_target, new LogAdapter(this._log)); +} + +ExtensionStorageEngineBridge.prototype = { + __proto__: BridgedEngine.prototype, + syncPriority: 10, + + // Used to override the engine name in telemetry, so that we can distinguish . + overrideTelemetryName: "rust-webext-storage", + + _notifyPendingChanges() { + return new Promise(resolve => { + this._bridge + .QueryInterface(Ci.mozISyncedExtensionStorageArea) + .fetchPendingSyncChanges({ + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + ]), + onChanged: (extId, json) => { + try { + extensionStorageSync.notifyListeners(extId, JSON.parse(json)); + } catch (ex) { + this._log.warn( + `Error notifying change listeners for ${extId}`, + ex + ); + } + }, + handleSuccess: resolve, + handleError: (code, message) => { + this._log.warn( + "Error fetching pending synced changes", + message, + code + ); + resolve(); + }, + }); + }); + }, + + _takeMigrationInfo() { + return new Promise((resolve, reject) => { + this._bridge + .QueryInterface(Ci.mozIExtensionStorageArea) + .takeMigrationInfo({ + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageCallback", + ]), + handleSuccess: result => { + resolve(result ? JSON.parse(result) : null); + }, + handleError: (code, message) => { + this._log.warn("Error fetching migration info", message, code); + // `takeMigrationInfo` doesn't actually perform the migration, + // just reads (and clears) any data stored in the DB from the + // previous migration. + // + // Any errors here are very likely occurring a good while + // after the migration ran, so we just warn and pretend + // nothing was there. + resolve(null); + }, + }); + }); + }, + + async _syncStartup() { + let result = await super._syncStartup(); + let info = await this._takeMigrationInfo(); + if (info) { + Observers.notify("weave:telemetry:migration", info, "webext-storage"); + } + return result; + }, + + async _processIncoming() { + await super._processIncoming(); + try { + await this._notifyPendingChanges(); + } catch (ex) { + // Failing to notify `storage.onChanged` observers is bad, but shouldn't + // interrupt syncing. + this._log.warn("Error notifying about synced changes", ex); + } + }, + + get enabled() { + return getEngineEnabled(); + }, + set enabled(enabled) { + setEngineEnabled(enabled); + }, +}; + +/** + ***************************************************************************** + * + * Deprecated support for Kinto + * + ***************************************************************************** + */ + +/** + * The Engine that manages syncing for the web extension "storage" + * API, and in particular ext.storage.sync. + * + * ext.storage.sync is implemented using Kinto, so it has mechanisms + * for syncing that we do not need to integrate in the Firefox Sync + * framework, so this is something of a stub. + */ +function ExtensionStorageEngineKinto(service) { + SyncEngine.call(this, "Extension-Storage", service); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_skipPercentageChance", + "services.sync.extension-storage.skipPercentageChance", + 0 + ); +} +ExtensionStorageEngineKinto.prototype = { + __proto__: SyncEngine.prototype, + _trackerObj: ExtensionStorageTracker, + // we don't need these since we implement our own sync logic + _storeObj: undefined, + _recordObj: undefined, + + syncPriority: 10, + allowSkippedRecord: false, + + async _sync() { + return extensionStorageSyncKinto.syncAll(); + }, + + get enabled() { + return getEngineEnabled(); + }, + // We only need the enabled setter for the edge-case where info/collections + // has `extension-storage` - which could happen if the pref to flip the new + // engine on was once set but no longer is. + set enabled(enabled) { + setEngineEnabled(enabled); + }, + + _wipeClient() { + return extensionStorageSyncKinto.clearAll(); + }, + + shouldSkipSync(syncReason) { + if (syncReason == "user" || syncReason == "startup") { + this._log.info( + `Not skipping extension storage sync: reason == ${syncReason}` + ); + // Always sync if a user clicks the button, or if we're starting up. + return false; + } + // Ensure this wouldn't cause a resync... + if (this._tracker.score >= MULTI_DEVICE_THRESHOLD) { + this._log.info( + "Not skipping extension storage sync: Would trigger resync anyway" + ); + return false; + } + + let probability = this._skipPercentageChance / 100.0; + // Math.random() returns a value in the interval [0, 1), so `>` is correct: + // if `probability` is 1 skip every time, and if it's 0, never skip. + let shouldSkip = probability > Math.random(); + + this._log.info( + `Skipping extension-storage sync with a chance of ${probability}: ${shouldSkip}` + ); + return shouldSkip; + }, +}; + +function ExtensionStorageTracker(name, engine) { + Tracker.call(this, name, engine); + this._ignoreAll = false; +} +ExtensionStorageTracker.prototype = { + __proto__: Tracker.prototype, + + get ignoreAll() { + return this._ignoreAll; + }, + + set ignoreAll(value) { + this._ignoreAll = value; + }, + + onStart() { + Svc.Obs.add("ext.storage.sync-changed", this.asyncObserver); + }, + + onStop() { + Svc.Obs.remove("ext.storage.sync-changed", this.asyncObserver); + }, + + async observe(subject, topic, data) { + if (this.ignoreAll) { + return; + } + + if (topic !== "ext.storage.sync-changed") { + return; + } + + // Single adds, removes and changes are not so important on their + // own, so let's just increment score a bit. + this.score += SCORE_INCREMENT_MEDIUM; + }, +}; diff --git a/services/sync/modules/engines/forms.js b/services/sync/modules/engines/forms.js new file mode 100644 index 0000000000..fac1172e00 --- /dev/null +++ b/services/sync/modules/engines/forms.js @@ -0,0 +1,312 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["FormEngine", "FormRec", "FormValidator"]; + +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_MEDIUM } = ChromeUtils.import( + "resource://services-sync/constants.js" +); +const { CollectionProblemData, CollectionValidator } = ChromeUtils.import( + "resource://services-sync/collection_validator.js" +); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +ChromeUtils.defineModuleGetter( + this, + "FormHistory", + "resource://gre/modules/FormHistory.jsm" +); + +const FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds. + +function FormRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} +FormRec.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Form", + ttl: FORMS_TTL, +}; + +Utils.deferGetSet(FormRec, "cleartext", ["name", "value"]); + +var FormWrapper = { + _log: Log.repository.getLogger("Sync.Engine.Forms"), + + _getEntryCols: ["fieldname", "value"], + _guidCols: ["guid"], + + async _search(terms, searchData) { + return new Promise(resolve => { + let results = []; + let callbacks = { + handleResult(result) { + results.push(result); + }, + handleCompletion(reason) { + resolve(results); + }, + }; + FormHistory.search(terms, searchData, callbacks); + }); + }, + + async _update(changes) { + if (!FormHistory.enabled) { + return; // update isn't going to do anything. + } + await new Promise(resolve => { + let callbacks = { + handleCompletion(reason) { + resolve(); + }, + }; + FormHistory.update(changes, callbacks); + }); + }, + + async getEntry(guid) { + let results = await this._search(this._getEntryCols, { guid }); + if (!results.length) { + return null; + } + return { name: results[0].fieldname, value: results[0].value }; + }, + + async getGUID(name, value) { + // Query for the provided entry. + let query = { fieldname: name, value }; + let results = await this._search(this._guidCols, query); + return results.length ? results[0].guid : null; + }, + + async hasGUID(guid) { + // We could probably use a count function here, but search exists... + let results = await this._search(this._guidCols, { guid }); + return results.length != 0; + }, + + async replaceGUID(oldGUID, newGUID) { + let changes = { + op: "update", + guid: oldGUID, + newGuid: newGUID, + }; + await this._update(changes); + }, +}; + +function FormEngine(service) { + SyncEngine.call(this, "Forms", service); +} +FormEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: FormStore, + _trackerObj: FormTracker, + _recordObj: FormRec, + + syncPriority: 6, + + get prefName() { + return "history"; + }, + + async _findDupe(item) { + return FormWrapper.getGUID(item.name, item.value); + }, +}; + +function FormStore(name, engine) { + Store.call(this, name, engine); +} +FormStore.prototype = { + __proto__: Store.prototype, + + async _processChange(change) { + // If this._changes is defined, then we are applying a batch, so we + // can defer it. + if (this._changes) { + this._changes.push(change); + return; + } + + // Otherwise we must handle the change right now. + await FormWrapper._update(change); + }, + + async applyIncomingBatch(records) { + Async.checkAppReady(); + // We collect all the changes to be made then apply them all at once. + this._changes = []; + let failures = await Store.prototype.applyIncomingBatch.call(this, records); + if (this._changes.length) { + await FormWrapper._update(this._changes); + } + delete this._changes; + return failures; + }, + + async getAllIDs() { + let results = await FormWrapper._search(["guid"], []); + let guids = {}; + for (let result of results) { + guids[result.guid] = true; + } + return guids; + }, + + async changeItemID(oldID, newID) { + await FormWrapper.replaceGUID(oldID, newID); + }, + + async itemExists(id) { + return FormWrapper.hasGUID(id); + }, + + async createRecord(id, collection) { + let record = new FormRec(collection, id); + let entry = await FormWrapper.getEntry(id); + if (entry != null) { + record.name = entry.name; + record.value = entry.value; + } else { + record.deleted = true; + } + return record; + }, + + async create(record) { + this._log.trace("Adding form record for " + record.name); + let change = { + op: "add", + guid: record.id, + fieldname: record.name, + value: record.value, + }; + await this._processChange(change); + }, + + async remove(record) { + this._log.trace("Removing form record: " + record.id); + let change = { + op: "remove", + guid: record.id, + }; + await this._processChange(change); + }, + + async update(record) { + this._log.trace("Ignoring form record update request!"); + }, + + async wipe() { + let change = { + op: "remove", + }; + await FormWrapper._update(change); + }, +}; + +function FormTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +FormTracker.prototype = { + __proto__: LegacyTracker.prototype, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + onStart() { + Svc.Obs.add("satchel-storage-changed", this.asyncObserver); + }, + + onStop() { + Svc.Obs.remove("satchel-storage-changed", this.asyncObserver); + }, + + async observe(subject, topic, data) { + if (this.ignoreAll) { + return; + } + switch (topic) { + case "satchel-storage-changed": + if (data == "formhistory-add" || data == "formhistory-remove") { + let guid = subject.QueryInterface(Ci.nsISupportsString).toString(); + await this.trackEntry(guid); + } + break; + } + }, + + async trackEntry(guid) { + const added = await this.addChangedID(guid); + if (added) { + this.score += SCORE_INCREMENT_MEDIUM; + } + }, +}; + +class FormsProblemData extends CollectionProblemData { + getSummary() { + // We don't support syncing deleted form data, so "clientMissing" isn't a problem + return super.getSummary().filter(entry => entry.name !== "clientMissing"); + } +} + +class FormValidator extends CollectionValidator { + constructor() { + super("forms", "id", ["name", "value"]); + this.ignoresMissingClients = true; + } + + emptyProblemData() { + return new FormsProblemData(); + } + + async getClientItems() { + return FormWrapper._search(["guid", "fieldname", "value"], {}); + } + + normalizeClientItem(item) { + return { + id: item.guid, + guid: item.guid, + name: item.fieldname, + fieldname: item.fieldname, + value: item.value, + original: item, + }; + } + + async normalizeServerItem(item) { + let res = Object.assign( + { + guid: item.id, + fieldname: item.name, + original: item, + }, + item + ); + // Missing `name` or `value` causes the getGUID call to throw + if (item.name !== undefined && item.value !== undefined) { + let guid = await FormWrapper.getGUID(item.name, item.value); + if (guid) { + res.guid = guid; + res.id = guid; + res.duped = true; + } + } + + return res; + } +} diff --git a/services/sync/modules/engines/history.js b/services/sync/modules/engines/history.js new file mode 100644 index 0000000000..f2821cfc83 --- /dev/null +++ b/services/sync/modules/engines/history.js @@ -0,0 +1,625 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["HistoryEngine", "HistoryRec"]; + +const HISTORY_TTL = 5184000; // 60 days in milliseconds +const THIRTY_DAYS_IN_MS = 2592000000; // 30 days in milliseconds + +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { + MAX_HISTORY_DOWNLOAD, + MAX_HISTORY_UPLOAD, + SCORE_INCREMENT_SMALL, + SCORE_INCREMENT_XLARGE, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesSyncUtils", + "resource://gre/modules/PlacesSyncUtils.jsm" +); + +function HistoryRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} +HistoryRec.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.History", + ttl: HISTORY_TTL, +}; + +Utils.deferGetSet(HistoryRec, "cleartext", ["histUri", "title", "visits"]); + +function HistoryEngine(service) { + SyncEngine.call(this, "History", service); +} +HistoryEngine.prototype = { + __proto__: SyncEngine.prototype, + _recordObj: HistoryRec, + _storeObj: HistoryStore, + _trackerObj: HistoryTracker, + downloadLimit: MAX_HISTORY_DOWNLOAD, + + syncPriority: 7, + + async getSyncID() { + return PlacesSyncUtils.history.getSyncId(); + }, + + async ensureCurrentSyncID(newSyncID) { + this._log.debug( + "Checking if server sync ID ${newSyncID} matches existing", + { newSyncID } + ); + await PlacesSyncUtils.history.ensureCurrentSyncId(newSyncID); + return newSyncID; + }, + + async resetSyncID() { + // First, delete the collection on the server. It's fine if we're + // interrupted here: on the next sync, we'll detect that our old sync ID is + // now stale, and start over as a first sync. + await this._deleteServerCollection(); + // Then, reset our local sync ID. + return this.resetLocalSyncID(); + }, + + async resetLocalSyncID() { + let newSyncID = await PlacesSyncUtils.history.resetSyncId(); + this._log.debug("Assigned new sync ID ${newSyncID}", { newSyncID }); + return newSyncID; + }, + + async getLastSync() { + let lastSync = await PlacesSyncUtils.history.getLastSync(); + return lastSync; + }, + + async setLastSync(lastSync) { + await PlacesSyncUtils.history.setLastSync(lastSync); + }, + + shouldSyncURL(url) { + return !url.startsWith("file:"); + }, + + async pullNewChanges() { + const changedIDs = await this._tracker.getChangedIDs(); + let modifiedGUIDs = Object.keys(changedIDs); + if (!modifiedGUIDs.length) { + return {}; + } + + let guidsToRemove = await PlacesSyncUtils.history.determineNonSyncableGuids( + modifiedGUIDs + ); + await this._tracker.removeChangedID(...guidsToRemove); + return changedIDs; + }, + + async _resetClient() { + await super._resetClient(); + await PlacesSyncUtils.history.reset(); + }, +}; + +function HistoryStore(name, engine) { + Store.call(this, name, engine); +} + +HistoryStore.prototype = { + __proto__: Store.prototype, + + // We try and only update this many visits at one time. + MAX_VISITS_PER_INSERT: 500, + + // Some helper functions to handle GUIDs + async setGUID(uri, guid) { + if (!guid) { + guid = Utils.makeGUID(); + } + + try { + await PlacesSyncUtils.history.changeGuid(uri, guid); + } catch (e) { + this._log.error("Error setting GUID ${guid} for URI ${uri}", guid, uri); + } + + return guid; + }, + + async GUIDForUri(uri, create) { + // Use the existing GUID if it exists + let guid; + try { + guid = await PlacesSyncUtils.history.fetchGuidForURL(uri); + } catch (e) { + this._log.error("Error fetching GUID for URL ${uri}", uri); + } + + // If the URI has an existing GUID, return it. + if (guid) { + return guid; + } + + // If the URI doesn't have a GUID and we were indicated to create one. + if (create) { + return this.setGUID(uri); + } + + // If the URI doesn't have a GUID and we didn't create one for it. + return null; + }, + + async changeItemID(oldID, newID) { + let info = await PlacesSyncUtils.history.fetchURLInfoForGuid(oldID); + if (!info) { + throw new Error(`Can't change ID for nonexistent history entry ${oldID}`); + } + this.setGUID(info.url, newID); + }, + + async getAllIDs() { + let urls = await PlacesSyncUtils.history.getAllURLs({ + since: new Date(Date.now() - THIRTY_DAYS_IN_MS), + limit: MAX_HISTORY_UPLOAD, + }); + + let urlsByGUID = {}; + for (let url of urls) { + if (!this.engine.shouldSyncURL(url)) { + continue; + } + let guid = await this.GUIDForUri(url, true); + urlsByGUID[guid] = url; + } + return urlsByGUID; + }, + + async applyIncomingBatch(records) { + // Convert incoming records to mozIPlaceInfo objects which are applied as + // either history additions or removals. + let failed = []; + let toAdd = []; + let toRemove = []; + await Async.yieldingForEach(records, async record => { + if (record.deleted) { + toRemove.push(record); + } else { + try { + let pageInfo = await this._recordToPlaceInfo(record); + if (pageInfo) { + toAdd.push(pageInfo); + } + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.error("Failed to create a place info", ex); + this._log.trace("The record that failed", record); + failed.push(record.id); + } + } + }); + if (toAdd.length || toRemove.length) { + // We want to notify history observers that a batch operation is underway + // so they don't do lots of work for each incoming record. + let observers = PlacesUtils.history.getObservers(); + const notifyHistoryObservers = notification => { + for (let observer of observers) { + try { + observer[notification](); + } catch (ex) { + // don't log an error - it's not our code that failed and we don't + // want an error log written just for this. + this._log.info("history observer failed", ex); + } + } + }; + notifyHistoryObservers("onBeginUpdateBatch"); + try { + if (toRemove.length) { + // PlacesUtils.history.remove takes an array of visits to remove, + // but the error semantics are tricky - a single "bad" entry will cause + // an exception before anything is removed. So we do remove them one at + // a time. + await Async.yieldingForEach(toRemove, async record => { + try { + await this.remove(record); + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.error("Failed to delete a place info", ex); + this._log.trace("The record that failed", record); + failed.push(record.id); + } + }); + } + for (let chunk of this._generateChunks(toAdd)) { + // Per bug 1415560, we ignore any exceptions returned by insertMany + // as they are likely to be spurious. We do supply an onError handler + // and log the exceptions seen there as they are likely to be + // informative, but we still never abort the sync based on them. + try { + await PlacesUtils.history.insertMany(chunk, null, failedVisit => { + this._log.info( + "Failed to insert a history record", + failedVisit.guid + ); + this._log.trace("The record that failed", failedVisit); + failed.push(failedVisit.guid); + }); + } catch (ex) { + this._log.info("Failed to insert history records", ex); + } + } + } finally { + notifyHistoryObservers("onEndUpdateBatch"); + } + } + + return failed; + }, + + /** + * Returns a generator that splits records into sanely sized chunks suitable + * for passing to places to prevent places doing bad things at shutdown. + */ + *_generateChunks(records) { + // We chunk based on the number of *visits* inside each record. However, + // we do not split a single record into multiple records, because at some + // time in the future, we intend to ensure these records are ordered by + // lastModified, and advance the engine's timestamp as we process them, + // meaning we can resume exactly where we left off next sync - although + // currently that's not done, so we will retry the entire batch next sync + // if interrupted. + // ie, this means that if a single record has more than MAX_VISITS_PER_INSERT + // visits, we will call insertMany() with exactly 1 record, but with + // more than MAX_VISITS_PER_INSERT visits. + let curIndex = 0; + this._log.debug(`adding ${records.length} records to history`); + while (curIndex < records.length) { + Async.checkAppReady(); // may throw if we are shutting down. + let toAdd = []; // what we are going to insert. + let count = 0; // a counter which tells us when toAdd is full. + do { + let record = records[curIndex]; + curIndex += 1; + toAdd.push(record); + count += record.visits.length; + } while ( + curIndex < records.length && + count + records[curIndex].visits.length <= this.MAX_VISITS_PER_INSERT + ); + this._log.trace(`adding ${toAdd.length} items in this chunk`); + yield toAdd; + } + }, + + /* An internal helper to determine if we can add an entry to places. + Exists primarily so tests can override it. + */ + _canAddURI(uri) { + return PlacesUtils.history.canAddURI(uri); + }, + + /** + * Converts a Sync history record to a mozIPlaceInfo. + * + * Throws if an invalid record is encountered (invalid URI, etc.), + * returns a new PageInfo object if the record is to be applied, null + * otherwise (no visits to add, etc.), + */ + async _recordToPlaceInfo(record) { + // Sort out invalid URIs and ones Places just simply doesn't want. + record.url = PlacesUtils.normalizeToURLOrGUID(record.histUri); + record.uri = CommonUtils.makeURI(record.histUri); + + if (!Utils.checkGUID(record.id)) { + this._log.warn("Encountered record with invalid GUID: " + record.id); + return null; + } + record.guid = record.id; + + if ( + !this._canAddURI(record.uri) || + !this.engine.shouldSyncURL(record.uri.spec) + ) { + this._log.trace( + "Ignoring record " + + record.id + + " with URI " + + record.uri.spec + + ": can't add this URI." + ); + return null; + } + + // We dupe visits by date and type. So an incoming visit that has + // the same timestamp and type as a local one won't get applied. + // To avoid creating new objects, we rewrite the query result so we + // can simply check for containment below. + let curVisitsAsArray = []; + let curVisits = new Set(); + try { + curVisitsAsArray = await PlacesSyncUtils.history.fetchVisitsForURL( + record.histUri + ); + } catch (e) { + this._log.error( + "Error while fetching visits for URL ${record.histUri}", + record.histUri + ); + } + let oldestAllowed = PlacesSyncUtils.bookmarks.EARLIEST_BOOKMARK_TIMESTAMP; + if (curVisitsAsArray.length == 20) { + let oldestVisit = curVisitsAsArray[curVisitsAsArray.length - 1]; + oldestAllowed = PlacesSyncUtils.history.clampVisitDate( + PlacesUtils.toDate(oldestVisit.date).getTime() + ); + } + + let i, k; + for (i = 0; i < curVisitsAsArray.length; i++) { + // Same logic as used in the loop below to generate visitKey. + let { date, type } = curVisitsAsArray[i]; + let dateObj = PlacesUtils.toDate(date); + let millis = PlacesSyncUtils.history.clampVisitDate(dateObj).getTime(); + curVisits.add(`${millis},${type}`); + } + + // Walk through the visits, make sure we have sound data, and eliminate + // dupes. The latter is done by rewriting the array in-place. + for (i = 0, k = 0; i < record.visits.length; i++) { + let visit = (record.visits[k] = record.visits[i]); + + if ( + !visit.date || + typeof visit.date != "number" || + !Number.isInteger(visit.date) + ) { + this._log.warn( + "Encountered record with invalid visit date: " + visit.date + ); + continue; + } + + if ( + !visit.type || + !Object.values(PlacesUtils.history.TRANSITIONS).includes(visit.type) + ) { + this._log.warn( + "Encountered record with invalid visit type: " + + visit.type + + "; ignoring." + ); + continue; + } + + // Dates need to be integers. Future and far past dates are clamped to the + // current date and earliest sensible date, respectively. + let originalVisitDate = PlacesUtils.toDate(Math.round(visit.date)); + visit.date = PlacesSyncUtils.history.clampVisitDate(originalVisitDate); + + if (visit.date.getTime() < oldestAllowed) { + // Visit is older than the oldest visit we have, and we have so many + // visits for this uri that we hit our limit when inserting. + continue; + } + let visitKey = `${visit.date.getTime()},${visit.type}`; + if (curVisits.has(visitKey)) { + // Visit is a dupe, don't increment 'k' so the element will be + // overwritten. + continue; + } + + // Note the visit key, so that we don't add duplicate visits with + // clamped timestamps. + curVisits.add(visitKey); + + visit.transition = visit.type; + k += 1; + } + record.visits.length = k; // truncate array + + // No update if there aren't any visits to apply. + // History wants at least one visit. + // In any case, the only thing we could change would be the title + // and that shouldn't change without a visit. + if (!record.visits.length) { + this._log.trace( + "Ignoring record " + + record.id + + " with URI " + + record.uri.spec + + ": no visits to add." + ); + return null; + } + + // PageInfo is validated using validateItemProperties which does a shallow + // copy of the properties. Since record uses getters some of the properties + // are not copied over. Thus we create and return a new object. + let pageInfo = { + title: record.title, + url: record.url, + guid: record.guid, + visits: record.visits, + }; + + return pageInfo; + }, + + async remove(record) { + this._log.trace("Removing page: " + record.id); + let removed = await PlacesUtils.history.remove(record.id); + if (removed) { + this._log.trace("Removed page: " + record.id); + } else { + this._log.debug("Page already removed: " + record.id); + } + }, + + async itemExists(id) { + return !!(await PlacesSyncUtils.history.fetchURLInfoForGuid(id)); + }, + + async createRecord(id, collection) { + let foo = await PlacesSyncUtils.history.fetchURLInfoForGuid(id); + let record = new HistoryRec(collection, id); + if (foo) { + record.histUri = foo.url; + record.title = foo.title; + record.sortindex = foo.frecency; + try { + record.visits = await PlacesSyncUtils.history.fetchVisitsForURL( + record.histUri + ); + } catch (e) { + this._log.error( + "Error while fetching visits for URL ${record.histUri}", + record.histUri + ); + record.visits = []; + } + } else { + record.deleted = true; + } + + return record; + }, + + async wipe() { + return PlacesSyncUtils.history.wipe(); + }, +}; + +function HistoryTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +HistoryTracker.prototype = { + __proto__: LegacyTracker.prototype, + + onStart() { + this._log.info("Adding Places observer."); + PlacesUtils.history.addObserver(this, true); + this._placesObserver = new PlacesWeakCallbackWrapper( + this.handlePlacesEvents.bind(this) + ); + PlacesObservers.addListener( + ["page-visited", "history-cleared"], + this._placesObserver + ); + }, + + onStop() { + this._log.info("Removing Places observer."); + PlacesUtils.history.removeObserver(this); + if (this._placesObserver) { + PlacesObservers.removeListener( + ["page-visited", "history-cleared"], + this._placesObserver + ); + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsINavHistoryObserver", + "nsISupportsWeakReference", + ]), + + async onDeleteAffectsGUID(uri, guid, reason, source, increment) { + if (this.ignoreAll || reason == Ci.nsINavHistoryObserver.REASON_EXPIRED) { + return; + } + this._log.trace(source + ": " + uri.spec + ", reason " + reason); + const added = await this.addChangedID(guid); + if (added) { + this.score += increment; + } + }, + + onDeleteVisits(uri, partialRemoval, guid, reason) { + this.asyncObserver.enqueueCall(() => + this.onDeleteAffectsGUID( + uri, + guid, + reason, + "onDeleteVisits", + SCORE_INCREMENT_SMALL + ) + ); + }, + + onDeleteURI(uri, guid, reason) { + this.asyncObserver.enqueueCall(() => + this.onDeleteAffectsGUID( + uri, + guid, + reason, + "onDeleteURI", + SCORE_INCREMENT_XLARGE + ) + ); + }, + + handlePlacesEvents(aEvents) { + this.asyncObserver.enqueueCall(() => this._handlePlacesEvents(aEvents)); + }, + + async _handlePlacesEvents(aEvents) { + if (this.ignoreAll) { + this._log.trace( + "ignoreAll: ignoring visits [" + + aEvents.map(v => v.guid).join(",") + + "]" + ); + return; + } + for (let event of aEvents) { + switch (event.type) { + case "page-visited": { + this._log.trace("'page-visited': " + event.url); + if ( + this.engine.shouldSyncURL(event.url) && + (await this.addChangedID(event.pageGuid)) + ) { + this.score += SCORE_INCREMENT_SMALL; + } + break; + } + case "history-cleared": { + this._log.trace("history-cleared"); + // Note that we're going to trigger a sync, but none of the cleared + // pages are tracked, so the deletions will not be propagated. + // See Bug 578694. + this.score += SCORE_INCREMENT_XLARGE; + break; + } + } + } + }, + + onBeginUpdateBatch() {}, + onEndUpdateBatch() {}, + onBeforeDeleteURI() {}, +}; diff --git a/services/sync/modules/engines/passwords.js b/services/sync/modules/engines/passwords.js new file mode 100644 index 0000000000..5b6d2ab408 --- /dev/null +++ b/services/sync/modules/engines/passwords.js @@ -0,0 +1,537 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["PasswordEngine", "LoginRec", "PasswordValidator"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Collection, CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); +const { CollectionValidator } = ChromeUtils.import( + "resource://services-sync/collection_validator.js" +); +const { Store, SyncEngine, LegacyTracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); + +const SYNCABLE_LOGIN_FIELDS = [ + // `nsILoginInfo` fields. + "hostname", + "formSubmitURL", + "httpRealm", + "username", + "password", + "usernameField", + "passwordField", + + // `nsILoginMetaInfo` fields. + "timeCreated", + "timePasswordChanged", +]; + +// Compares two logins to determine if their syncable fields changed. The login +// manager fires `modifyLogin` for changes to all fields, including ones we +// don't sync. In particular, `timeLastUsed` changes shouldn't mark the login +// for upload; otherwise, we might overwrite changed passwords before they're +// downloaded (bug 973166). +function isSyncableChange(oldLogin, newLogin) { + oldLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); + newLogin.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo); + for (let property of SYNCABLE_LOGIN_FIELDS) { + if (oldLogin[property] != newLogin[property]) { + return true; + } + } + return false; +} + +function LoginRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} +LoginRec.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Login", + + cleartextToString() { + let o = Object.assign({}, this.cleartext); + if (o.password) { + o.password = "X".repeat(o.password.length); + } + return JSON.stringify(o); + }, +}; + +Utils.deferGetSet(LoginRec, "cleartext", [ + "hostname", + "formSubmitURL", + "httpRealm", + "username", + "password", + "usernameField", + "passwordField", + "timeCreated", + "timePasswordChanged", +]); + +function PasswordEngine(service) { + SyncEngine.call(this, "Passwords", service); +} +PasswordEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: PasswordStore, + _trackerObj: PasswordTracker, + _recordObj: LoginRec, + + syncPriority: 2, + + // Metadata for syncing is stored in the login manager. We also migrate it + // from preferences which can be removed eventually via bug 1651568. Note that + // we don't support profile downgrades - once it's migrated, the login manager + // becomes the single source of truth. + // Note also that the syncID is stored encrypted and null is returned if it + // can't be decrypted - this is done for that 'return null' side-effect rather + // than due to privacy - we want failure to decrypt the store to be treated as + // an engine reset. + async getSyncID() { + let legacyValue = this._syncID; // the base preference getter. + if (legacyValue) { + await Services.logins.setSyncID(legacyValue); + Svc.Prefs.reset(this.name + ".syncID"); + this._log.debug(`migrated syncID of ${legacyValue} to the logins store`); + return legacyValue; + } + return Services.logins.getSyncID(); + }, + + async ensureCurrentSyncID(newSyncID) { + // getSyncID above really only exists for this function - the rest of sync + // has already moved away from it, and even our tests barely use it. + // When we remove the migration code (bug 1651568) we should consider + // removing getSyncID() from both here and the login manager, and pushing + // this ensureCurrentSyncID() function down into the login manager. + let existingSyncID = await this.getSyncID(); + if (existingSyncID == newSyncID) { + return existingSyncID; + } + this._log.debug("Engine syncIDs: " + [newSyncID, existingSyncID]); + + await Services.logins.setSyncID(newSyncID); + await Services.logins.setLastSync(0); + return newSyncID; + }, + + async getLastSync() { + let legacyValue = await super.getLastSync(); + if (legacyValue) { + await this.setLastSync(legacyValue); + Svc.Prefs.reset(this.name + ".lastSync"); + this._log.debug( + `migrated timestamp of ${legacyValue} to the logins store` + ); + return legacyValue; + } + return Services.logins.getLastSync(); + }, + + async setLastSync(timestamp) { + await Services.logins.setLastSync(timestamp); + }, + + async _syncFinish() { + await SyncEngine.prototype._syncFinish.call(this); + + // Delete the Weave credentials from the server once. + if (!Svc.Prefs.get("deletePwdFxA", false)) { + try { + let ids = []; + for (let host of Utils.getSyncCredentialsHosts()) { + for (let info of Services.logins.findLogins(host, "", "")) { + ids.push(info.QueryInterface(Ci.nsILoginMetaInfo).guid); + } + } + if (ids.length) { + let coll = new Collection(this.engineURL, null, this.service); + coll.ids = ids; + let ret = await coll.delete(); + this._log.debug("Delete result: " + ret); + if (!ret.success && ret.status != 400) { + // A non-400 failure means try again next time. + return; + } + } else { + this._log.debug("Didn't find any passwords to delete"); + } + // If there were no ids to delete, or we succeeded, or got a 400, + // record success. + Svc.Prefs.set("deletePwdFxA", true); + Svc.Prefs.reset("deletePwd"); // The old prefname we previously used. + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.debug("Password deletes failed", ex); + } + } + }, + + async _findDupe(item) { + let login = this._store._nsLoginInfoFromRecord(item); + if (!login) { + return null; + } + + let logins = Services.logins.findLogins( + login.origin, + login.formActionOrigin, + login.httpRealm + ); + + await Async.promiseYield(); // Yield back to main thread after synchronous operation. + + // Look for existing logins that match the origin, but ignore the password. + for (let local of logins) { + if (login.matches(local, true) && local instanceof Ci.nsILoginMetaInfo) { + return local.guid; + } + } + + return null; + }, + + async pullAllChanges() { + let changes = {}; + let ids = await this._store.getAllIDs(); + for (let [id, info] of Object.entries(ids)) { + changes[id] = info.timePasswordChanged / 1000; + } + return changes; + }, + + getValidator() { + return new PasswordValidator(); + }, +}; + +function PasswordStore(name, engine) { + Store.call(this, name, engine); + this._nsLoginInfo = new Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + Ci.nsILoginInfo, + "init" + ); +} +PasswordStore.prototype = { + __proto__: Store.prototype, + + _newPropertyBag() { + return Cc["@mozilla.org/hash-property-bag;1"].createInstance( + Ci.nsIWritablePropertyBag2 + ); + }, + + /** + * Return an instance of nsILoginInfo (and, implicitly, nsILoginMetaInfo). + */ + _nsLoginInfoFromRecord(record) { + function nullUndefined(x) { + return x == undefined ? null : x; + } + + function stringifyNullUndefined(x) { + return x == undefined || x == null ? "" : x; + } + + if (record.formSubmitURL && record.httpRealm) { + this._log.warn( + "Record " + + record.id + + " has both formSubmitURL and httpRealm. Skipping." + ); + return null; + } + + // Passing in "undefined" results in an empty string, which later + // counts as a value. Explicitly `|| null` these fields according to JS + // truthiness. Records with empty strings or null will be unmolested. + let info = new this._nsLoginInfo( + record.hostname, + nullUndefined(record.formSubmitURL), + nullUndefined(record.httpRealm), + stringifyNullUndefined(record.username), + record.password, + record.usernameField, + record.passwordField + ); + + info.QueryInterface(Ci.nsILoginMetaInfo); + info.guid = record.id; + if (record.timeCreated && !isNaN(new Date(record.timeCreated).getTime())) { + info.timeCreated = record.timeCreated; + } + if ( + record.timePasswordChanged && + !isNaN(new Date(record.timePasswordChanged).getTime()) + ) { + info.timePasswordChanged = record.timePasswordChanged; + } + + return info; + }, + + async _getLoginFromGUID(id) { + let prop = this._newPropertyBag(); + prop.setPropertyAsAUTF8String("guid", id); + + let logins = Services.logins.searchLogins(prop); + await Async.promiseYield(); // Yield back to main thread after synchronous operation. + + if (logins.length > 0) { + this._log.trace(logins.length + " items matching " + id + " found."); + return logins[0]; + } + + this._log.trace("No items matching " + id + " found. Ignoring"); + return null; + }, + + async getAllIDs() { + let items = {}; + let logins = Services.logins.getAllLogins(); + + for (let i = 0; i < logins.length; i++) { + // Skip over Weave password/passphrase entries. + let metaInfo = logins[i].QueryInterface(Ci.nsILoginMetaInfo); + if (Utils.getSyncCredentialsHosts().has(metaInfo.origin)) { + continue; + } + + items[metaInfo.guid] = metaInfo; + } + + return items; + }, + + async changeItemID(oldID, newID) { + this._log.trace("Changing item ID: " + oldID + " to " + newID); + + let oldLogin = await this._getLoginFromGUID(oldID); + if (!oldLogin) { + this._log.trace("Can't change item ID: item doesn't exist"); + return; + } + if (await this._getLoginFromGUID(newID)) { + this._log.trace("Can't change item ID: new ID already in use"); + return; + } + + let prop = this._newPropertyBag(); + prop.setPropertyAsAUTF8String("guid", newID); + + Services.logins.modifyLogin(oldLogin, prop); + }, + + async itemExists(id) { + return !!(await this._getLoginFromGUID(id)); + }, + + async createRecord(id, collection) { + let record = new LoginRec(collection, id); + let login = await this._getLoginFromGUID(id); + + if (!login) { + record.deleted = true; + return record; + } + + record.hostname = login.origin; + record.formSubmitURL = login.formActionOrigin; + record.httpRealm = login.httpRealm; + record.username = login.username; + record.password = login.password; + record.usernameField = login.usernameField; + record.passwordField = login.passwordField; + + // Optional fields. + login.QueryInterface(Ci.nsILoginMetaInfo); + record.timeCreated = login.timeCreated; + record.timePasswordChanged = login.timePasswordChanged; + + return record; + }, + + async create(record) { + let login = this._nsLoginInfoFromRecord(record); + if (!login) { + return; + } + + this._log.trace("Adding login for " + record.hostname); + this._log.trace( + "httpRealm: " + + JSON.stringify(login.httpRealm) + + "; " + + "formSubmitURL: " + + JSON.stringify(login.formActionOrigin) + ); + Services.logins.addLogin(login); + }, + + async remove(record) { + this._log.trace("Removing login " + record.id); + + let loginItem = await this._getLoginFromGUID(record.id); + if (!loginItem) { + this._log.trace("Asked to remove record that doesn't exist, ignoring"); + return; + } + + Services.logins.removeLogin(loginItem); + }, + + async update(record) { + let loginItem = await this._getLoginFromGUID(record.id); + if (!loginItem) { + this._log.trace("Skipping update for unknown item: " + record.hostname); + return; + } + + this._log.trace("Updating " + record.hostname); + let newinfo = this._nsLoginInfoFromRecord(record); + if (!newinfo) { + return; + } + + Services.logins.modifyLogin(loginItem, newinfo); + }, + + async wipe() { + Services.logins.removeAllUserFacingLogins(); + }, +}; + +function PasswordTracker(name, engine) { + LegacyTracker.call(this, name, engine); +} +PasswordTracker.prototype = { + __proto__: LegacyTracker.prototype, + + onStart() { + Svc.Obs.add("passwordmgr-storage-changed", this.asyncObserver); + }, + + onStop() { + Svc.Obs.remove("passwordmgr-storage-changed", this.asyncObserver); + }, + + async observe(subject, topic, data) { + if (this.ignoreAll) { + return; + } + + // A single add, remove or change or removing all items + // will trigger a sync for MULTI_DEVICE. + switch (data) { + case "modifyLogin": { + subject.QueryInterface(Ci.nsIArrayExtensions); + let oldLogin = subject.GetElementAt(0); + let newLogin = subject.GetElementAt(1); + if (!isSyncableChange(oldLogin, newLogin)) { + this._log.trace(`${data}: Ignoring change for ${newLogin.guid}`); + break; + } + const tracked = await this._trackLogin(newLogin); + if (tracked) { + this._log.trace(`${data}: Tracking change for ${newLogin.guid}`); + } + break; + } + + case "addLogin": + case "removeLogin": + subject + .QueryInterface(Ci.nsILoginMetaInfo) + .QueryInterface(Ci.nsILoginInfo); + const tracked = await this._trackLogin(subject); + if (tracked) { + this._log.trace(data + ": " + subject.guid); + } + break; + + // Bug 1613620: We iterate through the removed logins and track them to ensure + // the logins are deleted across synced devices/accounts + case "removeAllLogins": + subject.QueryInterface(Ci.nsIArrayExtensions); + let count = subject.Count(); + for (let i = 0; i < count; i++) { + let currentSubject = subject.GetElementAt(i); + let tracked = await this._trackLogin(currentSubject); + if (tracked) { + this._log.trace(data + ": " + currentSubject.guid); + } + } + this.score += SCORE_INCREMENT_XLARGE; + break; + } + }, + + async _trackLogin(login) { + if (Utils.getSyncCredentialsHosts().has(login.origin)) { + // Skip over Weave password/passphrase changes. + return false; + } + const added = await this.addChangedID(login.guid); + if (!added) { + return false; + } + this.score += SCORE_INCREMENT_XLARGE; + return true; + }, +}; + +class PasswordValidator extends CollectionValidator { + constructor() { + super("passwords", "id", [ + "hostname", + "formSubmitURL", + "httpRealm", + "password", + "passwordField", + "username", + "usernameField", + ]); + } + + getClientItems() { + let logins = Services.logins.getAllLogins(); + let syncHosts = Utils.getSyncCredentialsHosts(); + let result = logins + .map(l => l.QueryInterface(Ci.nsILoginMetaInfo)) + .filter(l => !syncHosts.has(l.origin)); + return Promise.resolve(result); + } + + normalizeClientItem(item) { + return { + id: item.guid, + guid: item.guid, + hostname: item.hostname, + formSubmitURL: item.formSubmitURL, + httpRealm: item.httpRealm, + password: item.password, + passwordField: item.passwordField, + username: item.username, + usernameField: item.usernameField, + original: item, + }; + } + + async normalizeServerItem(item) { + return Object.assign({ guid: item.id }, item); + } +} diff --git a/services/sync/modules/engines/prefs.js b/services/sync/modules/engines/prefs.js new file mode 100644 index 0000000000..e4a3b99b29 --- /dev/null +++ b/services/sync/modules/engines/prefs.js @@ -0,0 +1,436 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["PrefsEngine", "PrefRec", "PREFS_GUID"]; + +const PREF_SYNC_PREFS_PREFIX = "services.sync.prefs.sync."; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +const { Store, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import( + "resource://services-sync/constants.js" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); + +XPCOMUtils.defineLazyGetter(this, "PREFS_GUID", () => + CommonUtils.encodeBase64URL(Services.appinfo.ID) +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +// In bug 1538015, we decided that it isn't always safe to allow all "incoming" +// preferences to be applied locally. So we have introduced another preference, +// which if false (the default) will ignore all incoming preferences which don't +// already have the "control" preference locally set. If this new +// preference is set to true, then we continue our old behavior of allowing all +// preferences to be updated, even those which don't already have a local +// "control" pref. +const PREF_SYNC_PREFS_ARBITRARY = + "services.sync.prefs.dangerously_allow_arbitrary"; + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "ALLOW_ARBITRARY", + PREF_SYNC_PREFS_ARBITRARY +); + +// The SUMO supplied URL we log with more information about how custom prefs can +// continue to be synced. SUMO have told us that this URL will remain "stable". +const PREFS_DOC_URL_TEMPLATE = + "https://support.mozilla.org/1/firefox/%VERSION%/%OS%/%LOCALE%/sync-custom-preferences"; +XPCOMUtils.defineLazyGetter(this, "PREFS_DOC_URL", () => + Services.urlFormatter.formatURL(PREFS_DOC_URL_TEMPLATE) +); + +// Check for a local control pref or PREF_SYNC_PREFS_ARBITRARY +this.isAllowedPrefName = function(prefName) { + if (prefName == PREF_SYNC_PREFS_ARBITRARY) { + return false; // never allow this. + } + if (ALLOW_ARBITRARY) { + // user has set the "dangerous" pref, so everything is allowed. + return true; + } + // The pref must already have a control pref set, although it doesn't matter + // here whether that value is true or false. We can't use prefHasUserValue + // here because we also want to check prefs still with default values. + try { + Services.prefs.getBoolPref(PREF_SYNC_PREFS_PREFIX + prefName); + // pref exists! + return true; + } catch (_) { + return false; + } +}; + +function PrefRec(collection, id) { + CryptoWrapper.call(this, collection, id); +} +PrefRec.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Pref", +}; + +Utils.deferGetSet(PrefRec, "cleartext", ["value"]); + +function PrefsEngine(service) { + SyncEngine.call(this, "Prefs", service); +} +PrefsEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: PrefStore, + _trackerObj: PrefTracker, + _recordObj: PrefRec, + version: 2, + + syncPriority: 1, + allowSkippedRecord: false, + + async getChangedIDs() { + // No need for a proper timestamp (no conflict resolution needed). + let changedIDs = {}; + if (this._tracker.modified) { + changedIDs[PREFS_GUID] = 0; + } + return changedIDs; + }, + + async _wipeClient() { + await SyncEngine.prototype._wipeClient.call(this); + this.justWiped = true; + }, + + async _reconcile(item) { + // Apply the incoming item if we don't care about the local data + if (this.justWiped) { + this.justWiped = false; + return true; + } + return SyncEngine.prototype._reconcile.call(this, item); + }, + + async trackRemainingChanges() { + if (this._modified.count() > 0) { + this._tracker.modified = true; + } + }, +}; + +// We don't use services.sync.engine.tabs.filteredUrls since it includes +// about: pages and the like, which we want to be syncable in preferences. +// Blob and moz-extension uris are never safe to sync, so we limit our check +// to those. +const UNSYNCABLE_URL_REGEXP = /^(moz-extension|blob):/i; +function isUnsyncableURLPref(prefName) { + if (Services.prefs.getPrefType(prefName) != Ci.nsIPrefBranch.PREF_STRING) { + return false; + } + const prefValue = Services.prefs.getStringPref(prefName, ""); + return UNSYNCABLE_URL_REGEXP.test(prefValue); +} + +function PrefStore(name, engine) { + Store.call(this, name, engine); + Svc.Obs.add( + "profile-before-change", + function() { + this.__prefs = null; + }, + this + ); +} +PrefStore.prototype = { + __proto__: Store.prototype, + + __prefs: null, + get _prefs() { + if (!this.__prefs) { + this.__prefs = new Preferences(); + } + return this.__prefs; + }, + + _getSyncPrefs() { + let syncPrefs = Services.prefs + .getBranch(PREF_SYNC_PREFS_PREFIX) + .getChildList("") + .filter(pref => isAllowedPrefName(pref) && !isUnsyncableURLPref(pref)); + // Also sync preferences that determine which prefs get synced. + let controlPrefs = syncPrefs.map(pref => PREF_SYNC_PREFS_PREFIX + pref); + return controlPrefs.concat(syncPrefs); + }, + + _isSynced(pref) { + if (pref.startsWith(PREF_SYNC_PREFS_PREFIX)) { + // this is an incoming control pref, which is ignored if there's not already + // a local control pref for the preference. + let controlledPref = pref.slice(PREF_SYNC_PREFS_PREFIX.length); + return isAllowedPrefName(controlledPref); + } + + // This is the pref itself - it must be both allowed, and have a control + // pref which is true. + if (!this._prefs.get(PREF_SYNC_PREFS_PREFIX + pref, false)) { + return false; + } + return isAllowedPrefName(pref); + }, + + _getAllPrefs() { + let values = {}; + for (let pref of this._getSyncPrefs()) { + // Note: _isSynced doesn't call isUnsyncableURLPref since it would cause + // us not to apply (syncable) changes to preferences that are set locally + // which have unsyncable urls. + if (this._isSynced(pref) && !isUnsyncableURLPref(pref)) { + // Missing and default prefs get the null value. + values[pref] = this._prefs.isSet(pref) + ? this._prefs.get(pref, null) + : null; + } + } + return values; + }, + + _setAllPrefs(values) { + const selectedThemeIDPref = "extensions.activeThemeID"; + let selectedThemeIDBefore = this._prefs.get(selectedThemeIDPref, null); + let selectedThemeIDAfter = selectedThemeIDBefore; + + // Update 'services.sync.prefs.sync.foo.pref' before 'foo.pref', otherwise + // _isSynced returns false when 'foo.pref' doesn't exist (e.g., on a new device). + let prefs = Object.keys(values).sort( + a => -a.indexOf(PREF_SYNC_PREFS_PREFIX) + ); + for (let pref of prefs) { + let value = values[pref]; + if (!this._isSynced(pref)) { + // An extra complication just so we can warn when we decline to sync a + // preference due to no local control pref. + if (!pref.startsWith(PREF_SYNC_PREFS_PREFIX)) { + // this is an incoming pref - if the incoming value is not null and + // there's no local control pref, then it means we would have previously + // applied a value, but now will decline to. + // We need to check this here rather than in _isSynced because the + // default list of prefs we sync has changed, so we don't want to report + // this message when we wouldn't have actually applied a value. + // We should probably remove all of this in ~ Firefox 80. + if (value !== null) { + // null means "use the default value" + let controlPref = PREF_SYNC_PREFS_PREFIX + pref; + let controlPrefExists; + try { + Services.prefs.getBoolPref(controlPref); + controlPrefExists = true; + } catch (ex) { + controlPrefExists = false; + } + if (!controlPrefExists) { + // This is a long message and written to both the sync log and the + // console, but note that users who have not customized the control + // prefs will never see this. + let msg = + `Not syncing the preference '${pref}' because it has no local ` + + `control preference (${PREF_SYNC_PREFS_PREFIX}${pref}) and ` + + `the preference ${PREF_SYNC_PREFS_ARBITRARY} isn't true. ` + + `See ${PREFS_DOC_URL} for more information`; + console.warn(msg); + this._log.warn(msg); + } + } + } + continue; + } + + if (typeof value == "string" && UNSYNCABLE_URL_REGEXP.test(value)) { + this._log.trace(`Skipping incoming unsyncable url for pref: ${pref}`); + continue; + } + + switch (pref) { + // Some special prefs we don't want to set directly. + case selectedThemeIDPref: + selectedThemeIDAfter = value; + break; + + // default is to just set the pref + default: + if (value == null) { + // Pref has gone missing. The best we can do is reset it. + this._prefs.reset(pref); + } else { + try { + this._prefs.set(pref, value); + } catch (ex) { + this._log.trace(`Failed to set pref: ${pref}`, ex); + } + } + } + } + // Themes are a little messy. Themes which have been installed are handled + // by the addons engine - but default themes aren't seen by that engine. + // So if there's a new default theme ID and that ID corresponds to a + // system addon, then we arrange to enable that addon here. + if (selectedThemeIDBefore != selectedThemeIDAfter) { + this._maybeEnableBuiltinTheme(selectedThemeIDAfter).catch(e => { + this._log.error("Failed to maybe update the default theme", e); + }); + } + }, + + async _maybeEnableBuiltinTheme(themeId) { + let addon = null; + try { + addon = await AddonManager.getAddonByID(themeId); + } catch (ex) { + this._log.trace( + `There's no addon with ID '${themeId} - it can't be a builtin theme` + ); + return; + } + if (addon && addon.isBuiltin && addon.type == "theme") { + this._log.trace(`Enabling builtin theme '${themeId}'`); + await addon.enable(); + } else { + this._log.trace( + `Have incoming theme ID of '${themeId}' but it's not a builtin theme` + ); + } + }, + + async getAllIDs() { + /* We store all prefs in just one WBO, with just one GUID */ + let allprefs = {}; + allprefs[PREFS_GUID] = true; + return allprefs; + }, + + async changeItemID(oldID, newID) { + this._log.trace("PrefStore GUID is constant!"); + }, + + async itemExists(id) { + return id === PREFS_GUID; + }, + + async createRecord(id, collection) { + let record = new PrefRec(collection, id); + + if (id == PREFS_GUID) { + record.value = this._getAllPrefs(); + } else { + record.deleted = true; + } + + return record; + }, + + async create(record) { + this._log.trace("Ignoring create request"); + }, + + async remove(record) { + this._log.trace("Ignoring remove request"); + }, + + async update(record) { + // Silently ignore pref updates that are for other apps. + if (record.id != PREFS_GUID) { + return; + } + + this._log.trace("Received pref updates, applying..."); + this._setAllPrefs(record.value); + }, + + async wipe() { + this._log.trace("Ignoring wipe request"); + }, +}; + +function PrefTracker(name, engine) { + Tracker.call(this, name, engine); + this._ignoreAll = false; + Svc.Obs.add("profile-before-change", this.asyncObserver); +} +PrefTracker.prototype = { + __proto__: Tracker.prototype, + + get ignoreAll() { + return this._ignoreAll; + }, + + set ignoreAll(value) { + this._ignoreAll = value; + }, + + get modified() { + return Svc.Prefs.get("engine.prefs.modified", false); + }, + set modified(value) { + Svc.Prefs.set("engine.prefs.modified", value); + }, + + clearChangedIDs: function clearChangedIDs() { + this.modified = false; + }, + + __prefs: null, + get _prefs() { + if (!this.__prefs) { + this.__prefs = new Preferences(); + } + return this.__prefs; + }, + + onStart() { + Services.prefs.addObserver("", this.asyncObserver); + }, + + onStop() { + this.__prefs = null; + Services.prefs.removeObserver("", this.asyncObserver); + }, + + async observe(subject, topic, data) { + switch (topic) { + case "profile-before-change": + await this.stop(); + break; + case "nsPref:changed": + if (this.ignoreAll) { + break; + } + // Trigger a sync for MULTI-DEVICE for a change that determines + // which prefs are synced or a regular pref change. + if ( + data.indexOf(PREF_SYNC_PREFS_PREFIX) == 0 || + this._prefs.get(PREF_SYNC_PREFS_PREFIX + data, false) + ) { + this.score += SCORE_INCREMENT_XLARGE; + this.modified = true; + this._log.trace("Preference " + data + " changed"); + } + break; + } + }, +}; diff --git a/services/sync/modules/engines/tabs.js b/services/sync/modules/engines/tabs.js new file mode 100644 index 0000000000..99ff59ca8b --- /dev/null +++ b/services/sync/modules/engines/tabs.js @@ -0,0 +1,395 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["TabEngine", "TabSetRecord"]; + +const TABS_TTL = 31622400; // 366 days (1 leap year). +const TAB_ENTRIES_LIMIT = 5; // How many URLs to include in tab history. + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Store, SyncEngine, Tracker } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { CryptoWrapper } = ChromeUtils.import( + "resource://services-sync/record.js" +); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { SCORE_INCREMENT_SMALL, URI_LENGTH_MAX } = ChromeUtils.import( + "resource://services-sync/constants.js" +); + +ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "SessionStore", + "resource:///modules/sessionstore/SessionStore.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); + +function TabSetRecord(collection, id) { + CryptoWrapper.call(this, collection, id); +} +TabSetRecord.prototype = { + __proto__: CryptoWrapper.prototype, + _logName: "Sync.Record.Tabs", + ttl: TABS_TTL, +}; + +Utils.deferGetSet(TabSetRecord, "cleartext", ["clientName", "tabs"]); + +function TabEngine(service) { + SyncEngine.call(this, "Tabs", service); +} +TabEngine.prototype = { + __proto__: SyncEngine.prototype, + _storeObj: TabStore, + _trackerObj: TabTracker, + _recordObj: TabSetRecord, + + syncPriority: 3, + + async initialize() { + await SyncEngine.prototype.initialize.call(this); + + // Reset the client on every startup so that we fetch recent tabs. + await this._resetClient(); + }, + + async getChangedIDs() { + // No need for a proper timestamp (no conflict resolution needed). + let changedIDs = {}; + if (this._tracker.modified) { + changedIDs[this.service.clientsEngine.localID] = 0; + } + return changedIDs; + }, + + // API for use by Sync UI code to give user choices of tabs to open. + getAllClients() { + return this._store._remoteClients; + }, + + getClientById(id) { + return this._store._remoteClients[id]; + }, + + async _resetClient() { + await SyncEngine.prototype._resetClient.call(this); + await this._store.wipe(); + this._tracker.modified = true; + }, + + async removeClientData() { + let url = this.engineURL + "/" + this.service.clientsEngine.localID; + await this.service.resource(url).delete(); + }, + + async _reconcile(item) { + // Skip our own record. + // TabStore.itemExists tests only against our local client ID. + if (await this._store.itemExists(item.id)) { + this._log.trace( + "Ignoring incoming tab item because of its id: " + item.id + ); + return false; + } + + return SyncEngine.prototype._reconcile.call(this, item); + }, + + async trackRemainingChanges() { + if (this._modified.count() > 0) { + this._tracker.modified = true; + } + }, +}; + +function TabStore(name, engine) { + Store.call(this, name, engine); +} +TabStore.prototype = { + __proto__: Store.prototype, + + async itemExists(id) { + return id == this.engine.service.clientsEngine.localID; + }, + + getWindowEnumerator() { + return Services.wm.getEnumerator("navigator:browser"); + }, + + shouldSkipWindow(win) { + return win.closed || PrivateBrowsingUtils.isWindowPrivate(win); + }, + + getTabState(tab) { + return JSON.parse(SessionStore.getTabState(tab)); + }, + + async getAllTabs(filter) { + let filteredUrls = new RegExp( + Svc.Prefs.get("engine.tabs.filteredUrls"), + "i" + ); + + let allTabs = []; + + for (let win of this.getWindowEnumerator()) { + if (this.shouldSkipWindow(win)) { + continue; + } + + for (let tab of win.gBrowser.tabs) { + let tabState = this.getTabState(tab); + + // Make sure there are history entries to look at. + if (!tabState || !tabState.entries.length) { + continue; + } + + let acceptable = !filter + ? url => url + : url => url && !filteredUrls.test(url); + + let entries = tabState.entries; + let index = tabState.index; + let current = entries[index - 1]; + + // We ignore the tab completely if the current entry url is + // not acceptable (we need something accurate to open). + if (!acceptable(current.url)) { + continue; + } + + if (current.url.length > URI_LENGTH_MAX) { + this._log.trace("Skipping over-long URL."); + continue; + } + + // The element at `index` is the current page. Previous URLs were + // previously visited URLs; subsequent URLs are in the 'forward' stack, + // which we can't represent in Sync, so we truncate here. + let candidates = + entries.length == index ? entries : entries.slice(0, index); + + let urls = candidates + .map(entry => entry.url) + .filter(acceptable) + .reverse(); // Because Sync puts current at index 0, and history after. + + // Truncate if necessary. + if (urls.length > TAB_ENTRIES_LIMIT) { + urls.length = TAB_ENTRIES_LIMIT; + } + + // tabState has .image, but it's a large data: url. So we ask the favicon service for the url. + let icon = ""; + try { + let iconData = await PlacesUtils.promiseFaviconData(urls[0]); + icon = iconData.uri.spec; + } catch (ex) { + this._log.trace(`Failed to fetch favicon for ${urls[0]}`, ex); + } + allTabs.push({ + title: current.title || "", + urlHistory: urls, + icon, + lastUsed: Math.floor((tabState.lastAccessed || 0) / 1000), + }); + } + } + + return allTabs; + }, + + async createRecord(id, collection) { + let record = new TabSetRecord(collection, id); + record.clientName = this.engine.service.clientsEngine.localName; + + // Sort tabs in descending-used order to grab the most recently used + let tabs = (await this.getAllTabs(true)).sort(function(a, b) { + return b.lastUsed - a.lastUsed; + }); + const maxPayloadSize = this.engine.service.getMemcacheMaxRecordPayloadSize(); + let records = Utils.tryFitItems(tabs, maxPayloadSize); + + if (records.length != tabs.length) { + this._log.warn( + `Can't fit all tabs in sync payload: have ${tabs.length}, but can only fit ${records.length}.` + ); + } + + if (this._log.level <= Log.Level.Trace) { + records.forEach(tab => { + this._log.trace("Wrapping tab: ", tab); + }); + } + + record.tabs = records; + return record; + }, + + async getAllIDs() { + // Don't report any tabs if all windows are in private browsing for + // first syncs. + let ids = {}; + let allWindowsArePrivate = false; + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (PrivateBrowsingUtils.isWindowPrivate(win)) { + // Ensure that at least there is a private window. + allWindowsArePrivate = true; + } else { + // If there is a not private windown then finish and continue. + allWindowsArePrivate = false; + break; + } + } + + if ( + allWindowsArePrivate && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + return ids; + } + + ids[this.engine.service.clientsEngine.localID] = true; + return ids; + }, + + async wipe() { + this._remoteClients = {}; + }, + + async create(record) { + this._log.debug("Adding remote tabs from " + record.id); + this._remoteClients[record.id] = Object.assign({}, record.cleartext, { + lastModified: record.modified, + }); + }, + + async update(record) { + this._log.trace("Ignoring tab updates as local ones win"); + }, +}; + +function TabTracker(name, engine) { + Tracker.call(this, name, engine); + + // Make sure "this" pointer is always set correctly for event listeners. + this.onTab = Utils.bind2(this, this.onTab); + this._unregisterListeners = Utils.bind2(this, this._unregisterListeners); +} +TabTracker.prototype = { + __proto__: Tracker.prototype, + + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + clearChangedIDs() { + this.modified = false; + }, + + _topics: ["pageshow", "TabOpen", "TabClose", "TabSelect"], + + _registerListenersForWindow(window) { + this._log.trace("Registering tab listeners in window"); + for (let topic of this._topics) { + window.addEventListener(topic, this.onTab); + } + window.addEventListener("unload", this._unregisterListeners); + // If it's got a tab browser we can listen for things like navigation. + if (window.gBrowser) { + window.gBrowser.addProgressListener(this); + } + }, + + _unregisterListeners(event) { + this._unregisterListenersForWindow(event.target); + }, + + _unregisterListenersForWindow(window) { + this._log.trace("Removing tab listeners in window"); + window.removeEventListener("unload", this._unregisterListeners); + for (let topic of this._topics) { + window.removeEventListener(topic, this.onTab); + } + if (window.gBrowser) { + window.gBrowser.removeProgressListener(this); + } + }, + + onStart() { + Svc.Obs.add("domwindowopened", this.asyncObserver); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._registerListenersForWindow(win); + } + }, + + onStop() { + Svc.Obs.remove("domwindowopened", this.asyncObserver); + for (let win of Services.wm.getEnumerator("navigator:browser")) { + this._unregisterListenersForWindow(win); + } + }, + + async observe(subject, topic, data) { + switch (topic) { + case "domwindowopened": + let onLoad = () => { + subject.removeEventListener("load", onLoad); + // Only register after the window is done loading to avoid unloads. + this._registerListenersForWindow(subject); + }; + + // Add tab listeners now that a window has opened. + subject.addEventListener("load", onLoad); + break; + } + }, + + onTab(event) { + if (event.originalTarget.linkedBrowser) { + let browser = event.originalTarget.linkedBrowser; + if ( + PrivateBrowsingUtils.isBrowserPrivate(browser) && + !PrivateBrowsingUtils.permanentPrivateBrowsing + ) { + this._log.trace("Ignoring tab event from private browsing."); + return; + } + } + + this._log.trace("onTab event: " + event.type); + this.modified = true; + + // For page shows, bump the score 10% of the time, emulating a partial + // score. We don't want to sync too frequently. For all other page + // events, always bump the score. + if (event.type != "pageshow" || Math.random() < 0.1) { + this.score += SCORE_INCREMENT_SMALL; + } + }, + + // web progress listeners. + onLocationChange(webProgress, request, location, flags) { + // We only care about top-level location changes which are not in the same + // document. + if ( + webProgress.isTopLevel && + (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) == 0 + ) { + this.modified = true; + } + }, +}; diff --git a/services/sync/modules/keys.js b/services/sync/modules/keys.js new file mode 100644 index 0000000000..5dba9c92bd --- /dev/null +++ b/services/sync/modules/keys.js @@ -0,0 +1,187 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["BulkKeyBundle"]; + +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +/** + * Represents a pair of keys. + * + * Each key stored in a key bundle is 256 bits. One key is used for symmetric + * encryption. The other is used for HMAC. + * + * A KeyBundle by itself is just an anonymous pair of keys. Other types + * deriving from this one add semantics, such as associated collections or + * generating a key bundle via HKDF from another key. + */ +function KeyBundle() { + this._encrypt = null; + this._encryptB64 = null; + this._hmac = null; + this._hmacB64 = null; + this._hmacObj = null; + this._sha256HMACHasher = null; +} +KeyBundle.prototype = { + _encrypt: null, + _encryptB64: null, + _hmac: null, + _hmacB64: null, + _hmacObj: null, + _sha256HMACHasher: null, + + equals: function equals(bundle) { + return ( + bundle && + bundle.hmacKey == this.hmacKey && + bundle.encryptionKey == this.encryptionKey + ); + }, + + /* + * Accessors for the two keys. + */ + get encryptionKey() { + return this._encrypt; + }, + + set encryptionKey(value) { + if (!value || typeof value != "string") { + throw new Error("Encryption key can only be set to string values."); + } + + if (value.length < 16) { + throw new Error("Encryption key must be at least 128 bits long."); + } + + this._encrypt = value; + this._encryptB64 = btoa(value); + }, + + get encryptionKeyB64() { + return this._encryptB64; + }, + + get hmacKey() { + return this._hmac; + }, + + set hmacKey(value) { + if (!value || typeof value != "string") { + throw new Error("HMAC key can only be set to string values."); + } + + if (value.length < 16) { + throw new Error("HMAC key must be at least 128 bits long."); + } + + this._hmac = value; + this._hmacB64 = btoa(value); + this._hmacObj = value ? Utils.makeHMACKey(value) : null; + this._sha256HMACHasher = value + ? Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, this._hmacObj) + : null; + }, + + get hmacKeyB64() { + return this._hmacB64; + }, + + get hmacKeyObject() { + return this._hmacObj; + }, + + get sha256HMACHasher() { + return this._sha256HMACHasher; + }, + + /** + * Populate this key pair with 2 new, randomly generated keys. + */ + async generateRandom() { + // Compute both at that same time + let [generatedHMAC, generatedEncr] = await Promise.all([ + Weave.Crypto.generateRandomKey(), + Weave.Crypto.generateRandomKey(), + ]); + this.keyPairB64 = [generatedEncr, generatedHMAC]; + }, +}; + +/** + * Represents a KeyBundle associated with a collection. + * + * This is just a KeyBundle with a collection attached. + */ +function BulkKeyBundle(collection) { + let log = Log.repository.getLogger("Sync.BulkKeyBundle"); + log.info("BulkKeyBundle being created for " + collection); + KeyBundle.call(this); + + this._collection = collection; +} + +BulkKeyBundle.fromHexKey = function(hexKey) { + let key = CommonUtils.hexToBytes(hexKey); + let bundle = new BulkKeyBundle(); + // [encryptionKey, hmacKey] + bundle.keyPair = [key.slice(0, 32), key.slice(32, 64)]; + return bundle; +}; + +BulkKeyBundle.fromJWK = function(jwk) { + if (!jwk || !jwk.k || jwk.kty !== "oct") { + throw new Error("Invalid JWK provided to BulkKeyBundle.fromJWK"); + } + return BulkKeyBundle.fromHexKey(CommonUtils.base64urlToHex(jwk.k)); +}; + +BulkKeyBundle.prototype = { + __proto__: KeyBundle.prototype, + + get collection() { + return this._collection; + }, + + /** + * Obtain the key pair in this key bundle. + * + * The returned keys are represented as raw byte strings. + */ + get keyPair() { + return [this.encryptionKey, this.hmacKey]; + }, + + set keyPair(value) { + if (!Array.isArray(value) || value.length != 2) { + throw new Error("BulkKeyBundle.keyPair value must be array of 2 keys."); + } + + this.encryptionKey = value[0]; + this.hmacKey = value[1]; + }, + + get keyPairB64() { + return [this.encryptionKeyB64, this.hmacKeyB64]; + }, + + set keyPairB64(value) { + if (!Array.isArray(value) || value.length != 2) { + throw new Error( + "BulkKeyBundle.keyPairB64 value must be an array of 2 keys." + ); + } + + this.encryptionKey = CommonUtils.safeAtoB(value[0]); + this.hmacKey = CommonUtils.safeAtoB(value[1]); + }, +}; diff --git a/services/sync/modules/main.js b/services/sync/modules/main.js new file mode 100644 index 0000000000..bc3ad53c8b --- /dev/null +++ b/services/sync/modules/main.js @@ -0,0 +1,42 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["Weave"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +var Weave = {}; +ChromeUtils.import("resource://services-sync/constants.js", Weave); +var lazies = { + "service.js": ["Service"], + "status.js": ["Status"], + "util.js": ["Utils", "Svc"], +}; + +function lazyImport(module, dest, props) { + function getter(prop) { + return function() { + let ns = {}; + ChromeUtils.import(module, ns); + delete dest[prop]; + return (dest[prop] = ns[prop]); + }; + } + props.forEach(function(prop) { + dest.__defineGetter__(prop, getter(prop)); + }); +} + +for (let mod in lazies) { + lazyImport("resource://services-sync/" + mod, Weave, lazies[mod]); +} + +XPCOMUtils.defineLazyGetter(Weave, "Crypto", function() { + let { WeaveCrypto } = ChromeUtils.import( + "resource://services-crypto/WeaveCrypto.js" + ); + return new WeaveCrypto(); +}); diff --git a/services/sync/modules/policies.js b/services/sync/modules/policies.js new file mode 100644 index 0000000000..c8792df2e6 --- /dev/null +++ b/services/sync/modules/policies.js @@ -0,0 +1,1079 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["ErrorHandler", "SyncScheduler"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { + CREDENTIALS_CHANGED, + ENGINE_APPLY_FAIL, + ENGINE_UNKNOWN_FAIL, + IDLE_OBSERVER_BACK_DELAY, + LOGIN_FAILED_INVALID_PASSPHRASE, + LOGIN_FAILED_LOGIN_REJECTED, + LOGIN_FAILED_NETWORK_ERROR, + LOGIN_FAILED_NO_PASSPHRASE, + LOGIN_SUCCEEDED, + MASTER_PASSWORD_LOCKED, + MASTER_PASSWORD_LOCKED_RETRY_INTERVAL, + MAX_ERROR_COUNT_BEFORE_BACKOFF, + MINIMUM_BACKOFF_INTERVAL, + MULTI_DEVICE_THRESHOLD, + NO_SYNC_NODE_FOUND, + NO_SYNC_NODE_INTERVAL, + OVER_QUOTA, + RESPONSE_OVER_QUOTA, + SCORE_UPDATE_DELAY, + SERVER_MAINTENANCE, + SINGLE_USER_THRESHOLD, + STATUS_OK, + SYNC_FAILED_PARTIAL, + SYNC_SUCCEEDED, + kSyncBackoffNotMet, + kSyncMasterPasswordLocked, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { logManager } = ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js" +); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); + +ChromeUtils.defineModuleGetter( + this, + "Status", + "resource://services-sync/status.js" +); +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "fxAccounts", + "resource://gre/modules/FxAccounts.jsm" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "IdleService", + "@mozilla.org/widget/useridleservice;1", + "nsIUserIdleService" +); +XPCOMUtils.defineLazyServiceGetter( + this, + "CaptivePortalService", + "@mozilla.org/network/captive-portal-service;1", + "nsICaptivePortalService" +); + +// Get the value for an interval that's stored in preferences. To save users +// from themselves (and us from them!) the minimum time they can specify +// is 60s. +function getThrottledIntervalPreference(prefName) { + return Math.max(Svc.Prefs.get(prefName), 60) * 1000; +} + +function SyncScheduler(service) { + this.service = service; + this.init(); +} +SyncScheduler.prototype = { + _log: Log.repository.getLogger("Sync.SyncScheduler"), + + _fatalLoginStatus: [ + LOGIN_FAILED_NO_PASSPHRASE, + LOGIN_FAILED_INVALID_PASSPHRASE, + LOGIN_FAILED_LOGIN_REJECTED, + ], + + /** + * The nsITimer object that schedules the next sync. See scheduleNextSync(). + */ + syncTimer: null, + + setDefaults: function setDefaults() { + this._log.trace("Setting SyncScheduler policy values to defaults."); + + this.singleDeviceInterval = getThrottledIntervalPreference( + "scheduler.fxa.singleDeviceInterval" + ); + this.idleInterval = getThrottledIntervalPreference( + "scheduler.idleInterval" + ); + this.activeInterval = getThrottledIntervalPreference( + "scheduler.activeInterval" + ); + this.immediateInterval = getThrottledIntervalPreference( + "scheduler.immediateInterval" + ); + + // A user is non-idle on startup by default. + this.idle = false; + + this.hasIncomingItems = false; + // This is the last number of clients we saw when previously updating the + // client mode. If this != currentNumClients (obtained from prefs written + // by the clients engine) then we need to transition to and from + // single and multi-device mode. + this.numClientsLastSync = 0; + + this._resyncs = 0; + + this.clearSyncTriggers(); + }, + + // nextSync is in milliseconds, but prefs can't hold that much + get nextSync() { + return Svc.Prefs.get("nextSync", 0) * 1000; + }, + set nextSync(value) { + Svc.Prefs.set("nextSync", Math.floor(value / 1000)); + }, + + get missedFxACommandsFetchInterval() { + return Services.prefs.getIntPref( + "identity.fxaccounts.commands.missed.fetch_interval" + ); + }, + + get missedFxACommandsLastFetch() { + return Services.prefs.getIntPref( + "identity.fxaccounts.commands.missed.last_fetch", + 0 + ); + }, + + set missedFxACommandsLastFetch(val) { + Services.prefs.setIntPref( + "identity.fxaccounts.commands.missed.last_fetch", + val + ); + }, + + get syncInterval() { + return this._syncInterval; + }, + set syncInterval(value) { + if (value != this._syncInterval) { + Services.prefs.setIntPref("services.sync.syncInterval", value); + } + }, + + get syncThreshold() { + return this._syncThreshold; + }, + set syncThreshold(value) { + if (value != this._syncThreshold) { + Services.prefs.setIntPref("services.sync.syncThreshold", value); + } + }, + + get globalScore() { + return this._globalScore; + }, + set globalScore(value) { + if (this._globalScore != value) { + Services.prefs.setIntPref("services.sync.globalScore", value); + } + }, + + // Managed by the clients engine (by way of prefs) + get numClients() { + return this.numDesktopClients + this.numMobileClients; + }, + set numClients(value) { + throw new Error("Don't set numClients - the clients engine manages it."); + }, + + get offline() { + // Services.io.offline has slowly become fairly useless over the years - it + // no longer attempts to track the actual network state by default, but one + // thing stays true: if it says we're offline then we are definitely not online. + // + // We also ask the captive portal service if we are behind a locked captive + // portal. + // + // We don't check on the NetworkLinkService however, because it gave us + // false positives in the past in a vm environment. + try { + if ( + Services.io.offline || + CaptivePortalService.state == CaptivePortalService.LOCKED_PORTAL + ) { + return true; + } + } catch (ex) { + this._log.warn("Could not determine network status.", ex); + } + return false; + }, + + _initPrefGetters() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "idleTime", + "services.sync.scheduler.idleTime" + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "maxResyncs", + "services.sync.maxResyncs", + 0 + ); + + // The number of clients we have is maintained in preferences via the + // clients engine, and only updated after a successsful sync. + XPCOMUtils.defineLazyPreferenceGetter( + this, + "numDesktopClients", + "services.sync.clients.devices.desktop", + 0 + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "numMobileClients", + "services.sync.clients.devices.mobile", + 0 + ); + + // Scheduler state that seems to be read more often than it's written. + // We also check if the value has changed before writing in the setters. + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_syncThreshold", + "services.sync.syncThreshold", + SINGLE_USER_THRESHOLD + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_syncInterval", + "services.sync.syncInterval", + this.singleDeviceInterval + ); + XPCOMUtils.defineLazyPreferenceGetter( + this, + "_globalScore", + "services.sync.globalScore", + 0 + ); + }, + + init: function init() { + this._log.manageLevelFromPref("services.sync.log.logger.service.main"); + this.setDefaults(); + this._initPrefGetters(); + Svc.Obs.add("weave:engine:score:updated", this); + Svc.Obs.add("network:offline-status-changed", this); + Svc.Obs.add("network:link-status-changed", this); + Svc.Obs.add("captive-portal-detected", this); + Svc.Obs.add("weave:service:sync:start", this); + Svc.Obs.add("weave:service:sync:finish", this); + Svc.Obs.add("weave:engine:sync:finish", this); + Svc.Obs.add("weave:engine:sync:error", this); + Svc.Obs.add("weave:service:login:error", this); + Svc.Obs.add("weave:service:logout:finish", this); + Svc.Obs.add("weave:service:sync:error", this); + Svc.Obs.add("weave:service:backoff:interval", this); + Svc.Obs.add("weave:service:ready", this); + Svc.Obs.add("weave:engine:sync:applied", this); + Svc.Obs.add("weave:service:setup-complete", this); + Svc.Obs.add("weave:service:start-over", this); + Svc.Obs.add("FxA:hawk:backoff:interval", this); + + if (Status.checkSetup() == STATUS_OK) { + Svc.Obs.add("wake_notification", this); + Svc.Obs.add("captive-portal-login-success", this); + Svc.Obs.add("sleep_notification", this); + IdleService.addIdleObserver(this, this.idleTime); + } + }, + + // eslint-disable-next-line complexity + observe: function observe(subject, topic, data) { + this._log.trace("Handling " + topic); + switch (topic) { + case "weave:engine:score:updated": + if (Status.login == LOGIN_SUCCEEDED) { + CommonUtils.namedTimer( + this.calculateScore, + SCORE_UPDATE_DELAY, + this, + "_scoreTimer" + ); + } + break; + case "network:link-status-changed": + // Note: NetworkLinkService is unreliable, we get false negatives for it + // in cases such as VMs (bug 1420802), so we don't want to use it in + // `get offline`, but we assume that it's probably reliable if we're + // getting status changed events. (We might be wrong about this, but + // if that's true, then the only downside is that we won't sync as + // promptly). + let isOffline = this.offline; + this._log.debug( + `Network link status changed to "${data}". Offline?`, + isOffline + ); + // Data may be one of `up`, `down`, `change`, or `unknown`. We only want + // to sync if it's "up". + if (data == "up" && !isOffline) { + this._log.debug("Network link looks up. Syncing."); + this.scheduleNextSync(0, { why: topic }); + } else if (data == "down") { + // Unschedule pending syncs if we know we're going down. We don't do + // this via `checkSyncStatus`, since link status isn't reflected in + // `this.offline`. + this.clearSyncTriggers(); + } + break; + case "network:offline-status-changed": + case "captive-portal-detected": + // Whether online or offline, we'll reschedule syncs + this._log.trace("Network offline status change: " + data); + this.checkSyncStatus(); + break; + case "weave:service:sync:start": + // Clear out any potentially pending syncs now that we're syncing + this.clearSyncTriggers(); + + // reset backoff info, if the server tells us to continue backing off, + // we'll handle that later + Status.resetBackoff(); + + this.globalScore = 0; + break; + case "weave:service:sync:finish": + this.nextSync = 0; + this.adjustSyncInterval(); + + if (Status.service == SYNC_FAILED_PARTIAL && this.requiresBackoff) { + this.requiresBackoff = false; + this.handleSyncError(); + return; + } + + let sync_interval; + let nextSyncReason = "schedule"; + this.updateGlobalScore(); + if ( + this.globalScore > this.syncThreshold && + Status.service == STATUS_OK + ) { + // The global score should be 0 after a sync. If it's not, either + // items were changed during the last sync (and we should schedule an + // immediate follow-up sync), or an engine skipped + this._resyncs++; + if (this._resyncs <= this.maxResyncs) { + sync_interval = 0; + nextSyncReason = "resync"; + } else { + this._log.warn( + `Resync attempt ${this._resyncs} exceeded ` + + `maximum ${this.maxResyncs}` + ); + Svc.Obs.notify("weave:service:resyncs-finished"); + } + } else { + this._resyncs = 0; + Svc.Obs.notify("weave:service:resyncs-finished"); + } + + this._syncErrors = 0; + if (Status.sync == NO_SYNC_NODE_FOUND) { + // If we don't have a Sync node, override the interval, even if we've + // scheduled a follow-up sync. + this._log.trace("Scheduling a sync at interval NO_SYNC_NODE_FOUND."); + sync_interval = NO_SYNC_NODE_INTERVAL; + } + this.scheduleNextSync(sync_interval, { why: nextSyncReason }); + break; + case "weave:engine:sync:finish": + if (data == "clients") { + // Update the client mode because it might change what we sync. + this.updateClientMode(); + } + break; + case "weave:engine:sync:error": + // `subject` is the exception thrown by an engine's sync() method. + let exception = subject; + if (exception.status >= 500 && exception.status <= 504) { + this.requiresBackoff = true; + } + break; + case "weave:service:login:error": + this.clearSyncTriggers(); + + if (Status.login == MASTER_PASSWORD_LOCKED) { + // Try again later, just as if we threw an error... only without the + // error count. + this._log.debug("Couldn't log in: master password is locked."); + this._log.trace( + "Scheduling a sync at MASTER_PASSWORD_LOCKED_RETRY_INTERVAL" + ); + this.scheduleAtInterval(MASTER_PASSWORD_LOCKED_RETRY_INTERVAL); + } else if (!this._fatalLoginStatus.includes(Status.login)) { + // Not a fatal login error, just an intermittent network or server + // issue. Keep on syncin'. + this.checkSyncStatus(); + } + break; + case "weave:service:logout:finish": + // Start or cancel the sync timer depending on if + // logged in or logged out + this.checkSyncStatus(); + break; + case "weave:service:sync:error": + // There may be multiple clients but if the sync fails, client mode + // should still be updated so that the next sync has a correct interval. + this.updateClientMode(); + this.adjustSyncInterval(); + this.nextSync = 0; + this.handleSyncError(); + break; + case "FxA:hawk:backoff:interval": + case "weave:service:backoff:interval": + let requested_interval = subject * 1000; + this._log.debug( + "Got backoff notification: " + requested_interval + "ms" + ); + // Leave up to 25% more time for the back off. + let interval = requested_interval * (1 + Math.random() * 0.25); + Status.backoffInterval = interval; + Status.minimumNextSync = Date.now() + requested_interval; + this._log.debug("Fuzzed minimum next sync: " + Status.minimumNextSync); + break; + case "weave:service:ready": + // Applications can specify this preference if they want autoconnect + // to happen after a fixed delay. + let delay = Svc.Prefs.get("autoconnectDelay"); + if (delay) { + this.delayedAutoConnect(delay); + } + break; + case "weave:engine:sync:applied": + let numItems = subject.succeeded; + this._log.trace( + "Engine " + data + " successfully applied " + numItems + " items." + ); + if (numItems) { + this.hasIncomingItems = true; + } + if (subject.newFailed) { + this._log.error( + `Engine ${data} found ${subject.newFailed} new records that failed to apply` + ); + } + break; + case "weave:service:setup-complete": + Services.prefs.savePrefFile(null); + IdleService.addIdleObserver(this, this.idleTime); + Svc.Obs.add("wake_notification", this); + Svc.Obs.add("captive-portal-login-success", this); + Svc.Obs.add("sleep_notification", this); + break; + case "weave:service:start-over": + this.setDefaults(); + try { + IdleService.removeIdleObserver(this, this.idleTime); + } catch (ex) { + if (ex.result != Cr.NS_ERROR_FAILURE) { + throw ex; + } + // In all likelihood we didn't have an idle observer registered yet. + // It's all good. + } + break; + case "idle": + this._log.trace("We're idle."); + this.idle = true; + // Adjust the interval for future syncs. This won't actually have any + // effect until the next pending sync (which will happen soon since we + // were just active.) + this.adjustSyncInterval(); + break; + case "active": + this._log.trace("Received notification that we're back from idle."); + this.idle = false; + CommonUtils.namedTimer( + function onBack() { + if (this.idle) { + this._log.trace( + "... and we're idle again. " + + "Ignoring spurious back notification." + ); + return; + } + + this._log.trace("Genuine return from idle. Syncing."); + // Trigger a sync if we have multiple clients. + if (this.numClients > 1) { + this.scheduleNextSync(0, { why: topic }); + } + }, + IDLE_OBSERVER_BACK_DELAY, + this, + "idleDebouncerTimer" + ); + break; + case "wake_notification": + this._log.debug("Woke from sleep."); + CommonUtils.nextTick(() => { + // Trigger a sync if we have multiple clients. We give it 2 seconds + // so the browser can recover from the wake and do more important + // operations first (timers etc). + if (this.numClients > 1) { + if (!this.offline) { + this._log.debug("Online, will sync in 2s."); + this.scheduleNextSync(2000, { why: topic }); + } + } + }); + break; + case "captive-portal-login-success": + this._log.debug("Captive portal login success. Scheduling a sync."); + CommonUtils.nextTick(() => { + this.scheduleNextSync(3000, { why: topic }); + }); + break; + case "sleep_notification": + if (this.service.engineManager.get("tabs")._tracker.modified) { + this._log.debug("Going to sleep, doing a quick sync."); + this.scheduleNextSync(0, { engines: ["tabs"], why: "sleep" }); + } + break; + } + }, + + adjustSyncInterval: function adjustSyncInterval() { + if (this.numClients <= 1) { + this._log.trace("Adjusting syncInterval to singleDeviceInterval."); + this.syncInterval = this.singleDeviceInterval; + return; + } + + // Only MULTI_DEVICE clients will enter this if statement + // since SINGLE_USER clients will be handled above. + if (this.idle) { + this._log.trace("Adjusting syncInterval to idleInterval."); + this.syncInterval = this.idleInterval; + return; + } + + if (this.hasIncomingItems) { + this._log.trace("Adjusting syncInterval to immediateInterval."); + this.hasIncomingItems = false; + this.syncInterval = this.immediateInterval; + } else { + this._log.trace("Adjusting syncInterval to activeInterval."); + this.syncInterval = this.activeInterval; + } + }, + + updateGlobalScore() { + let engines = [this.service.clientsEngine].concat( + this.service.engineManager.getEnabled() + ); + let globalScore = this.globalScore; + for (let i = 0; i < engines.length; i++) { + this._log.trace(engines[i].name + ": score: " + engines[i].score); + globalScore += engines[i].score; + engines[i]._tracker.resetScore(); + } + this.globalScore = globalScore; + this._log.trace("Global score updated: " + globalScore); + }, + + calculateScore() { + this.updateGlobalScore(); + this.checkSyncStatus(); + }, + + /** + * Query the number of known clients to figure out what mode to be in + */ + updateClientMode: function updateClientMode() { + // Nothing to do if it's the same amount + let numClients = this.numClients; + if (numClients == this.numClientsLastSync) { + return; + } + + this._log.debug( + `Client count: ${this.numClientsLastSync} -> ${numClients}` + ); + this.numClientsLastSync = numClients; + + if (numClients <= 1) { + this._log.trace("Adjusting syncThreshold to SINGLE_USER_THRESHOLD"); + this.syncThreshold = SINGLE_USER_THRESHOLD; + } else { + this._log.trace("Adjusting syncThreshold to MULTI_DEVICE_THRESHOLD"); + this.syncThreshold = MULTI_DEVICE_THRESHOLD; + } + this.adjustSyncInterval(); + }, + + /** + * Check if we should be syncing and schedule the next sync, if it's not scheduled + */ + checkSyncStatus: function checkSyncStatus() { + // Should we be syncing now, if not, cancel any sync timers and return + // if we're in backoff, we'll schedule the next sync. + let ignore = [kSyncBackoffNotMet, kSyncMasterPasswordLocked]; + let skip = this.service._checkSync(ignore); + this._log.trace('_checkSync returned "' + skip + '".'); + if (skip) { + this.clearSyncTriggers(); + return; + } + + let why = "schedule"; + // Only set the wait time to 0 if we need to sync right away + let wait; + if (this.globalScore > this.syncThreshold) { + this._log.debug("Global Score threshold hit, triggering sync."); + wait = 0; + why = "score"; + } + this.scheduleNextSync(wait, { why }); + }, + + /** + * Call sync() if Master Password is not locked. + * + * Otherwise, reschedule a sync for later. + */ + syncIfMPUnlocked(engines, why) { + // No point if we got kicked out by the master password dialog. + if (Status.login == MASTER_PASSWORD_LOCKED && Utils.mpLocked()) { + this._log.debug("Not initiating sync: Login status is " + Status.login); + + // If we're not syncing now, we need to schedule the next one. + this._log.trace( + "Scheduling a sync at MASTER_PASSWORD_LOCKED_RETRY_INTERVAL" + ); + this.scheduleAtInterval(MASTER_PASSWORD_LOCKED_RETRY_INTERVAL); + return; + } + + if (!Async.isAppReady()) { + this._log.debug("Not initiating sync: app is shutting down"); + return; + } + Services.tm.dispatchToMainThread(() => { + this.service.sync({ engines, why }); + const now = Math.round(new Date().getTime() / 1000); + // Only fetch missed messages in a "scheduled" sync so we don't race against + // the Push service reconnecting on a network link change for example. + if ( + why == "schedule" && + now >= + this.missedFxACommandsLastFetch + this.missedFxACommandsFetchInterval + ) { + fxAccounts.commands + .pollDeviceCommands() + .then(() => { + this.missedFxACommandsLastFetch = now; + }) + .catch(e => { + this._log.error("Fetching missed remote commands failed.", e); + }); + } + }); + }, + + /** + * Set a timer for the next sync + */ + scheduleNextSync(interval, { engines = null, why = null } = {}) { + // If no interval was specified, use the current sync interval. + if (interval == null) { + interval = this.syncInterval; + } + + // Ensure the interval is set to no less than the backoff. + if (Status.backoffInterval && interval < Status.backoffInterval) { + this._log.trace( + "Requested interval " + + interval + + " ms is smaller than the backoff interval. " + + "Using backoff interval " + + Status.backoffInterval + + " ms instead." + ); + interval = Status.backoffInterval; + } + let nextSync = this.nextSync; + if (nextSync != 0) { + // There's already a sync scheduled. Don't reschedule if there's already + // a timer scheduled for sooner than requested. + let currentInterval = nextSync - Date.now(); + this._log.trace( + "There's already a sync scheduled in " + currentInterval + " ms." + ); + if (currentInterval < interval && this.syncTimer) { + this._log.trace( + "Ignoring scheduling request for next sync in " + interval + " ms." + ); + return; + } + } + + // Start the sync right away if we're already late. + if (interval <= 0) { + this._log.trace(`Requested sync should happen right away. (why=${why})`); + this.syncIfMPUnlocked(engines, why); + return; + } + + this._log.debug(`Next sync in ${interval} ms. (why=${why})`); + CommonUtils.namedTimer( + () => { + this.syncIfMPUnlocked(engines, why); + }, + interval, + this, + "syncTimer" + ); + + // Save the next sync time in-case sync is disabled (logout/offline/etc.) + this.nextSync = Date.now() + interval; + }, + + /** + * Incorporates the backoff/retry logic used in error handling and elective + * non-syncing. + */ + scheduleAtInterval: function scheduleAtInterval(minimumInterval) { + let interval = Utils.calculateBackoff( + this._syncErrors, + MINIMUM_BACKOFF_INTERVAL, + Status.backoffInterval + ); + if (minimumInterval) { + interval = Math.max(minimumInterval, interval); + } + + this._log.debug( + "Starting client-initiated backoff. Next sync in " + interval + " ms." + ); + this.scheduleNextSync(interval, { why: "client-backoff-schedule" }); + }, + + /** + * Automatically start syncing after the given delay (in seconds). + * + * Applications can define the `services.sync.autoconnectDelay` preference + * to have this called automatically during start-up with the pref value as + * the argument. Alternatively, they can call it themselves to control when + * Sync should first start to sync. + */ + delayedAutoConnect: function delayedAutoConnect(delay) { + if (this.service._checkSetup() == STATUS_OK) { + CommonUtils.namedTimer( + this.autoConnect, + delay * 1000, + this, + "_autoTimer" + ); + } + }, + + autoConnect: function autoConnect() { + if (this.service._checkSetup() == STATUS_OK && !this.service._checkSync()) { + // Schedule a sync based on when a previous sync was scheduled. + // scheduleNextSync() will do the right thing if that time lies in + // the past. + this.scheduleNextSync(this.nextSync - Date.now(), { why: "startup" }); + } + + // Once autoConnect is called we no longer need _autoTimer. + if (this._autoTimer) { + this._autoTimer.clear(); + } + }, + + _syncErrors: 0, + /** + * Deal with sync errors appropriately + */ + handleSyncError: function handleSyncError() { + this._log.trace("In handleSyncError. Error count: " + this._syncErrors); + this._syncErrors++; + + // Do nothing on the first couple of failures, if we're not in + // backoff due to 5xx errors. + if (!Status.enforceBackoff) { + if (this._syncErrors < MAX_ERROR_COUNT_BEFORE_BACKOFF) { + this.scheduleNextSync(null, { why: "reschedule" }); + return; + } + this._log.debug( + "Sync error count has exceeded " + + MAX_ERROR_COUNT_BEFORE_BACKOFF + + "; enforcing backoff." + ); + Status.enforceBackoff = true; + } + + this.scheduleAtInterval(); + }, + + /** + * Remove any timers/observers that might trigger a sync + */ + clearSyncTriggers: function clearSyncTriggers() { + this._log.debug("Clearing sync triggers and the global score."); + this.globalScore = this.nextSync = 0; + + // Clear out any scheduled syncs + if (this.syncTimer) { + this.syncTimer.clear(); + } + }, +}; + +function ErrorHandler(service) { + this.service = service; + this.init(); +} +ErrorHandler.prototype = { + init() { + Svc.Obs.add("weave:engine:sync:applied", this); + Svc.Obs.add("weave:engine:sync:error", this); + Svc.Obs.add("weave:service:login:error", this); + Svc.Obs.add("weave:service:sync:error", this); + Svc.Obs.add("weave:service:sync:finish", this); + Svc.Obs.add("weave:service:start-over:finish", this); + + this.initLogs(); + }, + + initLogs: function initLogs() { + // Set the root Sync logger level based on a pref. All other logs will + // inherit this level unless they specifically override it. + Log.repository + .getLogger("Sync") + .manageLevelFromPref(`services.sync.log.logger`); + // And allow our specific log to have a custom level via a pref. + this._log = Log.repository.getLogger("Sync.ErrorHandler"); + this._log.manageLevelFromPref("services.sync.log.logger.service.main"); + }, + + observe(subject, topic, data) { + this._log.trace("Handling " + topic); + switch (topic) { + case "weave:engine:sync:applied": + if (subject.newFailed) { + // An engine isn't able to apply one or more incoming records. + // We don't fail hard on this, but it usually indicates a bug, + // so for now treat it as sync error (c.f. Service._syncEngine()) + Status.engines = [data, ENGINE_APPLY_FAIL]; + this._log.debug(data + " failed to apply some records."); + } + break; + case "weave:engine:sync:error": { + let exception = subject; // exception thrown by engine's sync() method + let engine_name = data; // engine name that threw the exception + + this.checkServerError(exception); + + Status.engines = [ + engine_name, + exception.failureCode || ENGINE_UNKNOWN_FAIL, + ]; + if (Async.isShutdownException(exception)) { + this._log.debug( + engine_name + + " was interrupted due to the application shutting down" + ); + } else { + this._log.debug(engine_name + " failed", exception); + } + break; + } + case "weave:service:login:error": + this._log.error("Sync encountered a login error"); + this.resetFileLog(); + break; + case "weave:service:sync:error": { + if (Status.sync == CREDENTIALS_CHANGED) { + this.service.logout(); + } + + let exception = subject; + if (Async.isShutdownException(exception)) { + // If we are shutting down we just log the fact, attempt to flush + // the log file and get out of here! + this._log.error( + "Sync was interrupted due to the application shutting down" + ); + this.resetFileLog(); + break; + } + + // Not a shutdown related exception... + this._log.error("Sync encountered an error", exception); + this.resetFileLog(); + break; + } + case "weave:service:sync:finish": + this._log.trace("Status.service is " + Status.service); + + // Check both of these status codes: in the event of a failure in one + // engine, Status.service will be SYNC_FAILED_PARTIAL despite + // Status.sync being SYNC_SUCCEEDED. + // *facepalm* + if (Status.sync == SYNC_SUCCEEDED && Status.service == STATUS_OK) { + // Great. Let's clear our mid-sync 401 note. + this._log.trace("Clearing lastSyncReassigned."); + Svc.Prefs.reset("lastSyncReassigned"); + } + + if (Status.service == SYNC_FAILED_PARTIAL) { + this._log.error("Some engines did not sync correctly."); + } + this.resetFileLog(); + break; + case "weave:service:start-over:finish": + // ensure we capture any logs between the last sync and the reset completing. + this.resetFileLog() + .then(() => { + // although for privacy reasons we also delete all logs (but we allow + // a preference to avoid this to help with debugging.) + if (!Svc.Prefs.get("log.keepLogsOnReset", false)) { + return logManager.removeAllLogs().then(() => { + Svc.Obs.notify("weave:service:remove-file-log"); + }); + } + return null; + }) + .catch(err => { + // So we failed to delete the logs - take the ironic option of + // writing this error to the logs we failed to delete! + this._log.error("Failed to delete logs on reset", err); + }); + break; + } + }, + + async _dumpAddons() { + // Just dump the items that sync may be concerned with. Specifically, + // active extensions that are not hidden. + let addons = []; + try { + addons = await AddonManager.getAddonsByTypes(["extension"]); + } catch (e) { + this._log.warn("Failed to dump addons", e); + } + + let relevantAddons = addons.filter(x => x.isActive && !x.hidden); + this._log.trace("Addons installed", relevantAddons.length); + for (let addon of relevantAddons) { + this._log.trace(" - ${name}, version ${version}, id ${id}", addon); + } + }, + + /** + * Generate a log file for the sync that just completed + * and refresh the input & output streams. + */ + async resetFileLog() { + // If we're writing an error log, dump extensions that may be causing problems. + if (logManager.sawError) { + await this._dumpAddons(); + } + const logType = await logManager.resetFileLog(); + if (logType == logManager.ERROR_LOG_WRITTEN) { + Cu.reportError( + "Sync encountered an error - see about:sync-log for the log file." + ); + } + Svc.Obs.notify("weave:service:reset-file-log"); + }, + + /** + * Handle HTTP response results or exceptions and set the appropriate + * Status.* bits. + * + * This method also looks for "side-channel" warnings. + */ + checkServerError(resp) { + // In this case we were passed a resolved value of Resource#_doRequest. + switch (resp.status) { + case 400: + if (resp == RESPONSE_OVER_QUOTA) { + Status.sync = OVER_QUOTA; + } + break; + + case 401: + this.service.logout(); + this._log.info("Got 401 response; resetting clusterURL."); + this.service.clusterURL = null; + + let delay = 0; + if (Svc.Prefs.get("lastSyncReassigned")) { + // We got a 401 in the middle of the previous sync, and we just got + // another. Login must have succeeded in order for us to get here, so + // the password should be correct. + // This is likely to be an intermittent server issue, so back off and + // give it time to recover. + this._log.warn("Last sync also failed for 401. Delaying next sync."); + delay = MINIMUM_BACKOFF_INTERVAL; + } else { + this._log.debug("New mid-sync 401 failure. Making a note."); + Svc.Prefs.set("lastSyncReassigned", true); + } + this._log.info("Attempting to schedule another sync."); + this.service.scheduler.scheduleNextSync(delay, { why: "reschedule" }); + break; + + case 500: + case 502: + case 503: + case 504: + Status.enforceBackoff = true; + if (resp.status == 503 && resp.headers["retry-after"]) { + let retryAfter = resp.headers["retry-after"]; + this._log.debug("Got Retry-After: " + retryAfter); + if (this.service.isLoggedIn) { + Status.sync = SERVER_MAINTENANCE; + } else { + Status.login = SERVER_MAINTENANCE; + } + Svc.Obs.notify( + "weave:service:backoff:interval", + parseInt(retryAfter, 10) + ); + } + break; + } + + // In this other case we were passed a rejection value. + switch (resp.result) { + case Cr.NS_ERROR_UNKNOWN_HOST: + case Cr.NS_ERROR_CONNECTION_REFUSED: + case Cr.NS_ERROR_NET_TIMEOUT: + case Cr.NS_ERROR_NET_RESET: + case Cr.NS_ERROR_NET_INTERRUPT: + case Cr.NS_ERROR_PROXY_CONNECTION_REFUSED: + // The constant says it's about login, but in fact it just + // indicates general network error. + if (this.service.isLoggedIn) { + Status.sync = LOGIN_FAILED_NETWORK_ERROR; + } else { + Status.login = LOGIN_FAILED_NETWORK_ERROR; + } + break; + } + }, +}; diff --git a/services/sync/modules/record.js b/services/sync/modules/record.js new file mode 100644 index 0000000000..e25029438e --- /dev/null +++ b/services/sync/modules/record.js @@ -0,0 +1,1332 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = [ + "WBORecord", + "RecordManager", + "RawCryptoWrapper", + "CryptoWrapper", + "CollectionKeyManager", + "Collection", +]; + +const CRYPTO_COLLECTION = "crypto"; +const KEYS_WBO = "keys"; + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { + DEFAULT_DOWNLOAD_BATCH_SIZE, + DEFAULT_KEYBUNDLE_NAME, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { BulkKeyBundle } = ChromeUtils.import( + "resource://services-sync/keys.js" +); +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { Resource } = ChromeUtils.import("resource://services-sync/resource.js"); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); + +/** + * The base class for all Sync basic storage objects (BSOs). This is the format + * used to store all records on the Sync server. In an earlier version of the + * Sync protocol, BSOs used to be called WBOs, or Weave Basic Objects. This + * class retains the old name. + * + * @class + * @param {String} collection The collection name for this BSO. + * @param {String} id The ID of this BSO. + */ +function WBORecord(collection, id) { + this.data = {}; + this.payload = {}; + this.collection = collection; // Optional. + this.id = id; // Optional. +} +WBORecord.prototype = { + _logName: "Sync.Record.WBO", + + get sortindex() { + if (this.data.sortindex) { + return this.data.sortindex; + } + return 0; + }, + + // Get thyself from your URI, then deserialize. + // Set thine 'response' field. + async fetch(resource) { + if (!(resource instanceof Resource)) { + throw new Error("First argument must be a Resource instance."); + } + + let r = await resource.get(); + if (r.success) { + this.deserialize(r.obj); // Warning! Muffles exceptions! + } + this.response = r; + return this; + }, + + upload(resource) { + if (!(resource instanceof Resource)) { + throw new Error("First argument must be a Resource instance."); + } + + return resource.put(this); + }, + + // Take a base URI string, with trailing slash, and return the URI of this + // WBO based on collection and ID. + uri(base) { + if (this.collection && this.id) { + let url = CommonUtils.makeURI(base + this.collection + "/" + this.id); + url.QueryInterface(Ci.nsIURL); + return url; + } + return null; + }, + + deserialize: function deserialize(json) { + if (!json || typeof json !== "object") { + throw new TypeError("Can't deserialize record from: " + json); + } + this.data = json; + try { + // The payload is likely to be JSON, but if not, keep it as a string + this.payload = JSON.parse(this.payload); + } catch (ex) {} + }, + + toJSON: function toJSON() { + // Copy fields from data to be stringified, making sure payload is a string + let obj = {}; + for (let [key, val] of Object.entries(this.data)) { + obj[key] = key == "payload" ? JSON.stringify(val) : val; + } + if (this.ttl) { + obj.ttl = this.ttl; + } + return obj; + }, + + toString: function toString() { + return ( + "{ " + + "id: " + + this.id + + " " + + "index: " + + this.sortindex + + " " + + "modified: " + + this.modified + + " " + + "ttl: " + + this.ttl + + " " + + "payload: " + + JSON.stringify(this.payload) + + " }" + ); + }, +}; + +Utils.deferGetSet(WBORecord, "data", [ + "id", + "modified", + "sortindex", + "payload", +]); + +/** + * An encrypted BSO record. This subclass handles encrypting and decrypting the + * BSO payload, but doesn't parse or interpret the cleartext string. Subclasses + * must override `transformBeforeEncrypt` and `transformAfterDecrypt` to process + * the cleartext. + * + * This class is only exposed for bridged engines, which handle serialization + * and deserialization in Rust. Sync engines implemented in JS should subclass + * `CryptoWrapper` instead, which takes care of transforming the cleartext into + * an object, and ensuring its contents are valid. + * + * @class + * @template Cleartext + * @param {String} collection The collection name for this BSO. + * @param {String} id The ID of this BSO. + */ +function RawCryptoWrapper(collection, id) { + // Setting properties before calling the superclass constructor isn't allowed + // in new-style classes (`class MyRecord extends RawCryptoWrapper`), but + // allowed with plain functions. This is also why `defaultCleartext` is a + // method, and not simply set in the subclass constructor. + this.cleartext = this.defaultCleartext(); + WBORecord.call(this, collection, id); + this.ciphertext = null; +} +RawCryptoWrapper.prototype = { + __proto__: WBORecord.prototype, + _logName: "Sync.Record.RawCryptoWrapper", + + /** + * Returns the default empty cleartext for this record type. This is exposed + * as a method so that subclasses can override it, and access the default + * cleartext in their constructors. `CryptoWrapper`, for example, overrides + * this to return an empty object, so that initializing the `id` in its + * constructor calls its overridden `id` setter. + * + * @returns {Cleartext} An empty cleartext. + */ + defaultCleartext() { + return null; + }, + + /** + * Transforms the cleartext into a string that can be encrypted and wrapped + * in a BSO payload. This is called before uploading the record to the server. + * + * @param {Cleartext} outgoingCleartext The cleartext to upload. + * @returns {String} The serialized cleartext. + */ + transformBeforeEncrypt(outgoingCleartext) { + throw new TypeError("Override to stringify outgoing records"); + }, + + /** + * Transforms an incoming cleartext string into an instance of the + * `Cleartext` type. This is called when fetching the record from the + * server. + * + * @param {String} incomingCleartext The decrypted cleartext string. + * @returns {Cleartext} The parsed cleartext. + */ + transformAfterDecrypt(incomingCleartext) { + throw new TypeError("Override to parse incoming records"); + }, + + ciphertextHMAC: function ciphertextHMAC(keyBundle) { + let hasher = keyBundle.sha256HMACHasher; + if (!hasher) { + throw new Error("Cannot compute HMAC without an HMAC key."); + } + + return CommonUtils.bytesAsHex(Utils.digestBytes(this.ciphertext, hasher)); + }, + + /* + * Don't directly use the sync key. Instead, grab a key for this + * collection, which is decrypted with the sync key. + * + * Cache those keys; invalidate the cache if the time on the keys collection + * changes, or other auth events occur. + * + * Optional key bundle overrides the collection key lookup. + */ + async encrypt(keyBundle) { + if (!keyBundle) { + throw new Error("A key bundle must be supplied to encrypt."); + } + + this.IV = Weave.Crypto.generateRandomIV(); + this.ciphertext = await Weave.Crypto.encrypt( + this.transformBeforeEncrypt(this.cleartext), + keyBundle.encryptionKeyB64, + this.IV + ); + this.hmac = this.ciphertextHMAC(keyBundle); + this.cleartext = null; + }, + + // Optional key bundle. + async decrypt(keyBundle) { + if (!this.ciphertext) { + throw new Error("No ciphertext: nothing to decrypt?"); + } + + if (!keyBundle) { + throw new Error("A key bundle must be supplied to decrypt."); + } + + // Authenticate the encrypted blob with the expected HMAC + let computedHMAC = this.ciphertextHMAC(keyBundle); + + if (computedHMAC != this.hmac) { + Utils.throwHMACMismatch(this.hmac, computedHMAC); + } + + let cleartext = await Weave.Crypto.decrypt( + this.ciphertext, + keyBundle.encryptionKeyB64, + this.IV + ); + this.cleartext = this.transformAfterDecrypt(cleartext); + this.ciphertext = null; + + return this.cleartext; + }, +}; + +Utils.deferGetSet(RawCryptoWrapper, "payload", ["ciphertext", "IV", "hmac"]); + +/** + * An encrypted BSO record with a JSON payload. All engines implemented in JS + * should subclass this class to describe their own record types. + * + * @class + * @param {String} collection The collection name for this BSO. + * @param {String} id The ID of this BSO. + */ +function CryptoWrapper(collection, id) { + RawCryptoWrapper.call(this, collection, id); +} +CryptoWrapper.prototype = { + __proto__: RawCryptoWrapper.prototype, + _logName: "Sync.Record.CryptoWrapper", + + defaultCleartext() { + return {}; + }, + + transformBeforeEncrypt(cleartext) { + return JSON.stringify(cleartext); + }, + + transformAfterDecrypt(cleartext) { + // Handle invalid data here. Elsewhere we assume that cleartext is an object. + let json_result = JSON.parse(cleartext); + + if (!(json_result && json_result instanceof Object)) { + throw new Error( + `Decryption failed: result is <${json_result}>, not an object.` + ); + } + + // Verify that the encrypted id matches the requested record's id. + if (json_result.id != this.id) { + throw new Error(`Record id mismatch: ${json_result.id} != ${this.id}`); + } + + return json_result; + }, + + cleartextToString() { + return JSON.stringify(this.cleartext); + }, + + toString: function toString() { + let payload = this.deleted ? "DELETED" : this.cleartextToString(); + + return ( + "{ " + + "id: " + + this.id + + " " + + "index: " + + this.sortindex + + " " + + "modified: " + + this.modified + + " " + + "ttl: " + + this.ttl + + " " + + "payload: " + + payload + + " " + + "collection: " + + (this.collection || "undefined") + + " }" + ); + }, + + // The custom setter below masks the parent's getter, so explicitly call it :( + get id() { + return super.id; + }, + + // Keep both plaintext and encrypted versions of the id to verify integrity + set id(val) { + super.id = val; + return (this.cleartext.id = val); + }, +}; + +Utils.deferGetSet(CryptoWrapper, "cleartext", "deleted"); + +/** + * An interface and caching layer for records. + */ +function RecordManager(service) { + this.service = service; + + this._log = Log.repository.getLogger(this._logName); + this._records = {}; +} +RecordManager.prototype = { + _recordType: CryptoWrapper, + _logName: "Sync.RecordManager", + + async import(url) { + this._log.trace("Importing record: " + (url.spec ? url.spec : url)); + try { + // Clear out the last response with empty object if GET fails + this.response = {}; + this.response = await this.service.resource(url).get(); + + // Don't parse and save the record on failure + if (!this.response.success) { + return null; + } + + let record = new this._recordType(url); + record.deserialize(this.response.obj); + + return this.set(url, record); + } catch (ex) { + if (Async.isShutdownException(ex)) { + throw ex; + } + this._log.debug("Failed to import record", ex); + return null; + } + }, + + get(url) { + // Use a url string as the key to the hash + let spec = url.spec ? url.spec : url; + if (spec in this._records) { + return Promise.resolve(this._records[spec]); + } + return this.import(url); + }, + + set: function RecordMgr_set(url, record) { + let spec = url.spec ? url.spec : url; + return (this._records[spec] = record); + }, + + contains: function RecordMgr_contains(url) { + if ((url.spec || url) in this._records) { + return true; + } + return false; + }, + + clearCache: function recordMgr_clearCache() { + this._records = {}; + }, + + del: function RecordMgr_del(url) { + delete this._records[url]; + }, +}; + +/** + * Keeps track of mappings between collection names ('tabs') and KeyBundles. + * + * You can update this thing simply by giving it /info/collections. It'll + * use the last modified time to bring itself up to date. + */ +function CollectionKeyManager(lastModified, default_, collections) { + this.lastModified = lastModified || 0; + this._default = default_ || null; + this._collections = collections || {}; + + this._log = Log.repository.getLogger("Sync.CollectionKeyManager"); +} + +// TODO: persist this locally as an Identity. Bug 610913. +// Note that the last modified time needs to be preserved. +CollectionKeyManager.prototype = { + /** + * Generate a new CollectionKeyManager that has the same attributes + * as this one. + */ + clone() { + const newCollections = {}; + for (let c in this._collections) { + newCollections[c] = this._collections[c]; + } + + return new CollectionKeyManager( + this.lastModified, + this._default, + newCollections + ); + }, + + // Return information about old vs new keys: + // * same: true if two collections are equal + // * changed: an array of collection names that changed. + _compareKeyBundleCollections: function _compareKeyBundleCollections(m1, m2) { + let changed = []; + + function process(m1, m2) { + for (let k1 in m1) { + let v1 = m1[k1]; + let v2 = m2[k1]; + if (!(v1 && v2 && v1.equals(v2))) { + changed.push(k1); + } + } + } + + // Diffs both ways. + process(m1, m2); + process(m2, m1); + + // Return a sorted, unique array. + changed.sort(); + let last; + changed = changed.filter(x => x != last && (last = x)); + return { same: changed.length == 0, changed }; + }, + + get isClear() { + return !this._default; + }, + + clear: function clear() { + this._log.info("Clearing collection keys..."); + this.lastModified = 0; + this._collections = {}; + this._default = null; + }, + + keyForCollection(collection) { + if (collection && this._collections[collection]) { + return this._collections[collection]; + } + + return this._default; + }, + + /** + * If `collections` (an array of strings) is provided, iterate + * over it and generate random keys for each collection. + * Create a WBO for the given data. + */ + _makeWBO(collections, defaultBundle) { + let wbo = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); + let c = {}; + for (let k in collections) { + c[k] = collections[k].keyPairB64; + } + wbo.cleartext = { + default: defaultBundle ? defaultBundle.keyPairB64 : null, + collections: c, + collection: CRYPTO_COLLECTION, + id: KEYS_WBO, + }; + return wbo; + }, + + /** + * Create a WBO for the current keys. + */ + asWBO(collection, id) { + return this._makeWBO(this._collections, this._default); + }, + + /** + * Compute a new default key, and new keys for any specified collections. + */ + async newKeys(collections) { + let newDefaultKeyBundle = await this.newDefaultKeyBundle(); + + let newColls = {}; + if (collections) { + for (let c of collections) { + let b = new BulkKeyBundle(c); + await b.generateRandom(); + newColls[c] = b; + } + } + return [newDefaultKeyBundle, newColls]; + }, + + /** + * Generates new keys, but does not replace our local copy. Use this to + * verify an upload before storing. + */ + async generateNewKeysWBO(collections) { + let newDefaultKey, newColls; + [newDefaultKey, newColls] = await this.newKeys(collections); + + return this._makeWBO(newColls, newDefaultKey); + }, + + /** + * Create a new default key. + * + * @returns {BulkKeyBundle} + */ + async newDefaultKeyBundle() { + const key = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); + await key.generateRandom(); + return key; + }, + + /** + * Create a new default key and store it as this._default, since without one you cannot use setContents. + */ + async generateDefaultKey() { + this._default = await this.newDefaultKeyBundle(); + }, + + /** + * Return true if keys are already present for each of the given + * collections. + */ + hasKeysFor(collections) { + // We can't use filter() here because sometimes collections is an iterator. + for (let collection of collections) { + if (!this._collections[collection]) { + return false; + } + } + return true; + }, + + /** + * Return a new CollectionKeyManager that has keys for each of the + * given collections (creating new ones for collections where we + * don't already have keys). + */ + async ensureKeysFor(collections) { + const newKeys = Object.assign({}, this._collections); + for (let c of collections) { + if (newKeys[c]) { + continue; // don't replace existing keys + } + + const b = new BulkKeyBundle(c); + await b.generateRandom(); + newKeys[c] = b; + } + return new CollectionKeyManager(this.lastModified, this._default, newKeys); + }, + + // Take the fetched info/collections WBO, checking the change + // time of the crypto collection. + updateNeeded(info_collections) { + this._log.info( + "Testing for updateNeeded. Last modified: " + this.lastModified + ); + + // No local record of modification time? Need an update. + if (!this.lastModified) { + return true; + } + + // No keys on the server? We need an update, though our + // update handling will be a little more drastic... + if (!(CRYPTO_COLLECTION in info_collections)) { + return true; + } + + // Otherwise, we need an update if our modification time is stale. + return info_collections[CRYPTO_COLLECTION] > this.lastModified; + }, + + // + // Set our keys and modified time to the values fetched from the server. + // Returns one of three values: + // + // * If the default key was modified, return true. + // * If the default key was not modified, but per-collection keys were, + // return an array of such. + // * Otherwise, return false -- we were up-to-date. + // + setContents: function setContents(payload, modified) { + let self = this; + + this._log.info( + "Setting collection keys contents. Our last modified: " + + this.lastModified + + ", input modified: " + + modified + + "." + ); + + if (!payload) { + throw new Error("No payload in CollectionKeyManager.setContents()."); + } + + if (!payload.default) { + this._log.warn("No downloaded default key: this should not occur."); + this._log.warn("Not clearing local keys."); + throw new Error( + "No default key in CollectionKeyManager.setContents(). Cannot proceed." + ); + } + + // Process the incoming default key. + let b = new BulkKeyBundle(DEFAULT_KEYBUNDLE_NAME); + b.keyPairB64 = payload.default; + let newDefault = b; + + // Process the incoming collections. + let newCollections = {}; + if ("collections" in payload) { + this._log.info("Processing downloaded per-collection keys."); + let colls = payload.collections; + for (let k in colls) { + let v = colls[k]; + if (v) { + let keyObj = new BulkKeyBundle(k); + keyObj.keyPairB64 = v; + newCollections[k] = keyObj; + } + } + } + + // Check to see if these are already our keys. + let sameDefault = this._default && this._default.equals(newDefault); + let collComparison = this._compareKeyBundleCollections( + newCollections, + this._collections + ); + let sameColls = collComparison.same; + + if (sameDefault && sameColls) { + self._log.info("New keys are the same as our old keys!"); + if (modified) { + self._log.info("Bumped local modified time."); + self.lastModified = modified; + } + return false; + } + + // Make sure things are nice and tidy before we set. + this.clear(); + + this._log.info("Saving downloaded keys."); + this._default = newDefault; + this._collections = newCollections; + + // Always trust the server. + if (modified) { + self._log.info("Bumping last modified to " + modified); + self.lastModified = modified; + } + + return sameDefault ? collComparison.changed : true; + }, + + async updateContents(syncKeyBundle, storage_keys) { + let log = this._log; + log.info("Updating collection keys..."); + + // storage_keys is a WBO, fetched from storage/crypto/keys. + // Its payload is the default key, and a map of collections to keys. + // We lazily compute the key objects from the strings we're given. + + let payload; + try { + payload = await storage_keys.decrypt(syncKeyBundle); + } catch (ex) { + log.warn("Got exception decrypting storage keys with sync key.", ex); + log.info("Aborting updateContents. Rethrowing."); + throw ex; + } + + let r = this.setContents(payload, storage_keys.modified); + log.info("Collection keys updated."); + return r; + }, +}; + +function Collection(uri, recordObj, service) { + if (!service) { + throw new Error("Collection constructor requires a service."); + } + + Resource.call(this, uri); + + // This is a bit hacky, but gets the job done. + let res = service.resource(uri); + this.authenticator = res.authenticator; + + this._recordObj = recordObj; + this._service = service; + + this._full = false; + this._ids = null; + this._limit = 0; + this._older = 0; + this._newer = 0; + this._data = []; + // optional members used by batch upload operations. + this._batch = null; + this._commit = false; + // Used for batch download operations -- note that this is explicitly an + // opaque value and not (necessarily) a number. + this._offset = null; +} +Collection.prototype = { + __proto__: Resource.prototype, + _logName: "Sync.Collection", + + _rebuildURL: function Coll__rebuildURL() { + // XXX should consider what happens if it's not a URL... + this.uri.QueryInterface(Ci.nsIURL); + + let args = []; + if (this.older) { + args.push("older=" + this.older); + } + if (this.newer) { + args.push("newer=" + this.newer); + } + if (this.full) { + args.push("full=1"); + } + if (this.sort) { + args.push("sort=" + this.sort); + } + if (this.ids != null) { + args.push("ids=" + this.ids); + } + if (this.limit > 0 && this.limit != Infinity) { + args.push("limit=" + this.limit); + } + if (this._batch) { + args.push("batch=" + encodeURIComponent(this._batch)); + } + if (this._commit) { + args.push("commit=true"); + } + if (this._offset) { + args.push("offset=" + encodeURIComponent(this._offset)); + } + + this.uri = this.uri + .mutate() + .setQuery(args.length > 0 ? "?" + args.join("&") : "") + .finalize(); + }, + + // get full items + get full() { + return this._full; + }, + set full(value) { + this._full = value; + this._rebuildURL(); + }, + + // Apply the action to a certain set of ids + get ids() { + return this._ids; + }, + set ids(value) { + this._ids = value; + this._rebuildURL(); + }, + + // Limit how many records to get + get limit() { + return this._limit; + }, + set limit(value) { + this._limit = value; + this._rebuildURL(); + }, + + // get only items modified before some date + get older() { + return this._older; + }, + set older(value) { + this._older = value; + this._rebuildURL(); + }, + + // get only items modified since some date + get newer() { + return this._newer; + }, + set newer(value) { + this._newer = value; + this._rebuildURL(); + }, + + // get items sorted by some criteria. valid values: + // oldest (oldest first) + // newest (newest first) + // index + get sort() { + return this._sort; + }, + set sort(value) { + if (value && value != "oldest" && value != "newest" && value != "index") { + throw new TypeError( + `Illegal value for sort: "${value}" (should be "oldest", "newest", or "index").` + ); + } + this._sort = value; + this._rebuildURL(); + }, + + get offset() { + return this._offset; + }, + set offset(value) { + this._offset = value; + this._rebuildURL(); + }, + + // Set information about the batch for this request. + get batch() { + return this._batch; + }, + set batch(value) { + this._batch = value; + this._rebuildURL(); + }, + + get commit() { + return this._commit; + }, + set commit(value) { + this._commit = value && true; + this._rebuildURL(); + }, + + // Similar to get(), but will page through the items `batchSize` at a time, + // deferring calling the record handler until we've gotten them all. + // + // Returns the last response processed, and doesn't run the record handler + // on any items if a non-success status is received while downloading the + // records (or if a network error occurs). + async getBatched(batchSize = DEFAULT_DOWNLOAD_BATCH_SIZE) { + let totalLimit = Number(this.limit) || Infinity; + if (batchSize <= 0 || batchSize >= totalLimit) { + throw new Error("Invalid batch size"); + } + + if (!this.full) { + throw new Error("getBatched is unimplemented for guid-only GETs"); + } + + // _onComplete and _onProgress are reset after each `get` by Resource. + let { _onComplete, _onProgress } = this; + let recordBuffer = []; + let resp; + try { + let lastModifiedTime; + this.limit = batchSize; + + do { + this._onProgress = _onProgress; + this._onComplete = _onComplete; + if (batchSize + recordBuffer.length > totalLimit) { + this.limit = totalLimit - recordBuffer.length; + } + this._log.trace("Performing batched GET", { + limit: this.limit, + offset: this.offset, + }); + // Actually perform the request + resp = await this.get(); + if (!resp.success) { + recordBuffer = []; + break; + } + for (let json of resp.obj) { + let record = new this._recordObj(); + record.deserialize(json); + recordBuffer.push(record); + } + + // Initialize last modified, or check that something broken isn't happening. + let lastModified = resp.headers["x-last-modified"]; + if (!lastModifiedTime) { + lastModifiedTime = lastModified; + this.setHeader("X-If-Unmodified-Since", lastModified); + } else if (lastModified != lastModifiedTime) { + // Should be impossible -- We'd get a 412 in this case. + throw new Error( + "X-Last-Modified changed in the middle of a download batch! " + + `${lastModified} => ${lastModifiedTime}` + ); + } + + // If this is missing, we're finished. + this.offset = resp.headers["x-weave-next-offset"]; + } while (this.offset && totalLimit > recordBuffer.length); + } finally { + // Ensure we undo any temporary state so that subsequent calls to get() + // or getBatched() work properly. We do this before calling the record + // handler so that we can more convincingly pretend to be a normal get() + // call. Note: we're resetting these to the values they had before this + // function was called. + this._limit = totalLimit; + this._offset = null; + delete this._headers["x-if-unmodified-since"]; + this._rebuildURL(); + } + return { response: resp, records: recordBuffer }; + }, + + // This object only supports posting via the postQueue object. + post() { + throw new Error( + "Don't directly post to a collection - use newPostQueue instead" + ); + }, + + newPostQueue(log, timestamp, postCallback) { + let poster = (data, headers, batch, commit) => { + this.batch = batch; + this.commit = commit; + for (let [header, value] of headers) { + this.setHeader(header, value); + } + return Resource.prototype.post.call(this, data); + }; + return new PostQueue( + poster, + timestamp, + this._service.serverConfiguration || {}, + log, + postCallback + ); + }, +}; + +// These are limits for requests provided by the server at the +// info/configuration endpoint -- server documentation is available here: +// http://moz-services-docs.readthedocs.io/en/latest/storage/apis-1.5.html#api-instructions +// +// All are optional, however we synthesize (non-infinite) default values for the +// "max_request_bytes" and "max_record_payload_bytes" options. For the others, +// we ignore them (we treat the limit is infinite) if they're missing. +// +// These are also the only ones that all servers (even batching-disabled +// servers) should support, at least once this sync-serverstorage patch is +// everywhere https://github.com/mozilla-services/server-syncstorage/pull/74 +// +// Batching enabled servers also limit the amount of payload data and number +// of and records we can send in a single post as well as in the whole batch. +// Note that the byte limits for these there are just with respect to the +// *payload* data, e.g. the data appearing in the payload property (a +// string) of the object. +// +// Note that in practice, these limits should be sensible, but the code makes +// no assumptions about this. If we hit any of the limits, we perform the +// corresponding action (e.g. submit a request, possibly committing the +// current batch). +const DefaultPostQueueConfig = Object.freeze({ + // Number of total bytes allowed in a request + max_request_bytes: 260 * 1024, + + // Maximum number of bytes allowed in the "payload" property of a record. + max_record_payload_bytes: 256 * 1024, + + // The limit for how many bytes worth of data appearing in "payload" + // properties are allowed in a single post. + max_post_bytes: Infinity, + + // The limit for the number of records allowed in a single post. + max_post_records: Infinity, + + // The limit for how many bytes worth of data appearing in "payload" + // properties are allowed in a batch. (Same as max_post_bytes, but for + // batches). + max_total_bytes: Infinity, + + // The limit for the number of records allowed in a single post. (Same + // as max_post_records, but for batches). + max_total_records: Infinity, +}); + +// Manages a pair of (byte, count) limits for a PostQueue, such as +// (max_post_bytes, max_post_records) or (max_total_bytes, max_total_records). +class LimitTracker { + constructor(maxBytes, maxRecords) { + this.maxBytes = maxBytes; + this.maxRecords = maxRecords; + this.curBytes = 0; + this.curRecords = 0; + } + + clear() { + this.curBytes = 0; + this.curRecords = 0; + } + + canAddRecord(payloadSize) { + // The record counts are inclusive, but depending on the version of the + // server, the byte counts may or may not be inclusive (See + // https://github.com/mozilla-services/server-syncstorage/issues/73). + return ( + this.curRecords + 1 <= this.maxRecords && + this.curBytes + payloadSize < this.maxBytes + ); + } + + canNeverAdd(recordSize) { + return recordSize >= this.maxBytes; + } + + didAddRecord(recordSize) { + if (!this.canAddRecord(recordSize)) { + // This is a bug, caller is expected to call canAddRecord first. + throw new Error( + "LimitTracker.canAddRecord must be checked before adding record" + ); + } + this.curRecords += 1; + this.curBytes += recordSize; + } +} + +/* A helper to manage the posting of records while respecting the various + size limits. + + This supports the concept of a server-side "batch". The general idea is: + * We queue as many records as allowed in memory, then make a single POST. + * This first POST (optionally) gives us a batch ID, which we use for + all subsequent posts, until... + * At some point we hit a batch-maximum, and jump through a few hoops to + commit the current batch (ie, all previous POSTs) and start a new one. + * Eventually commit the final batch. + + In most cases we expect there to be exactly 1 batch consisting of possibly + multiple POSTs. +*/ +function PostQueue(poster, timestamp, serverConfig, log, postCallback) { + // The "post" function we should use when it comes time to do the post. + this.poster = poster; + this.log = log; + + let config = Object.assign({}, DefaultPostQueueConfig, serverConfig); + + if (!serverConfig.max_request_bytes && serverConfig.max_post_bytes) { + // Use max_post_bytes for max_request_bytes if it's missing. Only needed + // until server-syncstorage/pull/74 is everywhere, and even then it's + // unnecessary if the server limits are configured sanely (there's no + // guarantee of -- at least before that is fully deployed) + config.max_request_bytes = serverConfig.max_post_bytes; + } + + this.log.trace("new PostQueue config (after defaults): ", config); + + // The callback we make with the response when we do get around to making the + // post (which could be during any of the enqueue() calls or the final flush()) + // This callback may be called multiple times and must not add new items to + // the queue. + // The second argument passed to this callback is a boolean value that is true + // if we're in the middle of a batch, and false if either the batch is + // complete, or it's a post to a server that does not understand batching. + this.postCallback = postCallback; + + // Tracks the count and combined payload size for the records we've queued + // so far but are yet to POST. + this.postLimits = new LimitTracker( + config.max_post_bytes, + config.max_post_records + ); + + // As above, but for the batch size. + this.batchLimits = new LimitTracker( + config.max_total_bytes, + config.max_total_records + ); + + // Limit for the size of `this.queued` before we do a post. + this.maxRequestBytes = config.max_request_bytes; + + // Limit for the size of incoming record payloads. + this.maxPayloadBytes = config.max_record_payload_bytes; + + // The string where we are capturing the stringified version of the records + // queued so far. It will always be invalid JSON as it is always missing the + // closing bracket. It's also used to track whether or not we've gone past + // maxRequestBytes. + this.queued = ""; + + // The ID of our current batch. Can be undefined (meaning we are yet to make + // the first post of a patch, so don't know if we have a batch), null (meaning + // we've made the first post but the server response indicated no batching + // semantics), otherwise we have made the first post and it holds the batch ID + // returned from the server. + this.batchID = undefined; + + // Time used for X-If-Unmodified-Since -- should be the timestamp from the last GET. + this.lastModified = timestamp; +} + +PostQueue.prototype = { + async enqueue(record) { + // We want to ensure the record has a .toJSON() method defined - even + // though JSON.stringify() would implicitly call it, the stringify might + // still work even if it isn't defined, which isn't what we want. + let jsonRepr = record.toJSON(); + if (!jsonRepr) { + throw new Error( + "You must only call this with objects that explicitly support JSON" + ); + } + + let bytes = JSON.stringify(jsonRepr); + + // We use the payload size for the LimitTrackers, since that's what the + // byte limits other than max_request_bytes refer to. + let payloadLength = jsonRepr.payload.length; + + // The `+ 2` is to account for the 2-byte (maximum) overhead (one byte for + // the leading comma or "[", which all records will have, and the other for + // the final trailing "]", only present for the last record). + let encodedLength = bytes.length + 2; + + // Check first if there's some limit that indicates we cannot ever enqueue + // this record. + let isTooBig = + this.postLimits.canNeverAdd(payloadLength) || + this.batchLimits.canNeverAdd(payloadLength) || + encodedLength >= this.maxRequestBytes || + payloadLength >= this.maxPayloadBytes; + + if (isTooBig) { + return { + enqueued: false, + error: new Error("Single record too large to submit to server"), + }; + } + + let canPostRecord = this.postLimits.canAddRecord(payloadLength); + let canBatchRecord = this.batchLimits.canAddRecord(payloadLength); + let canSendRecord = + this.queued.length + encodedLength < this.maxRequestBytes; + + if (!canPostRecord || !canBatchRecord || !canSendRecord) { + this.log.trace("PostQueue flushing: ", { + canPostRecord, + canSendRecord, + canBatchRecord, + }); + // We need to write the queue out before handling this one, but we only + // commit the batch (and thus start a new one) if the record couldn't fit + // inside the batch. + await this.flush(!canBatchRecord); + } + + this.postLimits.didAddRecord(payloadLength); + this.batchLimits.didAddRecord(payloadLength); + + // Either a ',' or a '[' depending on whether this is the first record. + this.queued += this.queued.length ? "," : "["; + this.queued += bytes; + return { enqueued: true }; + }, + + async flush(finalBatchPost) { + if (!this.queued) { + // nothing queued - we can't be in a batch, and something has gone very + // bad if we think we are. + if (this.batchID) { + throw new Error( + `Flush called when no queued records but we are in a batch ${this.batchID}` + ); + } + return; + } + // the batch query-param and headers we'll send. + let batch; + let headers = []; + if (this.batchID === undefined) { + // First commit in a (possible) batch. + batch = "true"; + } else if (this.batchID) { + // We have an existing batch. + batch = this.batchID; + } else { + // Not the first post and we know we have no batch semantics. + batch = null; + } + + headers.push(["x-if-unmodified-since", this.lastModified]); + + let numQueued = this.postLimits.curRecords; + this.log.info( + `Posting ${numQueued} records of ${this.queued.length + + 1} bytes with batch=${batch}` + ); + let queued = this.queued + "]"; + if (finalBatchPost) { + this.batchLimits.clear(); + } + this.postLimits.clear(); + this.queued = ""; + let response = await this.poster( + queued, + headers, + batch, + !!(finalBatchPost && this.batchID !== null) + ); + + if (!response.success) { + this.log.trace("Server error response during a batch", response); + // not clear what we should do here - we expect the consumer of this to + // abort by throwing in the postCallback below. + await this.postCallback(this, response, !finalBatchPost); + return; + } + + if (finalBatchPost) { + this.log.trace("Committed batch", this.batchID); + this.batchID = undefined; // we are now in "first post for the batch" state. + this.lastModified = response.headers["x-last-modified"]; + await this.postCallback(this, response, false); + return; + } + + if (response.status != 202) { + if (this.batchID) { + throw new Error( + "Server responded non-202 success code while a batch was in progress" + ); + } + this.batchID = null; // no batch semantics are in place. + this.lastModified = response.headers["x-last-modified"]; + await this.postCallback(this, response, false); + return; + } + + // this response is saying the server has batch semantics - we should + // always have a batch ID in the response. + let responseBatchID = response.obj.batch; + this.log.trace("Server responsed 202 with batch", responseBatchID); + if (!responseBatchID) { + this.log.error( + "Invalid server response: 202 without a batch ID", + response + ); + throw new Error("Invalid server response: 202 without a batch ID"); + } + + if (this.batchID === undefined) { + this.batchID = responseBatchID; + if (!this.lastModified) { + this.lastModified = response.headers["x-last-modified"]; + if (!this.lastModified) { + throw new Error("Batch response without x-last-modified"); + } + } + } + + if (this.batchID != responseBatchID) { + throw new Error( + `Invalid client/server batch state - client has ${this.batchID}, server has ${responseBatchID}` + ); + } + + await this.postCallback(this, response, true); + }, +}; diff --git a/services/sync/modules/resource.js b/services/sync/modules/resource.js new file mode 100644 index 0000000000..cf8678d31d --- /dev/null +++ b/services/sync/modules/resource.js @@ -0,0 +1,299 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["Resource"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { setTimeout, clearTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" +); +XPCOMUtils.defineLazyGlobalGetters(this, ["fetch", "Headers", "Request"]); +/* global AbortController */ + +/* + * Resource represents a remote network resource, identified by a URI. + * Create an instance like so: + * + * let resource = new Resource("http://foobar.com/path/to/resource"); + * + * The 'resource' object has the following methods to issue HTTP requests + * of the corresponding HTTP methods: + * + * get(callback) + * put(data, callback) + * post(data, callback) + * delete(callback) + */ +function Resource(uri) { + this._log = Log.repository.getLogger(this._logName); + this._log.manageLevelFromPref("services.sync.log.logger.network.resources"); + this.uri = uri; + this._headers = {}; +} +// (static) Caches the latest server timestamp (X-Weave-Timestamp header). +Resource.serverTime = null; + +XPCOMUtils.defineLazyPreferenceGetter( + Resource, + "SEND_VERSION_INFO", + "services.sync.sendVersionInfo", + true +); +Resource.prototype = { + _logName: "Sync.Resource", + + /** + * Callback to be invoked at request time to add authentication details. + * If the callback returns a promise, it will be awaited upon. + * + * By default, a global authenticator is provided. If this is set, it will + * be used instead of the global one. + */ + authenticator: null, + + // Wait 5 minutes before killing a request. + ABORT_TIMEOUT: 300000, + + // Headers to be included when making a request for the resource. + // Note: Header names should be all lower case, there's no explicit + // check for duplicates due to case! + get headers() { + return this._headers; + }, + set headers(_) { + throw new Error("headers can't be mutated directly. Please use setHeader."); + }, + setHeader(header, value) { + this._headers[header.toLowerCase()] = value; + }, + + // URI representing this resource. + get uri() { + return this._uri; + }, + set uri(value) { + if (typeof value == "string") { + this._uri = CommonUtils.makeURI(value); + } else { + this._uri = value; + } + }, + + // Get the string representation of the URI. + get spec() { + if (this._uri) { + return this._uri.spec; + } + return null; + }, + + /** + * @param {string} method HTTP method + * @returns {Headers} + */ + async _buildHeaders(method) { + const headers = new Headers(this._headers); + + if (Resource.SEND_VERSION_INFO) { + headers.append("user-agent", Utils.userAgent); + } + + if (this.authenticator) { + const result = await this.authenticator(this, method); + if (result && result.headers) { + for (const [k, v] of Object.entries(result.headers)) { + headers.append(k.toLowerCase(), v); + } + } + } else { + this._log.debug("No authenticator found."); + } + + // PUT and POST are treated differently because they have payload data. + if (("PUT" == method || "POST" == method) && !headers.has("content-type")) { + headers.append("content-type", "text/plain"); + } + + if (this._log.level <= Log.Level.Trace) { + for (const [k, v] of headers) { + if (k == "authorization" || k == "x-client-state") { + this._log.trace(`HTTP Header ${k}: ***** (suppressed)`); + } else { + this._log.trace(`HTTP Header ${k}: ${v}`); + } + } + } + + if (!headers.has("accept")) { + headers.append("accept", "application/json;q=0.9,*/*;q=0.2"); + } + + return headers; + }, + + /** + * @param {string} method HTTP method + * @param {string} data HTTP body + * @param {object} signal AbortSignal instance + * @returns {Request} + */ + async _createRequest(method, data, signal) { + const headers = await this._buildHeaders(method); + const init = { + cache: "no-store", // No cache. + headers, + method, + signal, + mozErrors: true, // Return nsresult error codes instead of a generic + // NetworkError when fetch rejects. + }; + + if (data) { + if (!(typeof data == "string" || data instanceof String)) { + data = JSON.stringify(data); + } + this._log.debug(`${method} Length: ${data.length}`); + this._log.trace(`${method} Body: ${data}`); + init.body = data; + } + return new Request(this.uri.spec, init); + }, + + /** + * @param {string} method HTTP method + * @param {string} [data] HTTP body + * @returns {Response} + */ + async _doRequest(method, data = null) { + const controller = new AbortController(); + const request = await this._createRequest(method, data, controller.signal); + const responsePromise = fetch(request); // Rejects on network failure. + let didTimeout = false; + const timeoutId = setTimeout(() => { + didTimeout = true; + this._log.error( + `Request timed out after ${this.ABORT_TIMEOUT}ms. Aborting.` + ); + controller.abort(); + }, this.ABORT_TIMEOUT); + let response; + try { + response = await responsePromise; + } catch (e) { + this._log.warn(`${method} request to ${this.uri.spec} failed`, e); + if (!didTimeout) { + throw e; + } + throw Components.Exception( + "Request aborted (timeout)", + Cr.NS_ERROR_NET_TIMEOUT + ); + } finally { + clearTimeout(timeoutId); + } + return this._processResponse(response, method); + }, + + async _processResponse(response, method) { + const data = await response.text(); + this._logResponse(response, method, data); + this._processResponseHeaders(response); + + const ret = { + data, + url: response.url, + status: response.status, + success: response.ok, + headers: {}, + }; + for (const [k, v] of response.headers) { + ret.headers[k] = v; + } + + // Make a lazy getter to convert the json response into an object. + // Note that this can cause a parse error to be thrown far away from the + // actual fetch, so be warned! + XPCOMUtils.defineLazyGetter(ret, "obj", () => { + try { + return JSON.parse(ret.data); + } catch (ex) { + this._log.warn("Got exception parsing response body", ex); + // Stringify to avoid possibly printing non-printable characters. + this._log.debug( + "Parse fail: Response body starts", + (ret.data + "").slice(0, 100) + ); + throw ex; + } + }); + + return ret; + }, + + _logResponse(response, method, data) { + const { status, ok: success, url } = response; + + // Log the status of the request. + this._log.debug( + `${method} ${success ? "success" : "fail"} ${status} ${url}` + ); + + // Additionally give the full response body when Trace logging. + if (this._log.level <= Log.Level.Trace) { + this._log.trace(`${method} body`, data); + } + + if (!success) { + this._log.warn( + `${method} request to ${url} failed with status ${status}` + ); + } + }, + + _processResponseHeaders({ headers, ok: success }) { + if (headers.has("x-weave-timestamp")) { + Resource.serverTime = parseFloat(headers.get("x-weave-timestamp")); + } + // This is a server-side safety valve to allow slowing down + // clients without hurting performance. + if (headers.has("x-weave-backoff")) { + let backoff = headers.get("x-weave-backoff"); + this._log.debug(`Got X-Weave-Backoff: ${backoff}`); + Observers.notify("weave:service:backoff:interval", parseInt(backoff, 10)); + } + + if (success && headers.has("x-weave-quota-remaining")) { + Observers.notify( + "weave:service:quota:remaining", + parseInt(headers.get("x-weave-quota-remaining"), 10) + ); + } + }, + + get() { + return this._doRequest("GET"); + }, + + put(data) { + return this._doRequest("PUT", data); + }, + + post(data) { + return this._doRequest("POST", data); + }, + + delete() { + return this._doRequest("DELETE"); + }, +}; diff --git a/services/sync/modules/service.js b/services/sync/modules/service.js new file mode 100644 index 0000000000..2def38a946 --- /dev/null +++ b/services/sync/modules/service.js @@ -0,0 +1,1646 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["Service"]; + +// How long before refreshing the cluster +const CLUSTER_BACKOFF = 5 * 60 * 1000; // 5 minutes + +// How long a key to generate from an old passphrase. +const PBKDF2_KEY_BYTES = 16; + +const CRYPTO_COLLECTION = "crypto"; +const KEYS_WBO = "keys"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { + CLIENT_NOT_CONFIGURED, + CREDENTIALS_CHANGED, + HMAC_EVENT_INTERVAL, + LOGIN_FAILED, + LOGIN_FAILED_INVALID_PASSPHRASE, + LOGIN_FAILED_NETWORK_ERROR, + LOGIN_FAILED_NO_PASSPHRASE, + LOGIN_FAILED_NO_USERNAME, + LOGIN_FAILED_SERVER_ERROR, + LOGIN_SUCCEEDED, + MASTER_PASSWORD_LOCKED, + METARECORD_DOWNLOAD_FAIL, + NO_SYNC_NODE_FOUND, + PREFS_BRANCH, + STATUS_DISABLED, + STATUS_OK, + STORAGE_VERSION, + VERSION_OUT_OF_DATE, + WEAVE_VERSION, + kFirefoxShuttingDown, + kFirstSyncChoiceNotMade, + kSyncBackoffNotMet, + kSyncMasterPasswordLocked, + kSyncNetworkOffline, + kSyncNotConfigured, + kSyncWeaveDisabled, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { EngineManager } = ChromeUtils.import( + "resource://services-sync/engines.js" +); +const { ClientEngine } = ChromeUtils.import( + "resource://services-sync/engines/clients.js" +); +const { Weave } = ChromeUtils.import("resource://services-sync/main.js"); +const { ErrorHandler, SyncScheduler } = ChromeUtils.import( + "resource://services-sync/policies.js" +); +const { + CollectionKeyManager, + CryptoWrapper, + RecordManager, + WBORecord, +} = ChromeUtils.import("resource://services-sync/record.js"); +const { Resource } = ChromeUtils.import("resource://services-sync/resource.js"); +const { EngineSynchronizer } = ChromeUtils.import( + "resource://services-sync/stages/enginesync.js" +); +const { DeclinedEngines } = ChromeUtils.import( + "resource://services-sync/stages/declined.js" +); +const { Status } = ChromeUtils.import("resource://services-sync/status.js"); +ChromeUtils.import("resource://services-sync/telemetry.js"); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { fxAccounts } = ChromeUtils.import( + "resource://gre/modules/FxAccounts.jsm" +); + +function getEngineModules() { + let result = { + Addons: { module: "addons.js", symbol: "AddonsEngine" }, + Bookmarks: { module: "bookmarks.js", symbol: "BookmarksEngine" }, + Form: { module: "forms.js", symbol: "FormEngine" }, + History: { module: "history.js", symbol: "HistoryEngine" }, + Password: { module: "passwords.js", symbol: "PasswordEngine" }, + Prefs: { module: "prefs.js", symbol: "PrefsEngine" }, + Tab: { module: "tabs.js", symbol: "TabEngine" }, + }; + if (Svc.Prefs.get("engine.addresses.available", false)) { + result.Addresses = { + module: "resource://formautofill/FormAutofillSync.jsm", + symbol: "AddressesEngine", + }; + } + if (Svc.Prefs.get("engine.creditcards.available", false)) { + result.CreditCards = { + module: "resource://formautofill/FormAutofillSync.jsm", + symbol: "CreditCardsEngine", + }; + } + result["Extension-Storage"] = { + module: "extension-storage.js", + controllingPref: "webextensions.storage.sync.kinto", + whenTrue: "ExtensionStorageEngineKinto", + whenFalse: "ExtensionStorageEngineBridge", + }; + return result; +} + +// A unique identifier for this browser session. Used for logging so +// we can easily see whether 2 logs are in the same browser session or +// after the browser restarted. +XPCOMUtils.defineLazyGetter(this, "browserSessionID", Utils.makeGUID); + +function Sync11Service() { + this._notify = Utils.notify("weave:service:"); + Utils.defineLazyIDProperty(this, "syncID", "services.sync.client.syncID"); +} +Sync11Service.prototype = { + _lock: Utils.lock, + _locked: false, + _loggedIn: false, + + infoURL: null, + storageURL: null, + metaURL: null, + cryptoKeyURL: null, + // The cluster URL comes via the identity object, which in the FxA + // world is ebbedded in the token returned from the token server. + _clusterURL: null, + + get clusterURL() { + return this._clusterURL || ""; + }, + set clusterURL(value) { + if (value != null && typeof value != "string") { + throw new Error("cluster must be a string, got " + typeof value); + } + this._clusterURL = value; + this._updateCachedURLs(); + }, + + get isLoggedIn() { + return this._loggedIn; + }, + + get locked() { + return this._locked; + }, + lock: function lock() { + if (this._locked) { + return false; + } + this._locked = true; + return true; + }, + unlock: function unlock() { + this._locked = false; + }, + + // A specialized variant of Utils.catch. + // This provides a more informative error message when we're already syncing: + // see Bug 616568. + _catch(func) { + function lockExceptions(ex) { + if (Utils.isLockException(ex)) { + // This only happens if we're syncing already. + this._log.info("Cannot start sync: already syncing?"); + } + } + + return Utils.catch.call(this, func, lockExceptions); + }, + + get userBaseURL() { + // The user URL is the cluster URL. + return this.clusterURL; + }, + + _updateCachedURLs: function _updateCachedURLs() { + // Nothing to cache yet if we don't have the building blocks + if (!this.clusterURL) { + // Also reset all other URLs used by Sync to ensure we aren't accidentally + // using one cached earlier - if there's no cluster URL any cached ones + // are invalid. + this.infoURL = undefined; + this.storageURL = undefined; + this.metaURL = undefined; + this.cryptoKeysURL = undefined; + return; + } + + this._log.debug( + "Caching URLs under storage user base: " + this.userBaseURL + ); + + // Generate and cache various URLs under the storage API for this user + this.infoURL = this.userBaseURL + "info/collections"; + this.storageURL = this.userBaseURL + "storage/"; + this.metaURL = this.storageURL + "meta/global"; + this.cryptoKeysURL = this.storageURL + CRYPTO_COLLECTION + "/" + KEYS_WBO; + }, + + _checkCrypto: function _checkCrypto() { + let ok = false; + + try { + let iv = Weave.Crypto.generateRandomIV(); + if (iv.length == 24) { + ok = true; + } + } catch (e) { + this._log.debug("Crypto check failed: " + e); + } + + return ok; + }, + + /** + * Here is a disgusting yet reasonable way of handling HMAC errors deep in + * the guts of Sync. The astute reader will note that this is a hacky way of + * implementing something like continuable conditions. + * + * A handler function is glued to each engine. If the engine discovers an + * HMAC failure, we fetch keys from the server and update our keys, just as + * we would on startup. + * + * If our key collection changed, we signal to the engine (via our return + * value) that it should retry decryption. + * + * If our key collection did not change, it means that we already had the + * correct keys... and thus a different client has the wrong ones. Reupload + * the bundle that we fetched, which will bump the modified time on the + * server and (we hope) prompt a broken client to fix itself. + * + * We keep track of the time at which we last applied this reasoning, because + * thrashing doesn't solve anything. We keep a reasonable interval between + * these remedial actions. + */ + lastHMACEvent: 0, + + /* + * Returns whether to try again. + */ + async handleHMACEvent() { + let now = Date.now(); + + // Leave a sizable delay between HMAC recovery attempts. This gives us + // time for another client to fix themselves if we touch the record. + if (now - this.lastHMACEvent < HMAC_EVENT_INTERVAL) { + return false; + } + + this._log.info( + "Bad HMAC event detected. Attempting recovery " + + "or signaling to other clients." + ); + + // Set the last handled time so that we don't act again. + this.lastHMACEvent = now; + + // Fetch keys. + let cryptoKeys = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); + try { + let cryptoResp = ( + await cryptoKeys.fetch(this.resource(this.cryptoKeysURL)) + ).response; + + // Save out the ciphertext for when we reupload. If there's a bug in + // CollectionKeyManager, this will prevent us from uploading junk. + let cipherText = cryptoKeys.ciphertext; + + if (!cryptoResp.success) { + this._log.warn("Failed to download keys."); + return false; + } + + let keysChanged = await this.handleFetchedKeys( + this.identity.syncKeyBundle, + cryptoKeys, + true + ); + if (keysChanged) { + // Did they change? If so, carry on. + this._log.info("Suggesting retry."); + return true; // Try again. + } + + // If not, reupload them and continue the current sync. + cryptoKeys.ciphertext = cipherText; + cryptoKeys.cleartext = null; + + let uploadResp = await this._uploadCryptoKeys( + cryptoKeys, + cryptoResp.obj.modified + ); + if (uploadResp.success) { + this._log.info("Successfully re-uploaded keys. Continuing sync."); + } else { + this._log.warn( + "Got error response re-uploading keys. " + + "Continuing sync; let's try again later." + ); + } + + return false; // Don't try again: same keys. + } catch (ex) { + this._log.warn( + "Got exception fetching and handling crypto keys. " + + "Will try again later.", + ex + ); + return false; + } + }, + + async handleFetchedKeys(syncKey, cryptoKeys, skipReset) { + // Don't want to wipe if we're just starting up! + let wasBlank = this.collectionKeys.isClear; + let keysChanged = await this.collectionKeys.updateContents( + syncKey, + cryptoKeys + ); + + if (keysChanged && !wasBlank) { + this._log.debug("Keys changed: " + JSON.stringify(keysChanged)); + + if (!skipReset) { + this._log.info("Resetting client to reflect key change."); + + if (keysChanged.length) { + // Collection keys only. Reset individual engines. + await this.resetClient(keysChanged); + } else { + // Default key changed: wipe it all. + await this.resetClient(); + } + + this._log.info("Downloaded new keys, client reset. Proceeding."); + } + return true; + } + return false; + }, + + /** + * Prepare to initialize the rest of Weave after waiting a little bit + */ + async onStartup() { + this.status = Status; + this.identity = Status._authManager; + this.collectionKeys = new CollectionKeyManager(); + + this.scheduler = new SyncScheduler(this); + this.errorHandler = new ErrorHandler(this); + + this._log = Log.repository.getLogger("Sync.Service"); + this._log.manageLevelFromPref("services.sync.log.logger.service.main"); + + this._log.info("Loading Weave " + WEAVE_VERSION); + + this.recordManager = new RecordManager(this); + + this.enabled = true; + + await this._registerEngines(); + + let ua = Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ).userAgent; + this._log.info(ua); + + if (!this._checkCrypto()) { + this.enabled = false; + this._log.info( + "Could not load the Weave crypto component. Disabling " + + "Weave, since it will not work correctly." + ); + } + + Svc.Obs.add("weave:service:setup-complete", this); + Svc.Obs.add("sync:collection_changed", this); // Pulled from FxAccountsCommon + Svc.Obs.add("fxaccounts:device_disconnected", this); + Services.prefs.addObserver(PREFS_BRANCH + "engine.", this); + + if (!this.enabled) { + this._log.info("Firefox Sync disabled."); + } + + this._updateCachedURLs(); + + let status = this._checkSetup(); + if (status != STATUS_DISABLED && status != CLIENT_NOT_CONFIGURED) { + this._startTracking(); + } + + // Send an event now that Weave service is ready. We don't do this + // synchronously so that observers can import this module before + // registering an observer. + CommonUtils.nextTick(() => { + this.status.ready = true; + + // UI code uses the flag on the XPCOM service so it doesn't have + // to load a bunch of modules. + let xps = Cc["@mozilla.org/weave/service;1"].getService(Ci.nsISupports) + .wrappedJSObject; + xps.ready = true; + + Svc.Obs.notify("weave:service:ready"); + }); + }, + + _checkSetup: function _checkSetup() { + if (!this.enabled) { + return (this.status.service = STATUS_DISABLED); + } + return this.status.checkSetup(); + }, + + /** + * Register the built-in engines for certain applications + */ + async _registerEngines() { + this.engineManager = new EngineManager(this); + + let engineModules = getEngineModules(); + + let engines = []; + // We allow a pref, which has no default value, to limit the engines + // which are registered. We expect only tests will use this. + if (Svc.Prefs.has("registerEngines")) { + engines = Svc.Prefs.get("registerEngines").split(","); + this._log.info("Registering custom set of engines", engines); + } else { + // default is all engines. + engines = Object.keys(engineModules); + } + + let declined = []; + let pref = Svc.Prefs.get("declinedEngines"); + if (pref) { + declined = pref.split(","); + } + + let clientsEngine = new ClientEngine(this); + // Ideally clientsEngine should not exist + // (or be a promise that calls initialize() before returning the engine) + await clientsEngine.initialize(); + this.clientsEngine = clientsEngine; + + for (let name of engines) { + if (!(name in engineModules)) { + this._log.info("Do not know about engine: " + name); + continue; + } + let modInfo = engineModules[name]; + if (!modInfo.module.includes(":")) { + modInfo.module = "resource://services-sync/engines/" + modInfo.module; + } + let ns = {}; + try { + ChromeUtils.import(modInfo.module, ns); + if (modInfo.symbol) { + let symbol = modInfo.symbol; + if (!(symbol in ns)) { + this._log.warn( + "Could not find exported engine instance: " + symbol + ); + continue; + } + await this.engineManager.register(ns[symbol]); + } else { + let { whenTrue, whenFalse, controllingPref } = modInfo; + if (!(whenTrue in ns) || !(whenFalse in ns)) { + this._log.warn("Could not find all exported engine instances", { + whenTrue, + whenFalse, + }); + continue; + } + await this.engineManager.registerAlternatives( + name.toLowerCase(), + controllingPref, + ns[whenTrue], + ns[whenFalse] + ); + } + } catch (ex) { + this._log.warn("Could not register engine " + name, ex); + } + } + + this.engineManager.setDeclined(declined); + }, + + /** + * This method updates the local engines state from an existing meta/global + * when Sync is disabled. + * Running this code if sync is enabled would end up in very weird results + * (but we're nice and we check before doing anything!). + */ + async updateLocalEnginesState() { + await this.promiseInitialized; + + // Sanity check, this method is not meant to be run if Sync is enabled! + if (Svc.Prefs.get("username", "")) { + throw new Error("Sync is enabled!"); + } + + // For historical reasons the behaviour of setCluster() is bizarre, + // so just check what we care about - the meta URL. + if (!this.metaURL) { + await this.identity.setCluster(); + if (!this.metaURL) { + this._log.warn("Could not find a cluster."); + return; + } + } + // Clear the cache so we always fetch the latest meta/global. + this.recordManager.clearCache(); + let meta = await this.recordManager.get(this.metaURL); + if (!meta) { + this._log.info("Meta record is null, aborting engine state update."); + return; + } + const declinedEngines = meta.payload.declined; + const allEngines = this.engineManager.getAll().map(e => e.name); + // We don't want our observer of the enabled prefs to treat the change as + // a user-change, otherwise we will do the wrong thing with declined etc. + this._ignorePrefObserver = true; + try { + for (const engine of allEngines) { + Svc.Prefs.set(`engine.${engine}`, !declinedEngines.includes(engine)); + } + } finally { + this._ignorePrefObserver = false; + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIObserver", + "nsISupportsWeakReference", + ]), + + observe(subject, topic, data) { + switch (topic) { + // Ideally this observer should be in the SyncScheduler, but it would require + // some work to know about the sync specific engines. We should move this there once it does. + case "sync:collection_changed": + // We check if we're running TPS here to avoid TPS failing because it + // couldn't get to get the sync lock, due to us currently syncing the + // clients engine. + if (data.includes("clients") && !Svc.Prefs.get("testing.tps", false)) { + // Sync in the background (it's fine not to wait on the returned promise + // because sync() has a lock). + // [] = clients collection only + this.sync({ why: "collection_changed", engines: [] }).catch(e => { + this._log.error(e); + }); + } + break; + case "fxaccounts:device_disconnected": + data = JSON.parse(data); + if (!data.isLocalDevice) { + // Refresh the known stale clients list in the background. + this.clientsEngine.updateKnownStaleClients().catch(e => { + this._log.error(e); + }); + } + break; + case "weave:service:setup-complete": + let status = this._checkSetup(); + if (status != STATUS_DISABLED && status != CLIENT_NOT_CONFIGURED) { + this._startTracking(); + } + break; + case "nsPref:changed": + if (this._ignorePrefObserver) { + return; + } + const engine = data.slice((PREFS_BRANCH + "engine.").length); + if (engine.includes(".")) { + // A sub-preference of the engine was changed. For example + // `services.sync.engine.bookmarks.validation.percentageChance`. + return; + } + this._handleEngineStatusChanged(engine); + break; + } + }, + + _handleEngineStatusChanged(engine) { + this._log.trace("Status for " + engine + " engine changed."); + if (Svc.Prefs.get("engineStatusChanged." + engine, false)) { + // The enabled status being changed back to what it was before. + Svc.Prefs.reset("engineStatusChanged." + engine); + } else { + // Remember that the engine status changed locally until the next sync. + Svc.Prefs.set("engineStatusChanged." + engine, true); + } + }, + + _startTracking() { + const engines = [this.clientsEngine, ...this.engineManager.getAll()]; + for (let engine of engines) { + try { + engine.startTracking(); + } catch (e) { + this._log.error(`Could not start ${engine.name} engine tracker`, e); + } + } + // This is for TPS. We should try to do better. + Svc.Obs.notify("weave:service:tracking-started"); + }, + + async _stopTracking() { + const engines = [this.clientsEngine, ...this.engineManager.getAll()]; + for (let engine of engines) { + try { + await engine.stopTracking(); + } catch (e) { + this._log.error(`Could not stop ${engine.name} engine tracker`, e); + } + } + Svc.Obs.notify("weave:service:tracking-stopped"); + }, + + /** + * Obtain a Resource instance with authentication credentials. + */ + resource: function resource(url) { + let res = new Resource(url); + res.authenticator = this.identity.getResourceAuthenticator(); + + return res; + }, + + /** + * Perform the info fetch as part of a login or key fetch, or + * inside engine sync. + */ + async _fetchInfo(url) { + let infoURL = url || this.infoURL; + + this._log.trace("In _fetchInfo: " + infoURL); + let info; + try { + info = await this.resource(infoURL).get(); + } catch (ex) { + this.errorHandler.checkServerError(ex); + throw ex; + } + + // Always check for errors. + this.errorHandler.checkServerError(info); + if (!info.success) { + this._log.error("Aborting sync: failed to get collections."); + throw info; + } + return info; + }, + + async verifyAndFetchSymmetricKeys(infoResponse) { + this._log.debug( + "Fetching and verifying -- or generating -- symmetric keys." + ); + + let syncKeyBundle = this.identity.syncKeyBundle; + if (!syncKeyBundle) { + this.status.login = LOGIN_FAILED_NO_PASSPHRASE; + this.status.sync = CREDENTIALS_CHANGED; + return false; + } + + try { + if (!infoResponse) { + infoResponse = await this._fetchInfo(); // Will throw an exception on failure. + } + + // This only applies when the server is already at version 4. + if (infoResponse.status != 200) { + this._log.warn( + "info/collections returned non-200 response. Failing key fetch." + ); + this.status.login = LOGIN_FAILED_SERVER_ERROR; + this.errorHandler.checkServerError(infoResponse); + return false; + } + + let infoCollections = infoResponse.obj; + + this._log.info( + "Testing info/collections: " + JSON.stringify(infoCollections) + ); + + if (this.collectionKeys.updateNeeded(infoCollections)) { + this._log.info("collection keys reports that a key update is needed."); + + // Don't always set to CREDENTIALS_CHANGED -- we will probably take care of this. + + // Fetch storage/crypto/keys. + let cryptoKeys; + + if (infoCollections && CRYPTO_COLLECTION in infoCollections) { + try { + cryptoKeys = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); + let cryptoResp = ( + await cryptoKeys.fetch(this.resource(this.cryptoKeysURL)) + ).response; + + if (cryptoResp.success) { + await this.handleFetchedKeys(syncKeyBundle, cryptoKeys); + return true; + } else if (cryptoResp.status == 404) { + // On failure, ask to generate new keys and upload them. + // Fall through to the behavior below. + this._log.warn( + "Got 404 for crypto/keys, but 'crypto' in info/collections. Regenerating." + ); + cryptoKeys = null; + } else { + // Some other problem. + this.status.login = LOGIN_FAILED_SERVER_ERROR; + this.errorHandler.checkServerError(cryptoResp); + this._log.warn( + "Got status " + cryptoResp.status + " fetching crypto keys." + ); + return false; + } + } catch (ex) { + this._log.warn("Got exception fetching cryptoKeys.", ex); + // TODO: Um, what exceptions might we get here? Should we re-throw any? + + // One kind of exception: HMAC failure. + if (Utils.isHMACMismatch(ex)) { + this.status.login = LOGIN_FAILED_INVALID_PASSPHRASE; + this.status.sync = CREDENTIALS_CHANGED; + } else { + // In the absence of further disambiguation or more precise + // failure constants, just report failure. + this.status.login = LOGIN_FAILED; + } + return false; + } + } else { + this._log.info( + "... 'crypto' is not a reported collection. Generating new keys." + ); + } + + if (!cryptoKeys) { + this._log.info("No keys! Generating new ones."); + + // Better make some and upload them, and wipe the server to ensure + // consistency. This is all achieved via _freshStart. + // If _freshStart fails to clear the server or upload keys, it will + // throw. + await this._freshStart(); + return true; + } + + // Last-ditch case. + return false; + } + // No update needed: we're good! + return true; + } catch (ex) { + // This means no keys are present, or there's a network error. + this._log.debug("Failed to fetch and verify keys", ex); + this.errorHandler.checkServerError(ex); + return false; + } + }, + + getMaxRecordPayloadSize() { + let config = this.serverConfiguration; + if (!config || !config.max_record_payload_bytes) { + this._log.warn( + "No config or incomplete config in getMaxRecordPayloadSize." + + " Are we running tests?" + ); + return 256 * 1024; + } + let payloadMax = config.max_record_payload_bytes; + if (config.max_post_bytes && payloadMax <= config.max_post_bytes) { + return config.max_post_bytes - 4096; + } + return payloadMax; + }, + + getMemcacheMaxRecordPayloadSize() { + // Collections stored in memcached ("tabs", "clients" or "meta") have a + // different max size than ones stored in the normal storage server db. + // In practice, the real limit here is 1M (bug 1300451 comment 40), but + // there's overhead involved that is hard to calculate on the client, so we + // use 512k to be safe (at the recommendation of the server team). Note + // that if the server reports a lower limit (via info/configuration), we + // respect that limit instead. See also bug 1403052. + return Math.min(512 * 1024, this.getMaxRecordPayloadSize()); + }, + + async verifyLogin(allow40XRecovery = true) { + // Attaching auth credentials to a request requires access to + // passwords, which means that Resource.get can throw MP-related + // exceptions! + // So we ask the identity to verify the login state after unlocking the + // master password (ie, this call is expected to prompt for MP unlock + // if necessary) while we still have control. + this.status.login = await this.identity.unlockAndVerifyAuthState(); + this._log.debug( + "Fetching unlocked auth state returned " + this.status.login + ); + if (this.status.login != STATUS_OK) { + return false; + } + + try { + // Make sure we have a cluster to verify against. + // This is a little weird, if we don't get a node we pretend + // to succeed, since that probably means we just don't have storage. + if (this.clusterURL == "" && !(await this.identity.setCluster())) { + this.status.sync = NO_SYNC_NODE_FOUND; + return true; + } + + // Fetch collection info on every startup. + let test = await this.resource(this.infoURL).get(); + + switch (test.status) { + case 200: + // The user is authenticated. + + // We have no way of verifying the passphrase right now, + // so wait until remoteSetup to do so. + // Just make the most trivial checks. + if (!this.identity.syncKeyBundle) { + this._log.warn("No passphrase in verifyLogin."); + this.status.login = LOGIN_FAILED_NO_PASSPHRASE; + return false; + } + + // Go ahead and do remote setup, so that we can determine + // conclusively that our passphrase is correct. + if (await this._remoteSetup(test)) { + // Username/password verified. + this.status.login = LOGIN_SUCCEEDED; + return true; + } + + this._log.warn("Remote setup failed."); + // Remote setup must have failed. + return false; + + case 401: + this._log.warn("401: login failed."); + // Fall through to the 404 case. + + case 404: + // Check that we're verifying with the correct cluster + if (allow40XRecovery && (await this.identity.setCluster())) { + return await this.verifyLogin(false); + } + + // We must have the right cluster, but the server doesn't expect us. + // For FxA this almost certainly means "transient error fetching token". + this.status.login = LOGIN_FAILED_NETWORK_ERROR; + return false; + + default: + // Server didn't respond with something that we expected + this.status.login = LOGIN_FAILED_SERVER_ERROR; + this.errorHandler.checkServerError(test); + return false; + } + } catch (ex) { + // Must have failed on some network issue + this._log.debug("verifyLogin failed", ex); + this.status.login = LOGIN_FAILED_NETWORK_ERROR; + this.errorHandler.checkServerError(ex); + return false; + } + }, + + async generateNewSymmetricKeys() { + this._log.info("Generating new keys WBO..."); + let wbo = await this.collectionKeys.generateNewKeysWBO(); + this._log.info("Encrypting new key bundle."); + await wbo.encrypt(this.identity.syncKeyBundle); + + let uploadRes = await this._uploadCryptoKeys(wbo, 0); + if (uploadRes.status != 200) { + this._log.warn( + "Got status " + + uploadRes.status + + " uploading new keys. What to do? Throw!" + ); + this.errorHandler.checkServerError(uploadRes); + throw new Error("Unable to upload symmetric keys."); + } + this._log.info("Got status " + uploadRes.status + " uploading keys."); + let serverModified = uploadRes.obj; // Modified timestamp according to server. + this._log.debug("Server reports crypto modified: " + serverModified); + + // Now verify that info/collections shows them! + this._log.debug("Verifying server collection records."); + let info = await this._fetchInfo(); + this._log.debug("info/collections is: " + info.data); + + if (info.status != 200) { + this._log.warn("Non-200 info/collections response. Aborting."); + throw new Error("Unable to upload symmetric keys."); + } + + info = info.obj; + if (!(CRYPTO_COLLECTION in info)) { + this._log.error( + "Consistency failure: info/collections excludes " + + "crypto after successful upload." + ); + throw new Error("Symmetric key upload failed."); + } + + // Can't check against local modified: clock drift. + if (info[CRYPTO_COLLECTION] < serverModified) { + this._log.error( + "Consistency failure: info/collections crypto entry " + + "is stale after successful upload." + ); + throw new Error("Symmetric key upload failed."); + } + + // Doesn't matter if the timestamp is ahead. + + // Download and install them. + let cryptoKeys = new CryptoWrapper(CRYPTO_COLLECTION, KEYS_WBO); + let cryptoResp = (await cryptoKeys.fetch(this.resource(this.cryptoKeysURL))) + .response; + if (cryptoResp.status != 200) { + this._log.warn("Failed to download keys."); + throw new Error("Symmetric key download failed."); + } + let keysChanged = await this.handleFetchedKeys( + this.identity.syncKeyBundle, + cryptoKeys, + true + ); + if (keysChanged) { + this._log.info("Downloaded keys differed, as expected."); + } + }, + + // configures/enabled/turns-on sync. There must be an FxA user signed in. + async configure() { + // We don't, and must not, throw if sync is already configured, because we + // might end up being called as part of a "reconnect" flow. We also want to + // avoid checking the FxA user is the same as the pref because the email + // address for the FxA account can change - we'd need to use the uid. + let user = await fxAccounts.getSignedInUser(); + if (!user) { + throw new Error("No FxA user is signed in"); + } + this._log.info("Configuring sync with current FxA user"); + Svc.Prefs.set("username", user.email); + Svc.Obs.notify("weave:connected"); + }, + + // resets/turns-off sync. + async startOver() { + this._log.trace("Invoking Service.startOver."); + await this._stopTracking(); + this.status.resetSync(); + + // Deletion doesn't make sense if we aren't set up yet! + if (this.clusterURL != "") { + // Clear client-specific data from the server, including disabled engines. + const engines = [this.clientsEngine, ...this.engineManager.getAll()]; + for (let engine of engines) { + try { + await engine.removeClientData(); + } catch (ex) { + this._log.warn(`Deleting client data for ${engine.name} failed`, ex); + } + } + this._log.debug("Finished deleting client data."); + } else { + this._log.debug("Skipping client data removal: no cluster URL."); + } + + this.identity.resetCredentials(); + this.status.login = LOGIN_FAILED_NO_USERNAME; + this.logout(); + Svc.Obs.notify("weave:service:start-over"); + + // Reset all engines and clear keys. + await this.resetClient(); + this.collectionKeys.clear(); + this.status.resetBackoff(); + + // Reset Weave prefs. + this._ignorePrefObserver = true; + Svc.Prefs.resetBranch(""); + this._ignorePrefObserver = false; + this.clusterURL = null; + + Svc.Prefs.set("lastversion", WEAVE_VERSION); + + try { + this.identity.finalize(); + this.status.__authManager = null; + this.identity = Status._authManager; + Svc.Obs.notify("weave:service:start-over:finish"); + } catch (err) { + this._log.error( + "startOver failed to re-initialize the identity manager", + err + ); + // Still send the observer notification so the current state is + // reflected in the UI. + Svc.Obs.notify("weave:service:start-over:finish"); + } + }, + + async login() { + async function onNotify() { + this._loggedIn = false; + if (this.scheduler.offline) { + this.status.login = LOGIN_FAILED_NETWORK_ERROR; + throw new Error("Application is offline, login should not be called"); + } + + this._log.info("User logged in successfully - verifying login."); + if (!(await this.verifyLogin())) { + // verifyLogin sets the failure states here. + throw new Error(`Login failed: ${this.status.login}`); + } + + this._updateCachedURLs(); + + this._loggedIn = true; + + return true; + } + + let notifier = this._notify("login", "", onNotify.bind(this)); + return this._catch(this._lock("service.js: login", notifier))(); + }, + + logout: function logout() { + // If we failed during login, we aren't going to have this._loggedIn set, + // but we still want to ask the identity to logout, so it doesn't try and + // reuse any old credentials next time we sync. + this._log.info("Logging out"); + this.identity.logout(); + this._loggedIn = false; + + Svc.Obs.notify("weave:service:logout:finish"); + }, + + // Note: returns false if we failed for a reason other than the server not yet + // supporting the api. + async _fetchServerConfiguration() { + // This is similar to _fetchInfo, but with different error handling. + + let infoURL = this.userBaseURL + "info/configuration"; + this._log.debug("Fetching server configuration", infoURL); + let configResponse; + try { + configResponse = await this.resource(infoURL).get(); + } catch (ex) { + // This is probably a network or similar error. + this._log.warn("Failed to fetch info/configuration", ex); + this.errorHandler.checkServerError(ex); + return false; + } + + if (configResponse.status == 404) { + // This server doesn't support the URL yet - that's OK. + this._log.debug( + "info/configuration returned 404 - using default upload semantics" + ); + } else if (configResponse.status != 200) { + this._log.warn( + `info/configuration returned ${configResponse.status} - using default configuration` + ); + this.errorHandler.checkServerError(configResponse); + return false; + } else { + this.serverConfiguration = configResponse.obj; + } + this._log.trace( + "info/configuration for this server", + this.serverConfiguration + ); + return true; + }, + + // Stuff we need to do after login, before we can really do + // anything (e.g. key setup). + async _remoteSetup(infoResponse, fetchConfig = true) { + if (fetchConfig && !(await this._fetchServerConfiguration())) { + return false; + } + + this._log.debug("Fetching global metadata record"); + let meta = await this.recordManager.get(this.metaURL); + + // Checking modified time of the meta record. + if ( + infoResponse && + infoResponse.obj.meta != this.metaModified && + (!meta || !meta.isNew) + ) { + // Delete the cached meta record... + this._log.debug( + "Clearing cached meta record. metaModified is " + + JSON.stringify(this.metaModified) + + ", setting to " + + JSON.stringify(infoResponse.obj.meta) + ); + + this.recordManager.del(this.metaURL); + + // ... fetch the current record from the server, and COPY THE FLAGS. + let newMeta = await this.recordManager.get(this.metaURL); + + // If we got a 401, we do not want to create a new meta/global - we + // should be able to get the existing meta after we get a new node. + if (this.recordManager.response.status == 401) { + this._log.debug( + "Fetching meta/global record on the server returned 401." + ); + this.errorHandler.checkServerError(this.recordManager.response); + return false; + } + + if (this.recordManager.response.status == 404) { + this._log.debug("No meta/global record on the server. Creating one."); + try { + await this._uploadNewMetaGlobal(); + } catch (uploadRes) { + this._log.warn( + "Unable to upload new meta/global. Failing remote setup." + ); + this.errorHandler.checkServerError(uploadRes); + return false; + } + } else if (!newMeta) { + this._log.warn("Unable to get meta/global. Failing remote setup."); + this.errorHandler.checkServerError(this.recordManager.response); + return false; + } else { + // If newMeta, then it stands to reason that meta != null. + newMeta.isNew = meta.isNew; + newMeta.changed = meta.changed; + } + + // Switch in the new meta object and record the new time. + meta = newMeta; + this.metaModified = infoResponse.obj.meta; + } + + let remoteVersion = + meta && meta.payload.storageVersion ? meta.payload.storageVersion : ""; + + this._log.debug( + [ + "Weave Version:", + WEAVE_VERSION, + "Local Storage:", + STORAGE_VERSION, + "Remote Storage:", + remoteVersion, + ].join(" ") + ); + + // Check for cases that require a fresh start. When comparing remoteVersion, + // we need to convert it to a number as older clients used it as a string. + if ( + !meta || + !meta.payload.storageVersion || + !meta.payload.syncID || + STORAGE_VERSION > parseFloat(remoteVersion) + ) { + this._log.info( + "One of: no meta, no meta storageVersion, or no meta syncID. Fresh start needed." + ); + + // abort the server wipe if the GET status was anything other than 404 or 200 + let status = this.recordManager.response.status; + if (status != 200 && status != 404) { + this.status.sync = METARECORD_DOWNLOAD_FAIL; + this.errorHandler.checkServerError(this.recordManager.response); + this._log.warn( + "Unknown error while downloading metadata record. Aborting sync." + ); + return false; + } + + if (!meta) { + this._log.info("No metadata record, server wipe needed"); + } + if (meta && !meta.payload.syncID) { + this._log.warn("No sync id, server wipe needed"); + } + + this._log.info("Wiping server data"); + await this._freshStart(); + + if (status == 404) { + this._log.info( + "Metadata record not found, server was wiped to ensure " + + "consistency." + ); + } else { + // 200 + this._log.info("Wiped server; incompatible metadata: " + remoteVersion); + } + return true; + } else if (remoteVersion > STORAGE_VERSION) { + this.status.sync = VERSION_OUT_OF_DATE; + this._log.warn("Upgrade required to access newer storage version."); + return false; + } else if (meta.payload.syncID != this.syncID) { + this._log.info( + "Sync IDs differ. Local is " + + this.syncID + + ", remote is " + + meta.payload.syncID + ); + await this.resetClient(); + this.collectionKeys.clear(); + this.syncID = meta.payload.syncID; + this._log.debug("Clear cached values and take syncId: " + this.syncID); + + if (!(await this.verifyAndFetchSymmetricKeys(infoResponse))) { + this._log.warn("Failed to fetch symmetric keys. Failing remote setup."); + return false; + } + + // bug 545725 - re-verify creds and fail sanely + if (!(await this.verifyLogin())) { + this.status.sync = CREDENTIALS_CHANGED; + this._log.info( + "Credentials have changed, aborting sync and forcing re-login." + ); + return false; + } + + return true; + } + if (!(await this.verifyAndFetchSymmetricKeys(infoResponse))) { + this._log.warn("Failed to fetch symmetric keys. Failing remote setup."); + return false; + } + + return true; + }, + + /** + * Return whether we should attempt login at the start of a sync. + * + * Note that this function has strong ties to _checkSync: callers + * of this function should typically use _checkSync to verify that + * any necessary login took place. + */ + _shouldLogin: function _shouldLogin() { + return ( + this.enabled && + !this.scheduler.offline && + !this.isLoggedIn && + Async.isAppReady() + ); + }, + + /** + * Determine if a sync should run. + * + * @param ignore [optional] + * array of reasons to ignore when checking + * + * @return Reason for not syncing; not-truthy if sync should run + */ + _checkSync: function _checkSync(ignore) { + let reason = ""; + // Ideally we'd call _checkSetup() here but that has too many side-effects. + if (Status.service == CLIENT_NOT_CONFIGURED) { + reason = kSyncNotConfigured; + } else if (Status.service == STATUS_DISABLED || !this.enabled) { + reason = kSyncWeaveDisabled; + } else if (this.scheduler.offline) { + reason = kSyncNetworkOffline; + } else if (this.status.minimumNextSync > Date.now()) { + reason = kSyncBackoffNotMet; + } else if ( + this.status.login == MASTER_PASSWORD_LOCKED && + Utils.mpLocked() + ) { + reason = kSyncMasterPasswordLocked; + } else if (Svc.Prefs.get("firstSync") == "notReady") { + reason = kFirstSyncChoiceNotMade; + } else if (!Async.isAppReady()) { + reason = kFirefoxShuttingDown; + } + + if (ignore && ignore.includes(reason)) { + return ""; + } + + return reason; + }, + + async sync({ engines, why } = {}) { + let dateStr = Utils.formatTimestamp(new Date()); + this._log.debug("User-Agent: " + Utils.userAgent); + await this.promiseInitialized; + this._log.info( + `Starting sync at ${dateStr} in browser session ${browserSessionID}` + ); + return this._catch(async function() { + // Make sure we're logged in. + if (this._shouldLogin()) { + this._log.debug("In sync: should login."); + if (!(await this.login())) { + this._log.debug("Not syncing: login returned false."); + return; + } + } else { + this._log.trace("In sync: no need to login."); + } + await this._lockedSync(engines, why); + })(); + }, + + /** + * Sync up engines with the server. + */ + async _lockedSync(engineNamesToSync, why) { + return this._lock( + "service.js: sync", + this._notify("sync", JSON.stringify({ why }), async function onNotify() { + let histogram = Services.telemetry.getHistogramById( + "WEAVE_START_COUNT" + ); + histogram.add(1); + + let synchronizer = new EngineSynchronizer(this); + await synchronizer.sync(engineNamesToSync, why); // Might throw! + + histogram = Services.telemetry.getHistogramById( + "WEAVE_COMPLETE_SUCCESS_COUNT" + ); + histogram.add(1); + + // We successfully synchronized. + // Check if the identity wants to pre-fetch a migration sentinel from + // the server. + // If we have no clusterURL, we are probably doing a node reassignment + // so don't attempt to get it in that case. + if (this.clusterURL) { + this.identity.prefetchMigrationSentinel(this); + } + + // Now let's update our declined engines + await this._maybeUpdateDeclined(); + }) + )(); + }, + + /** + * Update the "declined" information in meta/global if necessary. + */ + async _maybeUpdateDeclined() { + // if Sync failed due to no node we will not have a meta URL, so can't + // update anything. + if (!this.metaURL) { + return; + } + let meta = await this.recordManager.get(this.metaURL); + if (!meta) { + this._log.warn("No meta/global; can't update declined state."); + return; + } + + let declinedEngines = new DeclinedEngines(this); + let didChange = declinedEngines.updateDeclined(meta, this.engineManager); + if (!didChange) { + this._log.info( + "No change to declined engines. Not reuploading meta/global." + ); + return; + } + + await this.uploadMetaGlobal(meta); + }, + + /** + * Upload a fresh meta/global record + * @throws the response object if the upload request was not a success + */ + async _uploadNewMetaGlobal() { + let meta = new WBORecord("meta", "global"); + meta.payload.syncID = this.syncID; + meta.payload.storageVersion = STORAGE_VERSION; + meta.payload.declined = this.engineManager.getDeclined(); + meta.modified = 0; + meta.isNew = true; + + await this.uploadMetaGlobal(meta); + }, + + /** + * Upload meta/global, throwing the response on failure + * @param {WBORecord} meta meta/global record + * @throws the response object if the request was not a success + */ + async uploadMetaGlobal(meta) { + this._log.debug("Uploading meta/global", meta); + let res = this.resource(this.metaURL); + res.setHeader("X-If-Unmodified-Since", meta.modified); + let response = await res.put(meta); + if (!response.success) { + throw response; + } + // From https://docs.services.mozilla.com/storage/apis-1.5.html: + // "Successful responses will return the new last-modified time for the collection." + meta.modified = response.obj; + this.recordManager.set(this.metaURL, meta); + }, + + /** + * Upload crypto/keys + * @param {WBORecord} cryptoKeys crypto/keys record + * @param {Number} lastModified known last modified timestamp (in decimal seconds), + * will be used to set the X-If-Unmodified-Since header + */ + async _uploadCryptoKeys(cryptoKeys, lastModified) { + this._log.debug(`Uploading crypto/keys (lastModified: ${lastModified})`); + let res = this.resource(this.cryptoKeysURL); + res.setHeader("X-If-Unmodified-Since", lastModified); + return res.put(cryptoKeys); + }, + + async _freshStart() { + this._log.info("Fresh start. Resetting client."); + await this.resetClient(); + this.collectionKeys.clear(); + + // Wipe the server. + await this.wipeServer(); + + // Upload a new meta/global record. + // _uploadNewMetaGlobal throws on failure -- including race conditions. + // If we got into a race condition, we'll abort the sync this way, too. + // That's fine. We'll just wait till the next sync. The client that we're + // racing is probably busy uploading stuff right now anyway. + await this._uploadNewMetaGlobal(); + + // Wipe everything we know about except meta because we just uploaded it + // TODO: there's a bug here. We should be calling resetClient, no? + + // Generate, upload, and download new keys. Do this last so we don't wipe + // them... + await this.generateNewSymmetricKeys(); + }, + + /** + * Wipe user data from the server. + * + * @param collections [optional] + * Array of collections to wipe. If not given, all collections are + * wiped by issuing a DELETE request for `storageURL`. + * + * @return the server's timestamp of the (last) DELETE. + */ + async wipeServer(collections) { + let response; + let histogram = Services.telemetry.getHistogramById( + "WEAVE_WIPE_SERVER_SUCCEEDED" + ); + if (!collections) { + // Strip the trailing slash. + let res = this.resource(this.storageURL.slice(0, -1)); + res.setHeader("X-Confirm-Delete", "1"); + try { + response = await res.delete(); + } catch (ex) { + this._log.debug("Failed to wipe server", ex); + histogram.add(false); + throw ex; + } + if (response.status != 200 && response.status != 404) { + this._log.debug( + "Aborting wipeServer. Server responded with " + + response.status + + " response for " + + this.storageURL + ); + histogram.add(false); + throw response; + } + histogram.add(true); + return response.headers["x-weave-timestamp"]; + } + + let timestamp; + for (let name of collections) { + let url = this.storageURL + name; + try { + response = await this.resource(url).delete(); + } catch (ex) { + this._log.debug("Failed to wipe '" + name + "' collection", ex); + histogram.add(false); + throw ex; + } + + if (response.status != 200 && response.status != 404) { + this._log.debug( + "Aborting wipeServer. Server responded with " + + response.status + + " response for " + + url + ); + histogram.add(false); + throw response; + } + + if ("x-weave-timestamp" in response.headers) { + timestamp = response.headers["x-weave-timestamp"]; + } + } + histogram.add(true); + return timestamp; + }, + + /** + * Wipe all local user data. + * + * @param engines [optional] + * Array of engine names to wipe. If not given, all engines are used. + */ + async wipeClient(engines) { + // If we don't have any engines, reset the service and wipe all engines + if (!engines) { + // Clear out any service data + await this.resetService(); + + engines = [this.clientsEngine, ...this.engineManager.getAll()]; + } else { + // Convert the array of names into engines + engines = this.engineManager.get(engines); + } + + // Fully wipe each engine if it's able to decrypt data + for (let engine of engines) { + if (await engine.canDecrypt()) { + await engine.wipeClient(); + } + } + }, + + /** + * Wipe all remote user data by wiping the server then telling each remote + * client to wipe itself. + * + * @param engines [optional] + * Array of engine names to wipe. If not given, all engines are used. + */ + async wipeRemote(engines) { + try { + // Make sure stuff gets uploaded. + await this.resetClient(engines); + + // Clear out any server data. + await this.wipeServer(engines); + + // Only wipe the engines provided. + let extra = { reason: "wipe-remote" }; + if (engines) { + for (const e of engines) { + await this.clientsEngine.sendCommand("wipeEngine", [e], null, extra); + } + } else { + // Tell the remote machines to wipe themselves. + await this.clientsEngine.sendCommand("wipeAll", [], null, extra); + } + + // Make sure the changed clients get updated. + await this.clientsEngine.sync(); + } catch (ex) { + this.errorHandler.checkServerError(ex); + throw ex; + } + }, + + /** + * Reset local service information like logs, sync times, caches. + */ + async resetService() { + return this._catch(async function reset() { + this._log.info("Service reset."); + + // Pretend we've never synced to the server and drop cached data + this.syncID = ""; + this.recordManager.clearCache(); + })(); + }, + + /** + * Reset the client by getting rid of any local server data and client data. + * + * @param engines [optional] + * Array of engine names to reset. If not given, all engines are used. + */ + async resetClient(engines) { + return this._catch(async function doResetClient() { + // If we don't have any engines, reset everything including the service + if (!engines) { + // Clear out any service data + await this.resetService(); + + engines = [this.clientsEngine, ...this.engineManager.getAll()]; + } else { + // Convert the array of names into engines + engines = this.engineManager.get(engines); + } + + // Have each engine drop any temporary meta data + for (let engine of engines) { + await engine.resetClient(); + } + })(); + }, + + recordTelemetryEvent(object, method, value, extra = undefined) { + Svc.Obs.notify("weave:telemetry:event", { object, method, value, extra }); + }, +}; + +var Service = new Sync11Service(); +this.Service.promiseInitialized = new Promise(resolve => { + this.Service.onStartup().then(resolve); +}); diff --git a/services/sync/modules/stages/declined.js b/services/sync/modules/stages/declined.js new file mode 100644 index 0000000000..66bb24643f --- /dev/null +++ b/services/sync/modules/stages/declined.js @@ -0,0 +1,84 @@ +/* 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/. */ + +/** + * This file contains code for maintaining the set of declined engines, + * in conjunction with EngineManager. + */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["DeclinedEngines"]; + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); + +var DeclinedEngines = function(service) { + this._log = Log.repository.getLogger("Sync.Declined"); + this._log.manageLevelFromPref("services.sync.log.logger.declined"); + + this.service = service; +}; +this.DeclinedEngines.prototype = { + updateDeclined(meta, engineManager = this.service.engineManager) { + let enabled = new Set(engineManager.getEnabled().map(e => e.name)); + let known = new Set(engineManager.getAll().map(e => e.name)); + let remoteDeclined = new Set(meta.payload.declined || []); + let localDeclined = new Set(engineManager.getDeclined()); + + this._log.debug( + "Handling remote declined: " + JSON.stringify([...remoteDeclined]) + ); + this._log.debug( + "Handling local declined: " + JSON.stringify([...localDeclined]) + ); + + // Any engines that are locally enabled should be removed from the remote + // declined list. + // + // Any engines that are locally declined should be added to the remote + // declined list. + let newDeclined = CommonUtils.union( + localDeclined, + CommonUtils.difference(remoteDeclined, enabled) + ); + + // If our declined set has changed, put it into the meta object and mark + // it as changed. + let declinedChanged = !CommonUtils.setEqual(newDeclined, remoteDeclined); + this._log.debug("Declined changed? " + declinedChanged); + if (declinedChanged) { + meta.changed = true; + meta.payload.declined = [...newDeclined]; + } + + // Update the engine manager regardless. + engineManager.setDeclined(newDeclined); + + // Any engines that are locally known, locally disabled, and not remotely + // or locally declined, are candidates for enablement. + let undecided = CommonUtils.difference( + CommonUtils.difference(known, enabled), + newDeclined + ); + if (undecided.size) { + let subject = { + declined: newDeclined, + enabled, + known, + undecided, + }; + CommonUtils.nextTick(() => { + Observers.notify("weave:engines:notdeclined", subject); + }); + } + + return declinedChanged; + }, +}; diff --git a/services/sync/modules/stages/enginesync.js b/services/sync/modules/stages/enginesync.js new file mode 100644 index 0000000000..bbc4f7a509 --- /dev/null +++ b/services/sync/modules/stages/enginesync.js @@ -0,0 +1,399 @@ +/* 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/. */ + +/** + * This file contains code for synchronizing engines. + */ + +var EXPORTED_SYMBOLS = ["EngineSynchronizer"]; + +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { + ABORT_SYNC_COMMAND, + LOGIN_FAILED_NETWORK_ERROR, + NO_SYNC_NODE_FOUND, + STATUS_OK, + SYNC_FAILED_PARTIAL, + SYNC_SUCCEEDED, + WEAVE_VERSION, + kSyncNetworkOffline, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Svc, Utils } = ChromeUtils.import("resource://services-sync/util.js"); +const { Async } = ChromeUtils.import("resource://services-common/async.js"); +ChromeUtils.defineModuleGetter( + this, + "Doctor", + "resource://services-sync/doctor.js" +); + +/** + * Perform synchronization of engines. + * + * This was originally split out of service.js. The API needs lots of love. + */ +function EngineSynchronizer(service) { + this._log = Log.repository.getLogger("Sync.Synchronizer"); + this._log.manageLevelFromPref("services.sync.log.logger.synchronizer"); + + this.service = service; +} + +EngineSynchronizer.prototype = { + async sync(engineNamesToSync, why) { + let fastSync = why && why == "sleep"; + let startTime = Date.now(); + + this.service.status.resetSync(); + + // Make sure we should sync or record why we shouldn't. + let reason = this.service._checkSync(); + if (reason) { + if (reason == kSyncNetworkOffline) { + this.service.status.sync = LOGIN_FAILED_NETWORK_ERROR; + } + + // this is a purposeful abort rather than a failure, so don't set + // any status bits + reason = "Can't sync: " + reason; + throw new Error(reason); + } + + // If we don't have a node, get one. If that fails, retry in 10 minutes. + if ( + !this.service.clusterURL && + !(await this.service.identity.setCluster()) + ) { + this.service.status.sync = NO_SYNC_NODE_FOUND; + this._log.info("No cluster URL found. Cannot sync."); + return; + } + + // Ping the server with a special info request once a day. + let infoURL = this.service.infoURL; + let now = Math.floor(Date.now() / 1000); + let lastPing = Svc.Prefs.get("lastPing", 0); + if (now - lastPing > 86400) { + // 60 * 60 * 24 + infoURL += "?v=" + WEAVE_VERSION; + Svc.Prefs.set("lastPing", now); + } + + let engineManager = this.service.engineManager; + + // Figure out what the last modified time is for each collection + let info = await this.service._fetchInfo(infoURL); + + // Convert the response to an object and read out the modified times + for (let engine of [this.service.clientsEngine].concat( + engineManager.getAll() + )) { + engine.lastModified = info.obj[engine.name] || 0; + } + + if (!(await this.service._remoteSetup(info, !fastSync))) { + throw new Error("Aborting sync, remote setup failed"); + } + + if (!fastSync) { + // Make sure we have an up-to-date list of clients before sending commands + this._log.debug("Refreshing client list."); + if (!(await this._syncEngine(this.service.clientsEngine))) { + // Clients is an engine like any other; it can fail with a 401, + // and we can elect to abort the sync. + this._log.warn("Client engine sync failed. Aborting."); + return; + } + } + + // We only honor the "hint" of what engines to Sync if this isn't + // a first sync. + let allowEnginesHint = false; + // Wipe data in the desired direction if necessary + switch (Svc.Prefs.get("firstSync")) { + case "resetClient": + await this.service.resetClient(engineManager.enabledEngineNames); + break; + case "wipeClient": + await this.service.wipeClient(engineManager.enabledEngineNames); + break; + case "wipeRemote": + await this.service.wipeRemote(engineManager.enabledEngineNames); + break; + default: + allowEnginesHint = true; + break; + } + + if (!fastSync && this.service.clientsEngine.localCommands) { + try { + if (!(await this.service.clientsEngine.processIncomingCommands())) { + this.service.status.sync = ABORT_SYNC_COMMAND; + throw new Error("Processed command aborted sync."); + } + + // Repeat remoteSetup in-case the commands forced us to reset + if (!(await this.service._remoteSetup(info))) { + throw new Error("Remote setup failed after processing commands."); + } + } finally { + // Always immediately attempt to push back the local client (now + // without commands). + // Note that we don't abort here; if there's a 401 because we've + // been reassigned, we'll handle it around another engine. + await this._syncEngine(this.service.clientsEngine); + } + } + + // Update engines because it might change what we sync. + try { + await this._updateEnabledEngines(); + } catch (ex) { + this._log.debug("Updating enabled engines failed", ex); + this.service.errorHandler.checkServerError(ex); + throw ex; + } + + await this.service.engineManager.switchAlternatives(); + + // If the engines to sync has been specified, we sync in the order specified. + let enginesToSync; + if (allowEnginesHint && engineNamesToSync) { + this._log.info("Syncing specified engines", engineNamesToSync); + enginesToSync = engineManager + .get(engineNamesToSync) + .filter(e => e.enabled); + } else { + this._log.info("Syncing all enabled engines."); + enginesToSync = engineManager.getEnabled(); + } + try { + // We don't bother validating engines that failed to sync. + let enginesToValidate = []; + for (let engine of enginesToSync) { + if (engine.shouldSkipSync(why)) { + this._log.info(`Engine ${engine.name} asked to be skipped`); + continue; + } + // If there's any problems with syncing the engine, report the failure + if ( + !(await this._syncEngine(engine)) || + this.service.status.enforceBackoff + ) { + this._log.info("Aborting sync for failure in " + engine.name); + break; + } + enginesToValidate.push(engine); + } + + // If _syncEngine fails for a 401, we might not have a cluster URL here. + // If that's the case, break out of this immediately, rather than + // throwing an exception when trying to fetch metaURL. + if (!this.service.clusterURL) { + this._log.debug( + "Aborting sync, no cluster URL: not uploading new meta/global." + ); + return; + } + + // Upload meta/global if any engines changed anything. + let meta = await this.service.recordManager.get(this.service.metaURL); + if (meta.isNew || meta.changed) { + this._log.info("meta/global changed locally: reuploading."); + try { + await this.service.uploadMetaGlobal(meta); + delete meta.isNew; + delete meta.changed; + } catch (error) { + this._log.error( + "Unable to upload meta/global. Leaving marked as new." + ); + } + } + + if (!fastSync) { + await Doctor.consult(enginesToValidate); + } + + // If there were no sync engine failures + if (this.service.status.service != SYNC_FAILED_PARTIAL) { + this.service.status.sync = SYNC_SUCCEEDED; + } + + // Even if there were engine failures, bump lastSync even on partial since + // it's reflected in the UI (bug 1439777). + if ( + this.service.status.service == SYNC_FAILED_PARTIAL || + this.service.status.service == STATUS_OK + ) { + Svc.Prefs.set("lastSync", new Date().toString()); + } + } finally { + Svc.Prefs.reset("firstSync"); + + let syncTime = ((Date.now() - startTime) / 1000).toFixed(2); + let dateStr = Utils.formatTimestamp(new Date()); + this._log.info( + "Sync completed at " + dateStr + " after " + syncTime + " secs." + ); + } + }, + + // Returns true if sync should proceed. + // false / no return value means sync should be aborted. + async _syncEngine(engine) { + try { + await engine.sync(); + } catch (e) { + if (e.status == 401) { + // Maybe a 401, cluster update perhaps needed? + // We rely on ErrorHandler observing the sync failure notification to + // schedule another sync and clear node assignment values. + // Here we simply want to muffle the exception and return an + // appropriate value. + return false; + } + // Note that policies.js has already logged info about the exception... + if (Async.isShutdownException(e)) { + // Failure due to a shutdown exception should prevent other engines + // trying to start and immediately failing. + this._log.info( + `${engine.name} was interrupted by shutdown; no other engines will sync` + ); + return false; + } + } + + return true; + }, + + async _updateEnabledFromMeta( + meta, + numClients, + engineManager = this.service.engineManager + ) { + this._log.info("Updating enabled engines: " + numClients + " clients."); + + if (meta.isNew || !meta.payload.engines) { + this._log.debug( + "meta/global isn't new, or is missing engines. Not updating enabled state." + ); + return; + } + + // If we're the only client, and no engines are marked as enabled, + // thumb our noses at the server data: it can't be right. + // Belt-and-suspenders approach to Bug 615926. + let hasEnabledEngines = false; + for (let e in meta.payload.engines) { + if (e != "clients") { + hasEnabledEngines = true; + break; + } + } + + if (numClients <= 1 && !hasEnabledEngines) { + this._log.info( + "One client and no enabled engines: not touching local engine status." + ); + return; + } + + this.service._ignorePrefObserver = true; + + let enabled = engineManager.enabledEngineNames; + + let toDecline = new Set(); + let toUndecline = new Set(); + + for (let engineName in meta.payload.engines) { + if (engineName == "clients") { + // Clients is special. + continue; + } + let index = enabled.indexOf(engineName); + if (index != -1) { + // The engine is enabled locally. Nothing to do. + enabled.splice(index, 1); + continue; + } + let engine = engineManager.get(engineName); + if (!engine) { + // The engine doesn't exist locally. Nothing to do. + continue; + } + + let attemptedEnable = false; + // If the engine was enabled remotely, enable it locally. + if (!Svc.Prefs.get("engineStatusChanged." + engine.prefName, false)) { + this._log.trace( + "Engine " + engineName + " was enabled. Marking as non-declined." + ); + toUndecline.add(engineName); + this._log.trace(engineName + " engine was enabled remotely."); + engine.enabled = true; + // Note that setting engine.enabled to true might not have worked for + // the password engine if a master-password is enabled. However, it's + // still OK that we added it to undeclined - the user *tried* to enable + // it remotely - so it still winds up as not being flagged as declined + // even though it's disabled remotely. + attemptedEnable = true; + } + + // If either the engine was disabled locally or enabling the engine + // failed (see above re master-password) then wipe server data and + // disable it everywhere. + if (!engine.enabled) { + this._log.trace("Wiping data for " + engineName + " engine."); + await engine.wipeServer(); + delete meta.payload.engines[engineName]; + meta.changed = true; // the new enabled state must propagate + // We also here mark the engine as declined, because the pref + // was explicitly changed to false - unless we tried, and failed, + // to enable it - in which case we leave the declined state alone. + if (!attemptedEnable) { + // This will be reflected in meta/global in the next stage. + this._log.trace( + "Engine " + + engineName + + " was disabled locally. Marking as declined." + ); + toDecline.add(engineName); + } + } + } + + // Any remaining engines were either enabled locally or disabled remotely. + for (let engineName of enabled) { + let engine = engineManager.get(engineName); + if (Svc.Prefs.get("engineStatusChanged." + engine.prefName, false)) { + this._log.trace("The " + engineName + " engine was enabled locally."); + toUndecline.add(engineName); + } else { + this._log.trace("The " + engineName + " engine was disabled remotely."); + + // Don't automatically mark it as declined! + try { + engine.enabled = false; + } catch (e) { + this._log.trace("Failed to disable engine " + engineName); + } + } + } + + engineManager.decline(toDecline); + engineManager.undecline(toUndecline); + + Svc.Prefs.resetBranch("engineStatusChanged."); + this.service._ignorePrefObserver = false; + }, + + async _updateEnabledEngines() { + let meta = await this.service.recordManager.get(this.service.metaURL); + let numClients = this.service.scheduler.numClients; + let engineManager = this.service.engineManager; + + await this._updateEnabledFromMeta(meta, numClients, engineManager); + }, +}; +Object.freeze(EngineSynchronizer.prototype); diff --git a/services/sync/modules/status.js b/services/sync/modules/status.js new file mode 100644 index 0000000000..9a9497f088 --- /dev/null +++ b/services/sync/modules/status.js @@ -0,0 +1,137 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["Status"]; + +const { + CLIENT_NOT_CONFIGURED, + ENGINE_SUCCEEDED, + LOGIN_FAILED, + LOGIN_FAILED_NO_PASSPHRASE, + LOGIN_FAILED_NO_USERNAME, + LOGIN_SUCCEEDED, + STATUS_OK, + SYNC_FAILED, + SYNC_FAILED_PARTIAL, + SYNC_SUCCEEDED, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Log } = ChromeUtils.import("resource://gre/modules/Log.jsm"); +const { BrowserIDManager } = ChromeUtils.import( + "resource://services-sync/browserid_identity.js" +); + +var Status = { + _log: Log.repository.getLogger("Sync.Status"), + __authManager: null, + ready: false, + + get _authManager() { + if (this.__authManager) { + return this.__authManager; + } + this.__authManager = new BrowserIDManager(); + return this.__authManager; + }, + + get service() { + return this._service; + }, + + set service(code) { + this._log.debug( + "Status.service: " + (this._service || undefined) + " => " + code + ); + this._service = code; + }, + + get login() { + return this._login; + }, + + set login(code) { + this._log.debug("Status.login: " + this._login + " => " + code); + this._login = code; + + if ( + code == LOGIN_FAILED_NO_USERNAME || + code == LOGIN_FAILED_NO_PASSPHRASE + ) { + this.service = CLIENT_NOT_CONFIGURED; + } else if (code != LOGIN_SUCCEEDED) { + this.service = LOGIN_FAILED; + } else { + this.service = STATUS_OK; + } + }, + + get sync() { + return this._sync; + }, + + set sync(code) { + this._log.debug("Status.sync: " + this._sync + " => " + code); + this._sync = code; + this.service = code == SYNC_SUCCEEDED ? STATUS_OK : SYNC_FAILED; + }, + + get engines() { + return this._engines; + }, + + set engines([name, code]) { + this._log.debug("Status for engine " + name + ": " + code); + this._engines[name] = code; + + if (code != ENGINE_SUCCEEDED) { + this.service = SYNC_FAILED_PARTIAL; + } + }, + + // Implement toString because adding a logger introduces a cyclic object + // value, so we can't trivially debug-print Status as JSON. + toString: function toString() { + return ( + "<Status" + + ": login: " + + Status.login + + ", service: " + + Status.service + + ", sync: " + + Status.sync + + ">" + ); + }, + + checkSetup: function checkSetup() { + if (!this._authManager.username) { + Status.login = LOGIN_FAILED_NO_USERNAME; + Status.service = CLIENT_NOT_CONFIGURED; + } else if (Status.login == STATUS_OK) { + Status.service = STATUS_OK; + } + return Status.service; + }, + + resetBackoff: function resetBackoff() { + this.enforceBackoff = false; + this.backoffInterval = 0; + this.minimumNextSync = 0; + }, + + resetSync: function resetSync() { + // Logger setup. + this._log.manageLevelFromPref("services.sync.log.logger.status"); + + this._log.info("Resetting Status."); + this.service = STATUS_OK; + this._login = LOGIN_SUCCEEDED; + this._sync = SYNC_SUCCEEDED; + this._engines = {}; + this.partial = false; + }, +}; + +// Initialize various status values. +Status.resetBackoff(); +Status.resetSync(); diff --git a/services/sync/modules/telemetry.js b/services/sync/modules/telemetry.js new file mode 100644 index 0000000000..12150ade91 --- /dev/null +++ b/services/sync/modules/telemetry.js @@ -0,0 +1,1111 @@ +/* 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/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["SyncTelemetry"]; + +// Support for Sync-and-FxA-related telemetry, which is submitted in a special-purpose +// telemetry ping called the "sync ping", documented here: +// +// ../../../toolkit/components/telemetry/docs/data/sync-ping.rst +// +// The sync ping contains identifiers that are linked to the user's Firefox Account +// and are separate from the main telemetry client_id, so this file is also responsible +// for ensuring that we can delete those pings upon user request, by plumbing its +// identifiers into the "deletion-request" ping. + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + Async: "resource://services-common/async.js", + AuthenticationError: "resource://services-sync/browserid_identity.js", + fxAccounts: "resource://gre/modules/FxAccounts.jsm", + FxAccounts: "resource://gre/modules/FxAccounts.jsm", + Log: "resource://gre/modules/Log.jsm", + ObjectUtils: "resource://gre/modules/ObjectUtils.jsm", + Observers: "resource://services-common/observers.js", + OS: "resource://gre/modules/osfile.jsm", + Resource: "resource://services-sync/resource.js", + Services: "resource://gre/modules/Services.jsm", + Status: "resource://services-sync/status.js", + Svc: "resource://services-sync/util.js", + TelemetryController: "resource://gre/modules/TelemetryController.jsm", + TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.jsm", + TelemetryUtils: "resource://gre/modules/TelemetryUtils.jsm", + Weave: "resource://services-sync/main.js", +}); + +let constants = {}; +ChromeUtils.import("resource://services-sync/constants.js", constants); + +XPCOMUtils.defineLazyServiceGetter( + this, + "Telemetry", + "@mozilla.org/base/telemetry;1", + "nsITelemetry" +); + +XPCOMUtils.defineLazyGetter( + this, + "WeaveService", + () => Cc["@mozilla.org/weave/service;1"].getService().wrappedJSObject +); +const log = Log.repository.getLogger("Sync.Telemetry"); + +const TOPICS = [ + // For tracking change to account/device identifiers. + "fxaccounts:new_device_id", + "fxaccounts:onlogout", + "weave:service:ready", + "weave:service:login:change", + + // For whole-of-sync metrics. + "weave:service:sync:start", + "weave:service:sync:finish", + "weave:service:sync:error", + + // For individual engine metrics. + "weave:engine:sync:start", + "weave:engine:sync:finish", + "weave:engine:sync:error", + "weave:engine:sync:applied", + "weave:engine:sync:step", + "weave:engine:sync:uploaded", + "weave:engine:validate:finish", + "weave:engine:validate:error", + + // For ad-hoc telemetry events. + "weave:telemetry:event", + "weave:telemetry:histogram", + "fxa:telemetry:event", + + "weave:telemetry:migration", +]; + +const PING_FORMAT_VERSION = 1; + +const EMPTY_UID = "0".repeat(32); + +// The set of engines we record telemetry for - any other engines are ignored. +const ENGINES = new Set([ + "addons", + "bookmarks", + "clients", + "forms", + "history", + "passwords", + "prefs", + "tabs", + "extension-storage", + "addresses", + "creditcards", +]); + +// A regex we can use to replace the profile dir in error messages. We use a +// regexp so we can simply replace all case-insensitive occurences. +// This escaping function is from: +// https://developer.mozilla.org/en/docs/Web/JavaScript/Guide/Regular_Expressions +const reProfileDir = new RegExp( + OS.Constants.Path.profileDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + "gi" +); + +function tryGetMonotonicTimestamp() { + try { + return Telemetry.msSinceProcessStart(); + } catch (e) { + log.warn("Unable to get a monotonic timestamp!"); + return -1; + } +} + +function timeDeltaFrom(monotonicStartTime) { + let now = tryGetMonotonicTimestamp(); + if (monotonicStartTime !== -1 && now !== -1) { + return Math.round(now - monotonicStartTime); + } + return -1; +} + +// Converts extra integer fields to strings, rounds floats to three +// decimal places (nanosecond precision for timings), and removes profile +// directory paths and URLs from potential error messages. +function normalizeExtraTelemetryFields(extra) { + let result = {}; + for (let key in extra) { + let value = extra[key]; + let type = typeof value; + if (type == "string") { + result[key] = cleanErrorMessage(value); + } else if (type == "number") { + result[key] = Number.isInteger(value) + ? value.toString(10) + : value.toFixed(3); + } else if (type != "undefined") { + throw new TypeError( + `Invalid type ${type} for extra telemetry field ${key}` + ); + } + } + return ObjectUtils.isEmpty(result) ? undefined : result; +} + +// This function validates the payload of a telemetry "event" - this can be +// removed once there are APIs available for the telemetry modules to collect +// these events (bug 1329530) - but for now we simulate that planned API as +// best we can. +function validateTelemetryEvent(eventDetails) { + let { object, method, value, extra } = eventDetails; + // Do do basic validation of the params - everything except "extra" must + // be a string. method and object are required. + if ( + typeof method != "string" || + typeof object != "string" || + (value && typeof value != "string") || + (extra && typeof extra != "object") + ) { + log.warn("Invalid event parameters - wrong types", eventDetails); + return false; + } + // length checks. + if ( + method.length > 20 || + object.length > 20 || + (value && value.length > 80) + ) { + log.warn("Invalid event parameters - wrong lengths", eventDetails); + return false; + } + + // extra can be falsey, or an object with string names and values. + if (extra) { + if (Object.keys(extra).length > 10) { + log.warn("Invalid event parameters - too many extra keys", eventDetails); + return false; + } + for (let [ename, evalue] of Object.entries(extra)) { + if ( + typeof ename != "string" || + ename.length > 15 || + typeof evalue != "string" || + evalue.length > 85 + ) { + log.warn( + `Invalid event parameters: extra item "${ename} is invalid`, + eventDetails + ); + return false; + } + } + } + return true; +} + +class EngineRecord { + constructor(name) { + // startTime is in ms from process start, but is monotonic (unlike Date.now()) + // so we need to keep both it and when. + this.startTime = tryGetMonotonicTimestamp(); + this.name = name; + + // This allows cases like bookmarks-buffered to have a separate name from + // the bookmarks engine. + let engineImpl = Weave.Service.engineManager.get(name); + if (engineImpl && engineImpl.overrideTelemetryName) { + this.overrideTelemetryName = engineImpl.overrideTelemetryName; + } + } + + toJSON() { + let result = { name: this.overrideTelemetryName || this.name }; + let properties = [ + "took", + "status", + "failureReason", + "incoming", + "outgoing", + "validation", + "steps", + ]; + for (let property of properties) { + result[property] = this[property]; + } + return result; + } + + finished(error) { + let took = timeDeltaFrom(this.startTime); + if (took > 0) { + this.took = took; + } + if (error) { + this.failureReason = SyncTelemetry.transformError(error); + } + } + + recordApplied(counts) { + if (this.incoming) { + log.error( + `Incoming records applied multiple times for engine ${this.name}!` + ); + return; + } + if (this.name === "clients" && !counts.failed) { + // ignore successful application of client records + // since otherwise they show up every time and are meaningless. + return; + } + + let incomingData = {}; + let properties = ["applied", "failed", "newFailed", "reconciled"]; + // Only record non-zero properties and only record incoming at all if + // there's at least one property we care about. + for (let property of properties) { + if (counts[property]) { + incomingData[property] = counts[property]; + this.incoming = incomingData; + } + } + } + + recordStep(stepResult) { + let step = { + name: stepResult.name, + }; + if (stepResult.took > 0) { + step.took = Math.round(stepResult.took); + } + if (stepResult.counts) { + let counts = stepResult.counts.filter(({ count }) => count > 0); + if (counts.length) { + step.counts = counts; + } + } + if (this.steps) { + this.steps.push(step); + } else { + this.steps = [step]; + } + } + + recordValidation(validationResult) { + if (this.validation) { + log.error(`Multiple validations occurred for engine ${this.name}!`); + return; + } + let { problems, version, took, checked } = validationResult; + let validation = { + version: version || 0, + checked: checked || 0, + }; + if (took > 0) { + validation.took = Math.round(took); + } + let summarized = problems.filter(({ count }) => count > 0); + if (summarized.length) { + validation.problems = summarized; + } + this.validation = validation; + } + + recordValidationError(e) { + if (this.validation) { + log.error(`Multiple validations occurred for engine ${this.name}!`); + return; + } + + this.validation = { + failureReason: SyncTelemetry.transformError(e), + }; + } + + recordUploaded(counts) { + if (counts.sent || counts.failed) { + if (!this.outgoing) { + this.outgoing = []; + } + this.outgoing.push({ + sent: counts.sent || undefined, + failed: counts.failed || undefined, + }); + } + } +} + +// The record of a single "sync" - typically many of these are submitted in +// a single ping (ie, as a 'syncs' array) +class SyncRecord { + constructor(allowedEngines, why) { + this.allowedEngines = allowedEngines; + // Our failure reason. This property only exists in the generated ping if an + // error actually occurred. + this.failureReason = undefined; + this.syncNodeType = null; + this.when = Date.now(); + this.startTime = tryGetMonotonicTimestamp(); + this.took = 0; // will be set later. + this.why = why; + + // All engines that have finished (ie, does not include the "current" one) + // We omit this from the ping if it's empty. + this.engines = []; + // The engine that has started but not yet stopped. + this.currentEngine = null; + } + + toJSON() { + let result = { + when: this.when, + took: this.took, + failureReason: this.failureReason, + status: this.status, + }; + if (this.why) { + result.why = this.why; + } + let engines = []; + for (let engine of this.engines) { + engines.push(engine.toJSON()); + } + if (engines.length > 0) { + result.engines = engines; + } + return result; + } + + finished(error) { + this.took = timeDeltaFrom(this.startTime); + if (this.currentEngine != null) { + log.error( + "Finished called for the sync before the current engine finished" + ); + this.currentEngine.finished(null); + this.onEngineStop(this.currentEngine.name); + } + if (error) { + this.failureReason = SyncTelemetry.transformError(error); + } + + this.syncNodeType = Weave.Service.identity.telemetryNodeType; + + // Check for engine statuses. -- We do this now, and not in engine.finished + // to make sure any statuses that get set "late" are recorded + for (let engine of this.engines) { + let status = Status.engines[engine.name]; + if (status && status !== constants.ENGINE_SUCCEEDED) { + engine.status = status; + } + } + + let statusObject = {}; + + let serviceStatus = Status.service; + if (serviceStatus && serviceStatus !== constants.STATUS_OK) { + statusObject.service = serviceStatus; + this.status = statusObject; + } + let syncStatus = Status.sync; + if (syncStatus && syncStatus !== constants.SYNC_SUCCEEDED) { + statusObject.sync = syncStatus; + this.status = statusObject; + } + } + + onEngineStart(engineName) { + if (this._shouldIgnoreEngine(engineName, false)) { + return; + } + + if (this.currentEngine) { + log.error( + `Being told that engine ${engineName} has started, but current engine ${this.currentEngine.name} hasn't stopped` + ); + // Just discard the current engine rather than making up data for it. + } + this.currentEngine = new EngineRecord(engineName); + } + + onEngineStop(engineName, error) { + // We only care if it's the current engine if we have a current engine. + if (this._shouldIgnoreEngine(engineName, !!this.currentEngine)) { + return; + } + if (!this.currentEngine) { + // It's possible for us to get an error before the start message of an engine + // (somehow), in which case we still want to record that error. + if (!error) { + return; + } + log.error( + `Error triggered on ${engineName} when no current engine exists: ${error}` + ); + this.currentEngine = new EngineRecord(engineName); + } + this.currentEngine.finished(error); + this.engines.push(this.currentEngine); + this.currentEngine = null; + } + + onEngineApplied(engineName, counts) { + if (this._shouldIgnoreEngine(engineName)) { + return; + } + this.currentEngine.recordApplied(counts); + } + + onEngineStep(engineName, step) { + if (this._shouldIgnoreEngine(engineName)) { + return; + } + this.currentEngine.recordStep(step); + } + + onEngineValidated(engineName, validationData) { + if (this._shouldIgnoreEngine(engineName, false)) { + return; + } + let engine = this.engines.find(e => e.name === engineName); + if ( + !engine && + this.currentEngine && + engineName === this.currentEngine.name + ) { + engine = this.currentEngine; + } + if (engine) { + engine.recordValidation(validationData); + } else { + log.warn( + `Validation event triggered for engine ${engineName}, which hasn't been synced!` + ); + } + } + + onEngineValidateError(engineName, error) { + if (this._shouldIgnoreEngine(engineName, false)) { + return; + } + let engine = this.engines.find(e => e.name === engineName); + if ( + !engine && + this.currentEngine && + engineName === this.currentEngine.name + ) { + engine = this.currentEngine; + } + if (engine) { + engine.recordValidationError(error); + } else { + log.warn( + `Validation failure event triggered for engine ${engineName}, which hasn't been synced!` + ); + } + } + + onEngineUploaded(engineName, counts) { + if (this._shouldIgnoreEngine(engineName)) { + return; + } + this.currentEngine.recordUploaded(counts); + } + + _shouldIgnoreEngine(engineName, shouldBeCurrent = true) { + if (!this.allowedEngines.has(engineName)) { + log.info( + `Notification for engine ${engineName}, but we aren't recording telemetry for it` + ); + return true; + } + if (shouldBeCurrent) { + if (!this.currentEngine || engineName != this.currentEngine.name) { + log.error(`Notification for engine ${engineName} but it isn't current`); + return true; + } + } + return false; + } +} + +function cleanErrorMessage(error) { + // There's a chance the profiledir is in the error string which is PII we + // want to avoid including in the ping. + error = error.replace(reProfileDir, "[profileDir]"); + // MSG_INVALID_URL from /dom/bindings/Errors.msg -- no way to access this + // directly from JS. + if (error.endsWith("is not a valid URL.")) { + error = "<URL> is not a valid URL."; + } + // Try to filter things that look somewhat like a URL (in that they contain a + // colon in the middle of non-whitespace), in case anything else is including + // these in error messages. Note that JSON.stringified stuff comes through + // here, so we explicitly ignore double-quotes as well. + error = error.replace(/[^\s"]+:[^\s"]+/g, "<URL>"); + return error; +} + +// The entire "sync ping" - it includes all the syncs, events etc recorded in +// the ping. +class SyncTelemetryImpl { + constructor(allowedEngines) { + log.manageLevelFromPref("services.sync.log.logger.telemetry"); + // This is accessible so we can enable custom engines during tests. + this.allowedEngines = allowedEngines; + this.current = null; + this.setupObservers(); + + this.payloads = []; + this.discarded = 0; + this.events = []; + this.histograms = {}; + this.migrations = []; + this.maxEventsCount = Svc.Prefs.get("telemetry.maxEventsCount", 1000); + this.maxPayloadCount = Svc.Prefs.get("telemetry.maxPayloadCount"); + this.submissionInterval = + Svc.Prefs.get("telemetry.submissionInterval") * 1000; + this.lastSubmissionTime = Telemetry.msSinceProcessStart(); + this.lastUID = EMPTY_UID; + this.lastSyncNodeType = null; + this.currentSyncNodeType = null; + // Note that the sessionStartDate is somewhat arbitrary - the telemetry + // modules themselves just use `new Date()`. This means that our startDate + // isn't going to be the same as the sessionStartDate in the main pings, + // but that's OK for now - if it's a problem we'd need to change the + // telemetry modules to expose what it thinks the sessionStartDate is. + let sessionStartDate = new Date(); + this.sessionStartDate = TelemetryUtils.toLocalTimeISOString( + TelemetryUtils.truncateToHours(sessionStartDate) + ); + TelemetryController.registerSyncPingShutdown(() => this.shutdown()); + } + + sanitizeFxaDeviceId(deviceId) { + return fxAccounts.telemetry.sanitizeDeviceId(deviceId); + } + + prepareFxaDevices(devices) { + // For non-sync users, the data per device is limited -- just an id and a + // type (and not even the id yet). For sync users, if we can correctly map + // the fxaDevice to a sync device, then we can get os and version info, + // which would be quite unfortunate to lose. + let extraInfoMap = new Map(); + if (this.syncIsEnabled()) { + for (let client of this.getClientsEngineRecords()) { + if (client.fxaDeviceId) { + extraInfoMap.set(client.fxaDeviceId, { + os: client.os, + version: client.version, + syncID: this.sanitizeFxaDeviceId(client.id), + }); + } + } + } + // Finally, sanitize and convert to the proper format. + return devices.map(d => { + let { os, version, syncID } = extraInfoMap.get(d.id) || { + os: undefined, + version: undefined, + syncID: undefined, + }; + return { + id: this.sanitizeFxaDeviceId(d.id) || EMPTY_UID, + type: d.type, + os, + version, + syncID, + }; + }); + } + + syncIsEnabled() { + return WeaveService.enabled && WeaveService.ready; + } + + // Separate for testing. + getClientsEngineRecords() { + if (!this.syncIsEnabled()) { + throw new Error("Bug: syncIsEnabled() must be true, check it first"); + } + return Weave.Service.clientsEngine.remoteClients; + } + + updateFxaDevices(devices) { + if (!devices) { + return {}; + } + let me = devices.find(d => d.isCurrentDevice); + let id = me ? this.sanitizeFxaDeviceId(me.id) : undefined; + let cleanDevices = this.prepareFxaDevices(devices); + return { deviceID: id, devices: cleanDevices }; + } + + getFxaDevices() { + return fxAccounts.device.recentDeviceList; + } + + getPingJSON(reason) { + let { devices, deviceID } = this.updateFxaDevices(this.getFxaDevices()); + return { + os: TelemetryEnvironment.currentEnvironment.system.os, + why: reason, + devices, + discarded: this.discarded || undefined, + version: PING_FORMAT_VERSION, + syncs: this.payloads.slice(), + uid: this.lastUID, + syncNodeType: this.lastSyncNodeType || undefined, + deviceID, + sessionStartDate: this.sessionStartDate, + events: this.events.length == 0 ? undefined : this.events, + migrations: this.migrations.length == 0 ? undefined : this.migrations, + histograms: + Object.keys(this.histograms).length == 0 ? undefined : this.histograms, + }; + } + + _addMigrationRecord(type, info) { + log.debug("Saw telemetry migration info", type, info); + // Updates to this need to be documented in `sync-ping.rst` + switch (type) { + case "webext-storage": + this.migrations.push({ + type: "webext-storage", + entries: +info.entries, + entriesSuccessful: +info.entries_successful, + extensions: +info.extensions, + extensionsSuccessful: +info.extensions_successful, + openFailure: !!info.open_failure, + }); + break; + default: + throw new Error("Bug: Unknown migration record type " + type); + } + } + + finish(reason) { + // Note that we might be in the middle of a sync right now, and so we don't + // want to touch this.current. + let result = this.getPingJSON(reason); + this.payloads = []; + this.discarded = 0; + this.events = []; + this.migrations = []; + this.histograms = {}; + this.submit(result); + } + + setupObservers() { + for (let topic of TOPICS) { + Observers.add(topic, this, this); + } + } + + shutdown() { + this.finish("shutdown"); + for (let topic of TOPICS) { + Observers.remove(topic, this, this); + } + } + + submit(record) { + if (!this.isProductionSyncUser()) { + return false; + } + // We still call submit() with possibly illegal payloads so that tests can + // know that the ping was built. We don't end up submitting them, however. + let numEvents = record.events ? record.events.length : 0; + let numMigrations = record.migrations ? record.migrations.length : 0; + if (record.syncs.length || numEvents || numMigrations) { + log.trace( + `submitting ${record.syncs.length} sync record(s) and ` + + `${numEvents} event(s) to telemetry` + ); + TelemetryController.submitExternalPing("sync", record, { + usePingSender: true, + }).catch(err => { + log.error("failed to submit ping", err); + }); + return true; + } + return false; + } + + isProductionSyncUser() { + // If FxA isn't production then we treat sync as not being production. + // Further, there's the deprecated "services.sync.tokenServerURI" pref we + // need to consider - fxa doesn't consider that as if that's the only + // pref set, they *are* running a production fxa, just not production sync. + if ( + !FxAccounts.config.isProductionConfig() || + Services.prefs.prefHasUserValue("services.sync.tokenServerURI") + ) { + log.trace(`Not sending telemetry ping for self-hosted Sync user`); + return false; + } + return true; + } + + onSyncStarted(data) { + const why = data && JSON.parse(data).why; + if (this.current) { + log.warn( + "Observed weave:service:sync:start, but we're already recording a sync!" + ); + // Just discard the old record, consistent with our handling of engines, above. + this.current = null; + } + this.current = new SyncRecord(this.allowedEngines, why); + } + + // We need to ensure that the telemetry `deletion-request` ping always contains the user's + // current sync device ID, because if the user opts out of telemetry then the deletion ping + // will be immediately triggered for sending, and we won't have a chance to fill it in later. + // This keeps the `deletion-ping` up-to-date when the user's account state changes. + onAccountInitOrChange() { + // We don't submit sync pings for self-hosters, so don't need to collect their device ids either. + if (!this.isProductionSyncUser()) { + return; + } + // Awkwardly async, but no need to await. If the user's account state changes while + // this promise is in flight, it will reject and we won't record any data in the ping. + // (And a new notification will trigger us to try again with the new state). + fxAccounts.device + .getLocalId() + .then(deviceId => { + let sanitizedDeviceId = fxAccounts.telemetry.sanitizeDeviceId(deviceId); + // In the past we did not persist the FxA metrics identifiers to disk, + // so this might be missing until we can fetch it from the server for the + // first time. There will be a fresh notification tirggered when it's available. + if (sanitizedDeviceId) { + // Sanitized device ids are 64 characters long, but telemetry limits scalar strings to 50. + // The first 32 chars are sufficient to uniquely identify the device, so just send those. + // It's hard to change the sync ping itself to only send 32 chars, to b/w compat reasons. + sanitizedDeviceId = sanitizedDeviceId.substr(0, 32); + Services.telemetry.scalarSet( + "deletion.request.sync_device_id", + sanitizedDeviceId + ); + } + }) + .catch(err => { + log.warn( + `Failed to set sync identifiers in the deletion-request ping: ${err}` + ); + }); + } + + // This keeps the `deletion-request` ping up-to-date when the user signs out, + // clearing the now-nonexistent sync device id. + onAccountLogout() { + Services.telemetry.scalarSet("deletion.request.sync_device_id", ""); + } + + _checkCurrent(topic) { + if (!this.current) { + log.warn( + `Observed notification ${topic} but no current sync is being recorded.` + ); + return false; + } + return true; + } + + _shouldSubmitForDataChange() { + let newID = fxAccounts.telemetry.getSanitizedUID() || EMPTY_UID; + let oldID = this.lastUID; + if ( + newID != EMPTY_UID && + oldID != EMPTY_UID && + // Both are "real" uids, so we care if they've changed. + newID != oldID + ) { + log.trace( + `shouldSubmitForDataChange - uid from '${oldID}' -> '${newID}'` + ); + return true; + } + // We've gone from knowing one of the ids to not knowing it (which we + // ignore) or we've gone from not knowing it to knowing it (which is fine), + // Now check the node type because a change there also means we should + // submit. + if ( + this.lastSyncNodeType && + this.currentSyncNodeType != this.lastSyncNodeType + ) { + log.trace( + `shouldSubmitForDataChange - nodeType from '${this.lastSyncNodeType}' -> '${this.currentSyncNodeType}'` + ); + return true; + } + log.trace("shouldSubmitForDataChange - no need to submit"); + return false; + } + + maybeSubmitForDataChange() { + if (this._shouldSubmitForDataChange()) { + log.info( + "Early submission of sync telemetry due to changed IDs/NodeType" + ); + this.finish("idchange"); // this actually submits. + this.lastSubmissionTime = Telemetry.msSinceProcessStart(); + } + + // Only update the last UIDs if we actually know them. + let current_uid = fxAccounts.telemetry.getSanitizedUID(); + if (current_uid) { + this.lastUID = current_uid; + } + if (this.currentSyncNodeType) { + this.lastSyncNodeType = this.currentSyncNodeType; + } + } + + maybeSubmitForInterval() { + // We want to submit the ping every `this.submissionInterval` but only when + // there's no current sync in progress, otherwise we may end up submitting + // the sync and the events caused by it in different pings. + if ( + this.current == null && + Telemetry.msSinceProcessStart() - this.lastSubmissionTime > + this.submissionInterval + ) { + this.finish("schedule"); + this.lastSubmissionTime = Telemetry.msSinceProcessStart(); + } + } + + onSyncFinished(error) { + if (!this.current) { + log.warn("onSyncFinished but we aren't recording"); + return; + } + this.current.finished(error); + this.currentSyncNodeType = this.current.syncNodeType; + // We check for "data change" before appending the current sync to payloads, + // as it is the current sync which has the data with the new data, and thus + // must go in the *next* submission. + this.maybeSubmitForDataChange(); + if (this.payloads.length < this.maxPayloadCount) { + this.payloads.push(this.current.toJSON()); + } else { + ++this.discarded; + } + this.current = null; + // If we are submitting due to timing, it's desirable that the most recent + // sync is included, so we check after appending `this.current`. + this.maybeSubmitForInterval(); + } + + _addHistogram(hist) { + let histogram = Telemetry.getHistogramById(hist); + let s = histogram.snapshot(); + this.histograms[hist] = s; + } + + _recordEvent(eventDetails) { + this.maybeSubmitForDataChange(); + + if (this.events.length >= this.maxEventsCount) { + log.warn("discarding event - already queued our maximum", eventDetails); + return; + } + + let { object, method, value, extra } = eventDetails; + if (extra) { + extra = normalizeExtraTelemetryFields(extra); + eventDetails = { object, method, value, extra }; + } + + if (!validateTelemetryEvent(eventDetails)) { + // we've already logged what the problem is... + return; + } + log.debug("recording event", eventDetails); + + if (extra && Resource.serverTime && !extra.serverTime) { + extra.serverTime = String(Resource.serverTime); + } + let category = "sync"; + let ts = Math.floor(tryGetMonotonicTimestamp()); + + // An event record is a simple array with at least 4 items. + let event = [ts, category, method, object]; + // It may have up to 6 elements if |extra| is defined + if (value) { + event.push(value); + if (extra) { + event.push(extra); + } + } else if (extra) { + event.push(null); // a null for the empty value. + event.push(extra); + } + this.events.push(event); + this.maybeSubmitForInterval(); + } + + observe(subject, topic, data) { + log.trace(`observed ${topic} ${data}`); + + switch (topic) { + case "weave:service:ready": + case "weave:service:login:change": + case "fxaccounts:new_device_id": + this.onAccountInitOrChange(); + break; + + case "fxaccounts:onlogout": + this.onAccountLogout(); + break; + + /* sync itself state changes */ + case "weave:service:sync:start": + this.onSyncStarted(data); + break; + + case "weave:service:sync:finish": + if (this._checkCurrent(topic)) { + this.onSyncFinished(null); + } + break; + + case "weave:service:sync:error": + // argument needs to be truthy (this should always be the case) + this.onSyncFinished(subject || "Unknown"); + break; + + /* engine sync state changes */ + case "weave:engine:sync:start": + if (this._checkCurrent(topic)) { + this.current.onEngineStart(data); + } + break; + case "weave:engine:sync:finish": + if (this._checkCurrent(topic)) { + this.current.onEngineStop(data, null); + } + break; + + case "weave:engine:sync:error": + if (this._checkCurrent(topic)) { + // argument needs to be truthy (this should always be the case) + this.current.onEngineStop(data, subject || "Unknown"); + } + break; + + /* engine counts */ + case "weave:engine:sync:applied": + if (this._checkCurrent(topic)) { + this.current.onEngineApplied(data, subject); + } + break; + + case "weave:engine:sync:step": + if (this._checkCurrent(topic)) { + this.current.onEngineStep(data, subject); + } + break; + + case "weave:engine:sync:uploaded": + if (this._checkCurrent(topic)) { + this.current.onEngineUploaded(data, subject); + } + break; + + case "weave:engine:validate:finish": + if (this._checkCurrent(topic)) { + this.current.onEngineValidated(data, subject); + } + break; + + case "weave:engine:validate:error": + if (this._checkCurrent(topic)) { + this.current.onEngineValidateError(data, subject || "Unknown"); + } + break; + + case "weave:telemetry:event": + case "fxa:telemetry:event": + this._recordEvent(subject); + break; + + case "weave:telemetry:histogram": + this._addHistogram(data); + break; + + case "weave:telemetry:migration": + this._addMigrationRecord(data, subject); + break; + + default: + log.warn(`unexpected observer topic ${topic}`); + break; + } + } + + // Transform an exception into a standard description. Exposed here for when + // this module isn't directly responsible for knowing the transform should + // happen (for example, when including an error in the |extra| field of + // event telemetry) + transformError(error) { + // Certain parts of sync will use this pattern as a way to communicate to + // processIncoming to abort the processing. However, there's no guarantee + // this can only happen then. + if (typeof error == "object" && error.code && error.cause) { + error = error.cause; + } + if (Async.isShutdownException(error)) { + return { name: "shutdownerror" }; + } + + if (typeof error === "string") { + if (error.startsWith("error.")) { + // This is hacky, but I can't imagine that it's not also accurate. + return { name: "othererror", error }; + } + error = cleanErrorMessage(error); + return { name: "unexpectederror", error }; + } + + if (error instanceof AuthenticationError) { + return { name: "autherror", from: error.source }; + } + + let httpCode = + error.status || (error.response && error.response.status) || error.code; + + if (httpCode) { + return { name: "httperror", code: httpCode }; + } + + if (error.failureCode) { + return { name: "othererror", error: error.failureCode }; + } + + if (error.result) { + return { name: "nserror", code: error.result }; + } + // It's probably an Error object, but it also could be some + // other object that may or may not override toString to do + // something useful. + let msg = String(error); + if (msg.startsWith("[object")) { + // Nothing useful in the default, check for a string "message" property. + if (typeof error.message == "string") { + msg = String(error.message); + } else { + // Hopefully it won't come to this... + msg = JSON.stringify(error); + } + } + return { + name: "unexpectederror", + error: cleanErrorMessage(msg), + }; + } +} + +var SyncTelemetry = new SyncTelemetryImpl(ENGINES); diff --git a/services/sync/modules/util.js b/services/sync/modules/util.js new file mode 100644 index 0000000000..0f893dd7a1 --- /dev/null +++ b/services/sync/modules/util.js @@ -0,0 +1,796 @@ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["Utils", "Svc", "SerializableSet"]; + +const { Observers } = ChromeUtils.import( + "resource://services-common/observers.js" +); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); +const { + DEVICE_TYPE_DESKTOP, + MAXIMUM_BACKOFF_INTERVAL, + PREFS_BRANCH, + SYNC_KEY_DECODED_LENGTH, + SYNC_KEY_ENCODED_LENGTH, + WEAVE_VERSION, +} = ChromeUtils.import("resource://services-sync/constants.js"); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +// FxAccountsCommon.js doesn't use a "namespace", so create one here. +XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function() { + let FxAccountsCommon = {}; + ChromeUtils.import( + "resource://gre/modules/FxAccountsCommon.js", + FxAccountsCommon + ); + return FxAccountsCommon; +}); + +XPCOMUtils.defineLazyServiceGetter( + this, + "cryptoSDR", + "@mozilla.org/login-manager/crypto/SDR;1", + "nsILoginManagerCrypto" +); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "localDeviceType", + "services.sync.client.type", + DEVICE_TYPE_DESKTOP +); + +/* + * Custom exception types. + */ +class LockException extends Error { + constructor(message) { + super(message); + this.name = "LockException"; + } +} + +class HMACMismatch extends Error { + constructor(message) { + super(message); + this.name = "HMACMismatch"; + } +} + +/* + * Utility functions + */ +var Utils = { + // Aliases from CryptoUtils. + generateRandomBytesLegacy: CryptoUtils.generateRandomBytesLegacy, + computeHTTPMACSHA1: CryptoUtils.computeHTTPMACSHA1, + digestUTF8: CryptoUtils.digestUTF8, + digestBytes: CryptoUtils.digestBytes, + sha256: CryptoUtils.sha256, + makeHMACKey: CryptoUtils.makeHMACKey, + makeHMACHasher: CryptoUtils.makeHMACHasher, + hkdfExpand: CryptoUtils.hkdfExpand, + pbkdf2Generate: CryptoUtils.pbkdf2Generate, + getHTTPMACSHA1Header: CryptoUtils.getHTTPMACSHA1Header, + + /** + * The string to use as the base User-Agent in Sync requests. + * This string will look something like + * + * Firefox/49.0a1 (Windows NT 6.1; WOW64; rv:46.0) FxSync/1.51.0.20160516142357.desktop + */ + _userAgent: null, + get userAgent() { + if (!this._userAgent) { + let hph = Cc["@mozilla.org/network/protocol;1?name=http"].getService( + Ci.nsIHttpProtocolHandler + ); + /* eslint-disable no-multi-spaces */ + this._userAgent = + Services.appinfo.name + + "/" + + Services.appinfo.version + // Product. + " (" + + hph.oscpu + + ")" + // (oscpu) + " FxSync/" + + WEAVE_VERSION + + "." + // Sync. + Services.appinfo.appBuildID + + "."; // Build. + /* eslint-enable no-multi-spaces */ + } + return this._userAgent + localDeviceType; + }, + + /** + * Wrap a [promise-returning] function to catch all exceptions and log them. + * + * @usage MyObj._catch = Utils.catch; + * MyObj.foo = function() { this._catch(func)(); } + * + * Optionally pass a function which will be called if an + * exception occurs. + */ + catch(func, exceptionCallback) { + let thisArg = this; + return async function WrappedCatch() { + try { + return await func.call(thisArg); + } catch (ex) { + thisArg._log.debug( + "Exception calling " + (func.name || "anonymous function"), + ex + ); + if (exceptionCallback) { + return exceptionCallback.call(thisArg, ex); + } + return null; + } + }; + }, + + throwLockException(label) { + throw new LockException(`Could not acquire lock. Label: "${label}".`); + }, + + /** + * Wrap a [promise-returning] function to call lock before calling the function + * then unlock when it finishes executing or if it threw an error. + * + * @usage MyObj._lock = Utils.lock; + * MyObj.foo = async function() { await this._lock(func)(); } + */ + lock(label, func) { + let thisArg = this; + return async function WrappedLock() { + if (!thisArg.lock()) { + Utils.throwLockException(label); + } + + try { + return await func.call(thisArg); + } finally { + thisArg.unlock(); + } + }; + }, + + isLockException: function isLockException(ex) { + return ex instanceof LockException; + }, + + /** + * Wrap [promise-returning] functions to notify when it starts and + * finishes executing or if it threw an error. + * + * The message is a combination of a provided prefix, the local name, and + * the event. Possible events are: "start", "finish", "error". The subject + * is the function's return value on "finish" or the caught exception on + * "error". The data argument is the predefined data value. + * + * Example: + * + * @usage function MyObj(name) { + * this.name = name; + * this._notify = Utils.notify("obj:"); + * } + * MyObj.prototype = { + * foo: function() this._notify("func", "data-arg", async function () { + * //... + * }(), + * }; + */ + notify(prefix) { + return function NotifyMaker(name, data, func) { + let thisArg = this; + let notify = function(state, subject) { + let mesg = prefix + name + ":" + state; + thisArg._log.trace("Event: " + mesg); + Observers.notify(mesg, subject, data); + }; + + return async function WrappedNotify() { + notify("start", null); + try { + let ret = await func.call(thisArg); + notify("finish", ret); + return ret; + } catch (ex) { + notify("error", ex); + throw ex; + } + }; + }; + }, + + /** + * GUIDs are 9 random bytes encoded with base64url (RFC 4648). + * That makes them 12 characters long with 72 bits of entropy. + */ + makeGUID: function makeGUID() { + return CommonUtils.encodeBase64URL(Utils.generateRandomBytesLegacy(9)); + }, + + _base64url_regex: /^[-abcdefghijklmnopqrstuvwxyz0123456789_]{12}$/i, + checkGUID: function checkGUID(guid) { + return !!guid && this._base64url_regex.test(guid); + }, + + /** + * Add a simple getter/setter to an object that defers access of a property + * to an inner property. + * + * @param obj + * Object to add properties to defer in its prototype + * @param defer + * Property of obj to defer to + * @param prop + * Property name to defer (or an array of property names) + */ + deferGetSet: function Utils_deferGetSet(obj, defer, prop) { + if (Array.isArray(prop)) { + return prop.map(prop => Utils.deferGetSet(obj, defer, prop)); + } + + let prot = obj.prototype; + + // Create a getter if it doesn't exist yet + if (!prot.__lookupGetter__(prop)) { + prot.__defineGetter__(prop, function() { + return this[defer][prop]; + }); + } + + // Create a setter if it doesn't exist yet + if (!prot.__lookupSetter__(prop)) { + prot.__defineSetter__(prop, function(val) { + this[defer][prop] = val; + }); + } + }, + + deepEquals: function eq(a, b) { + // If they're triple equals, then it must be equals! + if (a === b) { + return true; + } + + // If they weren't equal, they must be objects to be different + if (typeof a != "object" || typeof b != "object") { + return false; + } + + // But null objects won't have properties to compare + if (a === null || b === null) { + return false; + } + + // Make sure all of a's keys have a matching value in b + for (let k in a) { + if (!eq(a[k], b[k])) { + return false; + } + } + + // Do the same for b's keys but skip those that we already checked + for (let k in b) { + if (!(k in a) && !eq(a[k], b[k])) { + return false; + } + } + + return true; + }, + + // Generator and discriminator for HMAC exceptions. + // Split these out in case we want to make them richer in future, and to + // avoid inevitable confusion if the message changes. + throwHMACMismatch: function throwHMACMismatch(shouldBe, is) { + throw new HMACMismatch( + `Record SHA256 HMAC mismatch: should be ${shouldBe}, is ${is}` + ); + }, + + isHMACMismatch: function isHMACMismatch(ex) { + return ex instanceof HMACMismatch; + }, + + /** + * Turn RFC 4648 base32 into our own user-friendly version. + * ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 + * becomes + * abcdefghijk8mn9pqrstuvwxyz234567 + */ + base32ToFriendly: function base32ToFriendly(input) { + return input + .toLowerCase() + .replace(/l/g, "8") + .replace(/o/g, "9"); + }, + + base32FromFriendly: function base32FromFriendly(input) { + return input + .toUpperCase() + .replace(/8/g, "L") + .replace(/9/g, "O"); + }, + + /** + * Key manipulation. + */ + + // Return an octet string in friendly base32 *with no trailing =*. + encodeKeyBase32: function encodeKeyBase32(keyData) { + return Utils.base32ToFriendly(CommonUtils.encodeBase32(keyData)).slice( + 0, + SYNC_KEY_ENCODED_LENGTH + ); + }, + + decodeKeyBase32: function decodeKeyBase32(encoded) { + return CommonUtils.decodeBase32( + Utils.base32FromFriendly(Utils.normalizePassphrase(encoded)) + ).slice(0, SYNC_KEY_DECODED_LENGTH); + }, + + jsonFilePath(filePath) { + return OS.Path.normalize( + OS.Path.join(OS.Constants.Path.profileDir, "weave", filePath + ".json") + ); + }, + + /** + * Load a JSON file from disk in the profile directory. + * + * @param filePath + * JSON file path load from profile. Loaded file will be + * <profile>/<filePath>.json. i.e. Do not specify the ".json" + * extension. + * @param that + * Object to use for logging. + * + * @return Promise<> + * Promise resolved when the write has been performed. + */ + async jsonLoad(filePath, that) { + let path = Utils.jsonFilePath(filePath); + + if (that._log && that._log.trace) { + that._log.trace("Loading json from disk: " + filePath); + } + + try { + return await CommonUtils.readJSON(path); + } catch (e) { + if (!(e instanceof OS.File.Error && e.becauseNoSuchFile)) { + if (that._log) { + that._log.debug("Failed to load json", e); + } + } + return null; + } + }, + + /** + * Save a json-able object to disk in the profile directory. + * + * @param filePath + * JSON file path save to <filePath>.json + * @param that + * Object to use for logging. + * @param obj + * Function to provide json-able object to save. If this isn't a + * function, it'll be used as the object to make a json string.* + * Function called when the write has been performed. Optional. + * + * @return Promise<> + * Promise resolved when the write has been performed. + */ + async jsonSave(filePath, that, obj) { + let path = OS.Path.join( + OS.Constants.Path.profileDir, + "weave", + ...(filePath + ".json").split("/") + ); + let dir = OS.Path.dirname(path); + + await OS.File.makeDir(dir, { from: OS.Constants.Path.profileDir }); + + if (that._log) { + that._log.trace("Saving json to disk: " + path); + } + + let json = typeof obj == "function" ? obj.call(that) : obj; + + return CommonUtils.writeJSON(json, path); + }, + + /** + * Helper utility function to fit an array of records so that when serialized, + * they will be within payloadSizeMaxBytes. Returns a new array without the + * items. + */ + tryFitItems(records, payloadSizeMaxBytes) { + // Copy this so that callers don't have to do it in advance. + records = records.slice(); + let encoder = Utils.utf8Encoder; + const computeSerializedSize = () => + encoder.encode(JSON.stringify(records)).byteLength; + // Figure out how many records we can pack into a payload. + // We use byteLength here because the data is not encrypted in ascii yet. + let size = computeSerializedSize(); + // See bug 535326 comment 8 for an explanation of the estimation + const maxSerializedSize = (payloadSizeMaxBytes / 4) * 3 - 1500; + if (maxSerializedSize < 0) { + // This is probably due to a test, but it causes very bad behavior if a + // test causes this accidentally. We could throw, but there's an obvious/ + // natural way to handle it, so we do that instead (otherwise we'd have a + // weird lower bound of ~1125b on the max record payload size). + return []; + } + if (size > maxSerializedSize) { + // Estimate a little more than the direct fraction to maximize packing + let cutoff = Math.ceil((records.length * maxSerializedSize) / size); + records = records.slice(0, cutoff + 1); + + // Keep dropping off the last entry until the data fits. + while (computeSerializedSize() > maxSerializedSize) { + records.pop(); + } + } + return records; + }, + + /** + * Move a json file in the profile directory. Will fail if a file exists at the + * destination. + * + * @returns a promise that resolves to undefined on success, or rejects on failure + * + * @param aFrom + * Current path to the JSON file saved on disk, relative to profileDir/weave + * .json will be appended to the file name. + * @param aTo + * New path to the JSON file saved on disk, relative to profileDir/weave + * .json will be appended to the file name. + * @param that + * Object to use for logging + */ + jsonMove(aFrom, aTo, that) { + let pathFrom = OS.Path.join( + OS.Constants.Path.profileDir, + "weave", + ...(aFrom + ".json").split("/") + ); + let pathTo = OS.Path.join( + OS.Constants.Path.profileDir, + "weave", + ...(aTo + ".json").split("/") + ); + if (that._log) { + that._log.trace("Moving " + pathFrom + " to " + pathTo); + } + return OS.File.move(pathFrom, pathTo, { noOverwrite: true }); + }, + + /** + * Removes a json file in the profile directory. + * + * @returns a promise that resolves to undefined on success, or rejects on failure + * + * @param filePath + * Current path to the JSON file saved on disk, relative to profileDir/weave + * .json will be appended to the file name. + * @param that + * Object to use for logging + */ + jsonRemove(filePath, that) { + let path = OS.Path.join( + OS.Constants.Path.profileDir, + "weave", + ...(filePath + ".json").split("/") + ); + if (that._log) { + that._log.trace("Deleting " + path); + } + return OS.File.remove(path, { ignoreAbsent: true }); + }, + + /** + * The following are the methods supported for UI use: + * + * * isPassphrase: + * determines whether a string is either a normalized or presentable + * passphrase. + * * normalizePassphrase: + * take a presentable passphrase and reduce it to a normalized + * representation for storage. normalizePassphrase can safely be called + * on normalized input. + */ + + isPassphrase(s) { + if (s) { + return /^[abcdefghijkmnpqrstuvwxyz23456789]{26}$/.test( + Utils.normalizePassphrase(s) + ); + } + return false; + }, + + normalizePassphrase: function normalizePassphrase(pp) { + // Short var name... have you seen the lines below?! + // Allow leading and trailing whitespace. + pp = pp.trim().toLowerCase(); + + // 20-char sync key. + if (pp.length == 23 && [5, 11, 17].every(i => pp[i] == "-")) { + return ( + pp.slice(0, 5) + pp.slice(6, 11) + pp.slice(12, 17) + pp.slice(18, 23) + ); + } + + // "Modern" 26-char key. + if (pp.length == 31 && [1, 7, 13, 19, 25].every(i => pp[i] == "-")) { + return ( + pp.slice(0, 1) + + pp.slice(2, 7) + + pp.slice(8, 13) + + pp.slice(14, 19) + + pp.slice(20, 25) + + pp.slice(26, 31) + ); + } + + // Something else -- just return. + return pp; + }, + + /** + * Create an array like the first but without elements of the second. Reuse + * arrays if possible. + */ + arraySub: function arraySub(minuend, subtrahend) { + if (!minuend.length || !subtrahend.length) { + return minuend; + } + let setSubtrahend = new Set(subtrahend); + return minuend.filter(i => !setSubtrahend.has(i)); + }, + + /** + * Build the union of two arrays. Reuse arrays if possible. + */ + arrayUnion: function arrayUnion(foo, bar) { + if (!foo.length) { + return bar; + } + if (!bar.length) { + return foo; + } + return foo.concat(Utils.arraySub(bar, foo)); + }, + + /** + * Add all the items in `items` to the provided Set in-place. + * + * @return The provided set. + */ + setAddAll(set, items) { + for (let item of items) { + set.add(item); + } + return set; + }, + + /** + * Delete every items in `items` to the provided Set in-place. + * + * @return The provided set. + */ + setDeleteAll(set, items) { + for (let item of items) { + set.delete(item); + } + return set; + }, + + /** + * Take the first `size` items from the Set `items`. + * + * @return A Set of size at most `size` + */ + subsetOfSize(items, size) { + let result = new Set(); + let count = 0; + for (let item of items) { + if (count++ == size) { + return result; + } + result.add(item); + } + return result; + }, + + bind2: function Async_bind2(object, method) { + return function innerBind() { + return method.apply(object, arguments); + }; + }, + + /** + * Is there a master password configured and currently locked? + */ + mpLocked() { + return !cryptoSDR.isLoggedIn; + }, + + // If Master Password is enabled and locked, present a dialog to unlock it. + // Return whether the system is unlocked. + ensureMPUnlocked() { + if (cryptoSDR.uiBusy) { + return false; + } + try { + cryptoSDR.encrypt("bacon"); + return true; + } catch (e) {} + return false; + }, + + /** + * Return a value for a backoff interval. Maximum is eight hours, unless + * Status.backoffInterval is higher. + * + */ + calculateBackoff: function calculateBackoff( + attempts, + baseInterval, + statusInterval + ) { + let backoffInterval = + attempts * (Math.floor(Math.random() * baseInterval) + baseInterval); + return Math.max( + Math.min(backoffInterval, MAXIMUM_BACKOFF_INTERVAL), + statusInterval + ); + }, + + /** + * Return a set of hostnames (including the protocol) which may have + * credentials for sync itself stored in the login manager. + * + * In general, these hosts will not have their passwords synced, will be + * reset when we drop sync credentials, etc. + */ + getSyncCredentialsHosts() { + let result = new Set(); + // the FxA host + result.add(FxAccountsCommon.FXA_PWDMGR_HOST); + // We used to include the FxA hosts (hence the Set() result) but we now + // don't give them special treatment (hence the Set() with exactly 1 item) + return result; + }, + + /** + * Helper to implement a more efficient version of fairly common pattern: + * + * Utils.defineLazyIDProperty(this, "syncID", "services.sync.client.syncID") + * + * is equivalent to (but more efficient than) the following: + * + * Foo.prototype = { + * ... + * get syncID() { + * let syncID = Svc.Prefs.get("client.syncID", ""); + * return syncID == "" ? this.syncID = Utils.makeGUID() : syncID; + * }, + * set syncID(value) { + * Svc.Prefs.set("client.syncID", value); + * }, + * ... + * }; + */ + defineLazyIDProperty(object, propName, prefName) { + // An object that exists to be the target of the lazy pref getter. + // We can't use `object` (at least, not using `propName`) since XPCOMUtils + // will stomp on any setter we define. + const storage = {}; + XPCOMUtils.defineLazyPreferenceGetter(storage, "value", prefName, ""); + Object.defineProperty(object, propName, { + configurable: true, + enumerable: true, + get() { + let value = storage.value; + if (!value) { + value = Utils.makeGUID(); + Services.prefs.setStringPref(prefName, value); + } + return value; + }, + set(value) { + Services.prefs.setStringPref(prefName, value); + }, + }); + }, + + getDeviceType() { + return localDeviceType; + }, + + formatTimestamp(date) { + // Format timestamp as: "%Y-%m-%d %H:%M:%S" + let year = String(date.getFullYear()); + let month = String(date.getMonth() + 1).padStart(2, "0"); + let day = String(date.getDate()).padStart(2, "0"); + let hours = String(date.getHours()).padStart(2, "0"); + let minutes = String(date.getMinutes()).padStart(2, "0"); + let seconds = String(date.getSeconds()).padStart(2, "0"); + + return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; + }, + + *walkTree(tree, parent = null) { + if (tree) { + // Skip root node + if (parent) { + yield [tree, parent]; + } + if (tree.children) { + for (let child of tree.children) { + yield* Utils.walkTree(child, tree); + } + } + } + }, +}; + +/** + * A subclass of Set that serializes as an Array when passed to JSON.stringify. + */ +class SerializableSet extends Set { + toJSON() { + return Array.from(this); + } +} + +XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() { + let converter = Cc[ + "@mozilla.org/intl/scriptableunicodeconverter" + ].createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + return converter; +}); + +XPCOMUtils.defineLazyGetter( + Utils, + "utf8Encoder", + () => new TextEncoder("utf-8") +); + +/* + * Commonly-used services + */ +var Svc = {}; +Svc.Prefs = new Preferences(PREFS_BRANCH); +Svc.Obs = Observers; + +Svc.Obs.add("xpcom-shutdown", function() { + for (let name in Svc) { + delete Svc[name]; + } +}); |