mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-09-30 04:17:31 +09:00
import FIREFOX_52_6_0esr_RELEASE from mozilla-esr52 hg repo
This commit is contained in:
commit
dcd9973243
150858 changed files with 23884658 additions and 0 deletions
21
services/sync/SyncComponents.manifest
Normal file
21
services/sync/SyncComponents.manifest
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# WeaveService has to restrict its registration for the app-startup category
|
||||
# to the specific list of apps that use it so it doesn't get loaded in xpcshell.
|
||||
# Thus we restrict it to these apps:
|
||||
#
|
||||
# b2g: {3c2e2abc-06d4-11e1-ac3b-374f68613e61}
|
||||
# browser: {ec8030f7-c20a-464f-9b0e-13a3a9e97384}
|
||||
# mobile/android: {aa3c5121-dab2-40e2-81ca-7ea25febc110}
|
||||
# mobile/xul: {a23983c0-fd0e-11dc-95ff-0800200c9a66}
|
||||
# suite (comm): {92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}
|
||||
# graphene: {d1bfe7d9-c01e-4237-998b-7b5f960a4314}
|
||||
|
||||
# Weave.js
|
||||
component {74b89fb0-f200-4ae8-a3ec-dd164117f6de} Weave.js
|
||||
contract @mozilla.org/weave/service;1 {74b89fb0-f200-4ae8-a3ec-dd164117f6de}
|
||||
category app-startup WeaveService service,@mozilla.org/weave/service;1 application={3c2e2abc-06d4-11e1-ac3b-374f68613e61} application={ec8030f7-c20a-464f-9b0e-13a3a9e97384} application={aa3c5121-dab2-40e2-81ca-7ea25febc110} application={a23983c0-fd0e-11dc-95ff-0800200c9a66} application={92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a} application={99bceaaa-e3c6-48c1-b981-ef9b46b67d60} application={d1bfe7d9-c01e-4237-998b-7b5f960a4314}
|
||||
component {d28f8a0b-95da-48f4-b712-caf37097be41} Weave.js
|
||||
contract @mozilla.org/network/protocol/about;1?what=sync-log {d28f8a0b-95da-48f4-b712-caf37097be41}
|
||||
|
||||
# Register resource aliases
|
||||
# (Note, for tests these are also set up in addResourceAlias)
|
||||
resource services-sync resource://gre/modules/services-sync/
|
||||
200
services/sync/Weave.js
Normal file
200
services/sync/Weave.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/* 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/. */
|
||||
|
||||
const Cc = Components.classes;
|
||||
const Ci = Components.interfaces;
|
||||
const Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/FileUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
const SYNC_PREFS_BRANCH = "services.sync.";
|
||||
|
||||
|
||||
/**
|
||||
* Sync's XPCOM service.
|
||||
*
|
||||
* It is named "Weave" for historical reasons.
|
||||
*
|
||||
* It's worth noting how Sync is lazily loaded. We register a timer that
|
||||
* loads Sync a few seconds after app startup. This is so Sync does not
|
||||
* adversely affect application start time.
|
||||
*
|
||||
* If Sync is not configured, no extra Sync code is loaded. If an
|
||||
* external component (say the UI) needs to interact with Sync, it
|
||||
* should use the promise-base function whenLoaded() - something like the
|
||||
* following:
|
||||
*
|
||||
* // 1. Grab a handle to the Sync XPCOM service.
|
||||
* let service = Cc["@mozilla.org/weave/service;1"]
|
||||
* .getService(Components.interfaces.nsISupports)
|
||||
* .wrappedJSObject;
|
||||
*
|
||||
* // 2. Use the .then method of the promise.
|
||||
* service.whenLoaded().then(() => {
|
||||
* // You are free to interact with "Weave." objects.
|
||||
* return;
|
||||
* });
|
||||
*
|
||||
* And that's it! However, if you really want to avoid promises and do it
|
||||
* old-school, then
|
||||
*
|
||||
* // 1. Get a reference to the service as done in (1) above.
|
||||
*
|
||||
* // 2. Check if the service has been initialized.
|
||||
* if (service.ready) {
|
||||
* // You are free to interact with "Weave." objects.
|
||||
* return;
|
||||
* }
|
||||
*
|
||||
* // 3. Install "ready" listener.
|
||||
* Services.obs.addObserver(function onReady() {
|
||||
* Services.obs.removeObserver(onReady, "weave:service:ready");
|
||||
*
|
||||
* // You are free to interact with "Weave." objects.
|
||||
* }, "weave:service:ready", false);
|
||||
*
|
||||
* // 4. Trigger loading of Sync.
|
||||
* service.ensureLoaded();
|
||||
*/
|
||||
function WeaveService() {
|
||||
this.wrappedJSObject = this;
|
||||
this.ready = false;
|
||||
}
|
||||
WeaveService.prototype = {
|
||||
classID: Components.ID("{74b89fb0-f200-4ae8-a3ec-dd164117f6de}"),
|
||||
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
|
||||
Ci.nsISupportsWeakReference]),
|
||||
|
||||
ensureLoaded: function () {
|
||||
// If we are loaded and not using FxA, load the migration module.
|
||||
if (!this.fxAccountsEnabled) {
|
||||
Cu.import("resource://services-sync/FxaMigrator.jsm");
|
||||
}
|
||||
|
||||
Components.utils.import("resource://services-sync/main.js");
|
||||
|
||||
// Side-effect of accessing the service is that it is instantiated.
|
||||
Weave.Service;
|
||||
},
|
||||
|
||||
whenLoaded: function() {
|
||||
if (this.ready) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
let deferred = Promise.defer();
|
||||
|
||||
Services.obs.addObserver(function onReady() {
|
||||
Services.obs.removeObserver(onReady, "weave:service:ready");
|
||||
deferred.resolve();
|
||||
}, "weave:service:ready", false);
|
||||
this.ensureLoaded();
|
||||
return deferred.promise;
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether Firefox Accounts is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
get fxAccountsEnabled() {
|
||||
try {
|
||||
// Old sync guarantees '@' will never appear in the username while FxA
|
||||
// uses the FxA email address - so '@' is the flag we use.
|
||||
let username = Services.prefs.getCharPref(SYNC_PREFS_BRANCH + "username");
|
||||
return !username || username.includes('@');
|
||||
} catch (_) {
|
||||
return true; // No username == only allow FxA to be configured.
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Whether Sync appears to be enabled.
|
||||
*
|
||||
* This returns true if all the Sync preferences for storing account
|
||||
* and server configuration are populated.
|
||||
*
|
||||
* It does *not* perform a robust check to see if the client is working.
|
||||
* For that, you'll want to check Weave.Status.checkSetup().
|
||||
*/
|
||||
get enabled() {
|
||||
let prefs = Services.prefs.getBranch(SYNC_PREFS_BRANCH);
|
||||
return prefs.prefHasUserValue("username");
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
switch (topic) {
|
||||
case "app-startup":
|
||||
let os = Cc["@mozilla.org/observer-service;1"].
|
||||
getService(Ci.nsIObserverService);
|
||||
os.addObserver(this, "final-ui-startup", true);
|
||||
break;
|
||||
|
||||
case "final-ui-startup":
|
||||
// Force Weave service to load if it hasn't triggered from overlays
|
||||
this.timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
|
||||
this.timer.initWithCallback({
|
||||
notify: function() {
|
||||
let isConfigured = false;
|
||||
// We only load more if it looks like Sync is configured.
|
||||
let prefs = Services.prefs.getBranch(SYNC_PREFS_BRANCH);
|
||||
if (prefs.prefHasUserValue("username")) {
|
||||
// We have a username. So, do a more thorough check. This will
|
||||
// import a number of modules and thus increase memory
|
||||
// accordingly. We could potentially copy code performed by
|
||||
// this check into this file if our above code is yielding too
|
||||
// many false positives.
|
||||
Components.utils.import("resource://services-sync/main.js");
|
||||
isConfigured = Weave.Status.checkSetup() != Weave.CLIENT_NOT_CONFIGURED;
|
||||
}
|
||||
let getHistogramById = Services.telemetry.getHistogramById;
|
||||
getHistogramById("WEAVE_CONFIGURED").add(isConfigured);
|
||||
if (isConfigured) {
|
||||
getHistogramById("WEAVE_CONFIGURED_MASTER_PASSWORD").add(Utils.mpEnabled());
|
||||
this.ensureLoaded();
|
||||
}
|
||||
}.bind(this)
|
||||
}, 10000, Ci.nsITimer.TYPE_ONE_SHOT);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function AboutWeaveLog() {}
|
||||
AboutWeaveLog.prototype = {
|
||||
classID: Components.ID("{d28f8a0b-95da-48f4-b712-caf37097be41}"),
|
||||
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIAboutModule,
|
||||
Ci.nsISupportsWeakReference]),
|
||||
|
||||
getURIFlags: function(aURI) {
|
||||
return 0;
|
||||
},
|
||||
|
||||
newChannel: function(aURI, aLoadInfo) {
|
||||
let dir = FileUtils.getDir("ProfD", ["weave", "logs"], true);
|
||||
let uri = Services.io.newFileURI(dir);
|
||||
let channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo);
|
||||
|
||||
channel.originalURI = aURI;
|
||||
|
||||
// Ensure that the about page has the same privileges as a regular directory
|
||||
// view. That way links to files can be opened. make sure we use the correct
|
||||
// origin attributes when creating the principal for accessing the
|
||||
// about:sync-log data.
|
||||
let ssm = Cc["@mozilla.org/scriptsecuritymanager;1"]
|
||||
.getService(Ci.nsIScriptSecurityManager);
|
||||
let principal = ssm.createCodebasePrincipal(uri, aLoadInfo.originAttributes);
|
||||
|
||||
channel.owner = principal;
|
||||
return channel;
|
||||
}
|
||||
};
|
||||
|
||||
const components = [WeaveService, AboutWeaveLog];
|
||||
this.NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
|
||||
27
services/sync/locales/en-US/errors.properties
Normal file
27
services/sync/locales/en-US/errors.properties
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# 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/.
|
||||
|
||||
error.login.reason.network = Failed to connect to the server
|
||||
error.login.reason.recoverykey = Wrong Recovery Key
|
||||
error.login.reason.account = Incorrect account name or password
|
||||
error.login.reason.no_username = Missing account name
|
||||
error.login.reason.no_password2 = Missing password
|
||||
error.login.reason.no_recoverykey= No saved Recovery Key to use
|
||||
error.login.reason.server = Server incorrectly configured
|
||||
|
||||
error.sync.failed_partial = One or more data types could not be synced
|
||||
# LOCALIZATION NOTE (error.sync.reason.serverMaintenance): We removed the extraneous period from this string
|
||||
error.sync.reason.serverMaintenance = Firefox Sync server maintenance is underway, syncing will resume automatically
|
||||
|
||||
invalid-captcha = Incorrect words, try again
|
||||
weak-password = Use a stronger password
|
||||
|
||||
# this is the fallback, if we hit an error we didn't bother to localize
|
||||
error.reason.unknown = Unknown error
|
||||
|
||||
change.password.pwSameAsPassword = Password can’t match current password
|
||||
change.password.pwSameAsUsername = Password can’t match your user name
|
||||
change.password.pwSameAsEmail = Password can’t match your email address
|
||||
change.password.mismatch = The passwords entered do not match
|
||||
change.password.tooShort = The password entered is too short
|
||||
27
services/sync/locales/en-US/sync.properties
Normal file
27
services/sync/locales/en-US/sync.properties
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# 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/.
|
||||
|
||||
# %1: the user name (Ed), %2: the app name (Firefox), %3: the operating system (Android)
|
||||
client.name2 = %1$S’s %2$S on %3$S
|
||||
|
||||
# %S is the date and time at which the last sync successfully completed
|
||||
lastSync2.label = Last sync: %S
|
||||
|
||||
# signInToSync.description is the tooltip for the Sync buttons when Sync is
|
||||
# not configured.
|
||||
signInToSync.description = Sign In To Sync
|
||||
|
||||
error.sync.title = Error While Syncing
|
||||
error.sync.description = Sync encountered an error while syncing: %1$S. Sync will automatically retry this action.
|
||||
warning.sync.eol.label = Service Shutting Down
|
||||
# %1: the app name (Firefox)
|
||||
warning.sync.eol.description = Your Firefox Sync service is shutting down soon. Upgrade %1$S to keep syncing.
|
||||
error.sync.eol.label = Service Unavailable
|
||||
# %1: the app name (Firefox)
|
||||
error.sync.eol.description = Your Firefox Sync service is no longer available. You need to upgrade %1$S to keep syncing.
|
||||
sync.eol.learnMore.label = Learn more
|
||||
sync.eol.learnMore.accesskey = L
|
||||
|
||||
syncnow.label = Sync Now
|
||||
syncing2.label = Syncing…
|
||||
10
services/sync/locales/jar.mn
Normal file
10
services/sync/locales/jar.mn
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#filter substitution
|
||||
# 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/.
|
||||
|
||||
|
||||
@AB_CD@.jar:
|
||||
% locale weave @AB_CD@ %locale/@AB_CD@/
|
||||
locale/@AB_CD@/services/errors.properties (%errors.properties)
|
||||
locale/@AB_CD@/services/sync.properties (%sync.properties)
|
||||
9
services/sync/locales/l10n.ini
Normal file
9
services/sync/locales/l10n.ini
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
; 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/.
|
||||
|
||||
[general]
|
||||
depth = ../../..
|
||||
|
||||
[compare]
|
||||
dirs = services/sync
|
||||
7
services/sync/locales/moz.build
Normal file
7
services/sync/locales/moz.build
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
|
||||
# vim: set filetype=python:
|
||||
# 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/.
|
||||
|
||||
JAR_MANIFESTS += ['jar.mn']
|
||||
131
services/sync/modules-testing/fakeservices.js
Normal file
131
services/sync/modules-testing/fakeservices.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"FakeCryptoService",
|
||||
"FakeFilesystemService",
|
||||
"FakeGUIDService",
|
||||
"fakeSHA256HMAC",
|
||||
];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
var btoa = Cu.import("resource://gre/modules/Log.jsm").btoa;
|
||||
|
||||
this.FakeFilesystemService = 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 WTF 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 = function jsonSave(filePath, that, obj, callback) {
|
||||
let json = typeof obj == "function" ? obj.call(that) : obj;
|
||||
self.fakeContents["weave/" + filePath + ".json"] = JSON.stringify(json);
|
||||
callback.call(that);
|
||||
};
|
||||
|
||||
Utils.jsonLoad = function jsonLoad(filePath, that, cb) {
|
||||
let obj;
|
||||
let json = self.fakeContents["weave/" + filePath + ".json"];
|
||||
if (json) {
|
||||
obj = JSON.parse(json);
|
||||
}
|
||||
cb.call(that, 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();
|
||||
};
|
||||
};
|
||||
|
||||
this.fakeSHA256HMAC = function fakeSHA256HMAC(message) {
|
||||
message = message.substr(0, 64);
|
||||
while (message.length < 64) {
|
||||
message += " ";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
this.FakeGUIDService = 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.
|
||||
*/
|
||||
this.FakeCryptoService = function FakeCryptoService() {
|
||||
this.counter = 0;
|
||||
|
||||
delete Svc.Crypto; // get rid of the getter first
|
||||
Svc.Crypto = this;
|
||||
|
||||
CryptoWrapper.prototype.ciphertextHMAC = function ciphertextHMAC(keyBundle) {
|
||||
return fakeSHA256HMAC(this.ciphertext);
|
||||
};
|
||||
}
|
||||
FakeCryptoService.prototype = {
|
||||
|
||||
encrypt: function encrypt(clearText, symmetricKey, iv) {
|
||||
return clearText;
|
||||
},
|
||||
|
||||
decrypt: function decrypt(cipherText, symmetricKey, iv) {
|
||||
return cipherText;
|
||||
},
|
||||
|
||||
generateRandomKey: function 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;
|
||||
},
|
||||
|
||||
deriveKeyFromPassphrase: function deriveKeyFromPassphrase(passphrase,
|
||||
salt, keyLength) {
|
||||
return "some derived key string composed of bytes";
|
||||
},
|
||||
|
||||
generateRandomBytes: function generateRandomBytes(byteCount) {
|
||||
return "not-so-random-now-are-we-HA-HA-HA! >:)".slice(byteCount);
|
||||
}
|
||||
};
|
||||
|
||||
58
services/sync/modules-testing/fxa_utils.js
Normal file
58
services/sync/modules-testing/fxa_utils.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
"use strict";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"initializeIdentityWithTokenServerResponse",
|
||||
];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/main.js");
|
||||
Cu.import("resource://services-sync/browserid_identity.js");
|
||||
Cu.import("resource://services-common/tokenserverclient.js");
|
||||
Cu.import("resource://testing-common/services/common/logging.js");
|
||||
Cu.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.
|
||||
this.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: function() {},
|
||||
get: function(callback) {
|
||||
this.response = response;
|
||||
callback.call(this);
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
Weave.Service._clusterManager = Weave.Service.identity.createClusterManager(Weave.Service);
|
||||
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;
|
||||
}
|
||||
124
services/sync/modules-testing/rotaryengine.js
Normal file
124
services/sync/modules-testing/rotaryengine.js
Normal file
|
|
@ -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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"RotaryEngine",
|
||||
"RotaryRecord",
|
||||
"RotaryStore",
|
||||
"RotaryTracker",
|
||||
];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
/*
|
||||
* A fake engine implementation.
|
||||
* This is used all over the place.
|
||||
*
|
||||
* Complete with record, store, and tracker implementations.
|
||||
*/
|
||||
|
||||
this.RotaryRecord = function RotaryRecord(collection, id) {
|
||||
CryptoWrapper.call(this, collection, id);
|
||||
}
|
||||
RotaryRecord.prototype = {
|
||||
__proto__: CryptoWrapper.prototype
|
||||
};
|
||||
Utils.deferGetSet(RotaryRecord, "cleartext", ["denomination"]);
|
||||
|
||||
this.RotaryStore = function RotaryStore(name, engine) {
|
||||
Store.call(this, name, engine);
|
||||
this.items = {};
|
||||
}
|
||||
RotaryStore.prototype = {
|
||||
__proto__: Store.prototype,
|
||||
|
||||
create: function create(record) {
|
||||
this.items[record.id] = record.denomination;
|
||||
},
|
||||
|
||||
remove: function remove(record) {
|
||||
delete this.items[record.id];
|
||||
},
|
||||
|
||||
update: function update(record) {
|
||||
this.items[record.id] = record.denomination;
|
||||
},
|
||||
|
||||
itemExists: function itemExists(id) {
|
||||
return (id in this.items);
|
||||
},
|
||||
|
||||
createRecord: function 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;
|
||||
},
|
||||
|
||||
changeItemID: function changeItemID(oldID, newID) {
|
||||
if (oldID in this.items) {
|
||||
this.items[newID] = this.items[oldID];
|
||||
}
|
||||
|
||||
delete this.items[oldID];
|
||||
},
|
||||
|
||||
getAllIDs: function getAllIDs() {
|
||||
let ids = {};
|
||||
for (let id in this.items) {
|
||||
ids[id] = true;
|
||||
}
|
||||
return ids;
|
||||
},
|
||||
|
||||
wipe: function wipe() {
|
||||
this.items = {};
|
||||
}
|
||||
};
|
||||
|
||||
this.RotaryTracker = function RotaryTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
}
|
||||
RotaryTracker.prototype = {
|
||||
__proto__: Tracker.prototype
|
||||
};
|
||||
|
||||
|
||||
this.RotaryEngine = function RotaryEngine(service) {
|
||||
SyncEngine.call(this, "Rotary", service);
|
||||
// Ensure that the engine starts with a clean slate.
|
||||
this.toFetch = [];
|
||||
this.previousFailed = [];
|
||||
}
|
||||
RotaryEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: RotaryStore,
|
||||
_trackerObj: RotaryTracker,
|
||||
_recordObj: RotaryRecord,
|
||||
|
||||
_findDupe: function _findDupe(item) {
|
||||
// This is a semaphore 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
350
services/sync/modules-testing/utils.js
Normal file
350
services/sync/modules-testing/utils.js
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"btoa", // It comes from a module import.
|
||||
"encryptPayload",
|
||||
"isConfiguredWithLegacyIdentity",
|
||||
"ensureLegacyIdentityManager",
|
||||
"setBasicCredentials",
|
||||
"makeIdentityConfig",
|
||||
"makeFxAccountsInternalMock",
|
||||
"configureFxAccountIdentity",
|
||||
"configureIdentity",
|
||||
"SyncTestingInfrastructure",
|
||||
"waitForZeroTimer",
|
||||
"Promise", // from a module import
|
||||
"add_identity_test",
|
||||
"MockFxaStorageManager",
|
||||
"AccountState", // from a module import
|
||||
"sumHistogram",
|
||||
];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/status.js");
|
||||
Cu.import("resource://services-sync/identity.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-crypto/utils.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/browserid_identity.js");
|
||||
Cu.import("resource://testing-common/services/common/logging.js");
|
||||
Cu.import("resource://testing-common/services/sync/fakeservices.js");
|
||||
Cu.import("resource://gre/modules/FxAccounts.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccountsClient.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js");
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
// and grab non-exported stuff via a backstage pass.
|
||||
const {AccountState} = Cu.import("resource://gre/modules/FxAccounts.jsm", {});
|
||||
|
||||
// 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() {
|
||||
return Promise.resolve(this.accountData);
|
||||
},
|
||||
|
||||
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 Utils.nextTick() in autoConnect).
|
||||
*/
|
||||
this.waitForZeroTimer = function waitForZeroTimer(callback) {
|
||||
let ticks = 2;
|
||||
function wait() {
|
||||
if (ticks) {
|
||||
ticks -= 1;
|
||||
CommonUtils.nextTick(wait);
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
}
|
||||
CommonUtils.namedTimer(wait, 150, {}, "timer");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if Sync is configured with the "legacy" identity provider.
|
||||
*/
|
||||
this.isConfiguredWithLegacyIdentity = function() {
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
|
||||
// We can't use instanceof as BrowserIDManager (the "other" identity) inherits
|
||||
// from IdentityManager so that would return true - so check the prototype.
|
||||
return Object.getPrototypeOf(ns.Service.identity) === IdentityManager.prototype;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure Sync is configured with the "legacy" identity provider.
|
||||
*/
|
||||
this.ensureLegacyIdentityManager = function() {
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
|
||||
Status.__authManager = ns.Service.identity = new IdentityManager();
|
||||
ns.Service._clusterManager = ns.Service.identity.createClusterManager(ns.Service);
|
||||
}
|
||||
|
||||
this.setBasicCredentials =
|
||||
function setBasicCredentials(username, password, syncKey) {
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
|
||||
let auth = ns.Service.identity;
|
||||
auth.username = username;
|
||||
auth.basicPassword = password;
|
||||
auth.syncKey = syncKey;
|
||||
}
|
||||
|
||||
// Return an identity configuration suitable for testing with our identity
|
||||
// providers. |overrides| can specify overrides for any default values.
|
||||
this.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: 'email',
|
||||
kA: 'kA',
|
||||
kB: 'kB',
|
||||
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.
|
||||
}
|
||||
},
|
||||
sync: {
|
||||
// username will come from the top-level username
|
||||
password: "whatever",
|
||||
syncKey: "abcdeabcdeabcdeabcdeabcdea",
|
||||
}
|
||||
};
|
||||
|
||||
// Now handle any specified overrides.
|
||||
if (overrides) {
|
||||
if (overrides.username) {
|
||||
result.username = overrides.username;
|
||||
}
|
||||
if (overrides.sync) {
|
||||
// TODO: allow just some attributes to be specified
|
||||
result.sync = overrides.sync;
|
||||
}
|
||||
if (overrides.fxaccount) {
|
||||
// TODO: allow just some attributes to be specified
|
||||
result.fxaccount = overrides.fxaccount;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
this.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("assertion");
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Configure an instance of an FxAccount identity provider with the specified
|
||||
// config (or the default config if not specified).
|
||||
this.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
|
||||
getTokenFromBrowserIDAssertion: function(uri, assertion, cb) {
|
||||
config.fxaccount.token.uid = config.username;
|
||||
cb(null, 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;
|
||||
}
|
||||
|
||||
this.configureIdentity = function(identityOverrides) {
|
||||
let config = makeIdentityConfig(identityOverrides);
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
|
||||
if (ns.Service.identity instanceof BrowserIDManager) {
|
||||
// do the FxAccounts thang...
|
||||
configureFxAccountIdentity(ns.Service.identity, config);
|
||||
return ns.Service.identity.initializeWithCurrentIdentity().then(() => {
|
||||
// need to wait until this identity manager is readyToAuthenticate.
|
||||
return ns.Service.identity.whenReadyToAuthenticate.promise;
|
||||
});
|
||||
}
|
||||
// old style identity provider.
|
||||
setBasicCredentials(config.username, config.sync.password, config.sync.syncKey);
|
||||
let deferred = Promise.defer();
|
||||
deferred.resolve();
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
this.SyncTestingInfrastructure = function (server, username, password, syncKey) {
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
|
||||
ensureLegacyIdentityManager();
|
||||
let config = makeIdentityConfig();
|
||||
// XXX - hacks for the sync identity provider.
|
||||
if (username)
|
||||
config.username = username;
|
||||
if (password)
|
||||
config.sync.password = password;
|
||||
if (syncKey)
|
||||
config.sync.syncKey = syncKey;
|
||||
let cb = Async.makeSpinningCallback();
|
||||
configureIdentity(config).then(cb, cb);
|
||||
cb.wait();
|
||||
|
||||
let i = server.identity;
|
||||
let uri = i.primaryScheme + "://" + i.primaryHost + ":" +
|
||||
i.primaryPort + "/";
|
||||
|
||||
ns.Service.serverURL = uri;
|
||||
ns.Service.clusterURL = uri;
|
||||
|
||||
this.logStats = initTestLogging();
|
||||
this.fakeFilesystem = new FakeFilesystemService({});
|
||||
this.fakeGUIDService = new FakeGUIDService();
|
||||
this.fakeCryptoService = new FakeCryptoService();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn WBO cleartext into fake "encrypted" payload as it goes over the wire.
|
||||
*/
|
||||
this.encryptPayload = 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("")),
|
||||
};
|
||||
}
|
||||
|
||||
// This helper can be used instead of 'add_test' or 'add_task' to run the
|
||||
// specified test function twice - once with the old-style sync identity
|
||||
// manager and once with the new-style BrowserID identity manager, to ensure
|
||||
// it works in both cases.
|
||||
//
|
||||
// * The test itself should be passed as 'test' - ie, test code will generally
|
||||
// pass |this|.
|
||||
// * The test function is a regular test function - although note that it must
|
||||
// be a generator - async operations should yield them, and run_next_test
|
||||
// mustn't be called.
|
||||
this.add_identity_test = function(test, testFunction) {
|
||||
function note(what) {
|
||||
let msg = "running test " + testFunction.name + " with " + what + " identity manager";
|
||||
test.do_print(msg);
|
||||
}
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/service.js", ns);
|
||||
// one task for the "old" identity manager.
|
||||
test.add_task(function* () {
|
||||
note("sync");
|
||||
let oldIdentity = Status._authManager;
|
||||
ensureLegacyIdentityManager();
|
||||
yield testFunction();
|
||||
Status.__authManager = ns.Service.identity = oldIdentity;
|
||||
});
|
||||
// another task for the FxAccounts identity manager.
|
||||
test.add_task(function* () {
|
||||
note("FxAccounts");
|
||||
let oldIdentity = Status._authManager;
|
||||
Status.__authManager = ns.Service.identity = new BrowserIDManager();
|
||||
yield testFunction();
|
||||
Status.__authManager = ns.Service.identity = oldIdentity;
|
||||
});
|
||||
}
|
||||
|
||||
this.sumHistogram = function(name, options = {}) {
|
||||
let histogram = options.key ? Services.telemetry.getKeyedHistogramById(name) :
|
||||
Services.telemetry.getHistogramById(name);
|
||||
let snapshot = histogram.snapshot(options.key);
|
||||
let sum = -Infinity;
|
||||
if (snapshot) {
|
||||
sum = snapshot.sum;
|
||||
}
|
||||
histogram.clear();
|
||||
return sum;
|
||||
}
|
||||
99
services/sync/modules/FxaMigrator.jsm
Normal file
99
services/sync/modules/FxaMigrator.jsm
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* 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;"
|
||||
|
||||
// Note that this module used to supervise the step-by-step migration from
|
||||
// a legacy Sync account to a FxA-based Sync account. In bug 1205928, this
|
||||
// changed to automatically disconnect the legacy Sync account.
|
||||
|
||||
const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "WeaveService", function() {
|
||||
return Cc["@mozilla.org/weave/service;1"]
|
||||
.getService(Components.interfaces.nsISupports)
|
||||
.wrappedJSObject;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Weave",
|
||||
"resource://services-sync/main.js");
|
||||
|
||||
// We send this notification when we perform the disconnection. The browser
|
||||
// window will show a one-off notification bar.
|
||||
const OBSERVER_STATE_CHANGE_TOPIC = "fxa-migration:state-changed";
|
||||
|
||||
const OBSERVER_TOPICS = [
|
||||
"xpcom-shutdown",
|
||||
"weave:eol",
|
||||
];
|
||||
|
||||
function Migrator() {
|
||||
// Leave the log-level as Debug - Sync will setup log appenders such that
|
||||
// these messages generally will not be seen unless other log related
|
||||
// prefs are set.
|
||||
this.log.level = Log.Level.Debug;
|
||||
|
||||
for (let topic of OBSERVER_TOPICS) {
|
||||
Services.obs.addObserver(this, topic, false);
|
||||
}
|
||||
}
|
||||
|
||||
Migrator.prototype = {
|
||||
log: Log.repository.getLogger("Sync.SyncMigration"),
|
||||
|
||||
finalize() {
|
||||
for (let topic of OBSERVER_TOPICS) {
|
||||
Services.obs.removeObserver(this, topic);
|
||||
}
|
||||
},
|
||||
|
||||
observe(subject, topic, data) {
|
||||
this.log.debug("observed " + topic);
|
||||
switch (topic) {
|
||||
case "xpcom-shutdown":
|
||||
this.finalize();
|
||||
break;
|
||||
|
||||
default:
|
||||
// this notification when configured with legacy Sync means we want to
|
||||
// disconnect
|
||||
if (!WeaveService.fxAccountsEnabled) {
|
||||
this.log.info("Disconnecting from legacy Sync");
|
||||
// Set up an observer for when the disconnection is complete.
|
||||
let observe;
|
||||
Services.obs.addObserver(observe = () => {
|
||||
this.log.info("observed that startOver is complete");
|
||||
Services.obs.removeObserver(observe, "weave:service:start-over:finish");
|
||||
// Send the notification for the UI.
|
||||
Services.obs.notifyObservers(null, OBSERVER_STATE_CHANGE_TOPIC, null);
|
||||
}, "weave:service:start-over:finish", false);
|
||||
|
||||
// Do the disconnection.
|
||||
Weave.Service.startOver();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
get learnMoreLink() {
|
||||
try {
|
||||
var url = Services.prefs.getCharPref("app.support.baseURL");
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
url += "sync-upgrade";
|
||||
let sb = Services.strings.createBundle("chrome://weave/locale/services/sync.properties");
|
||||
return {
|
||||
text: sb.GetStringFromName("sync.eol.learnMore.label"),
|
||||
href: Services.urlFormatter.formatURL(url),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// We expose a singleton
|
||||
this.EXPORTED_SYMBOLS = ["fxaMigrator"];
|
||||
var fxaMigrator = new Migrator();
|
||||
301
services/sync/modules/SyncedTabs.jsm
Normal file
301
services/sync/modules/SyncedTabs.jsm
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["SyncedTabs"];
|
||||
|
||||
|
||||
const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/PlacesUtils.jsm", this);
|
||||
Cu.import("resource://services-sync/main.js");
|
||||
Cu.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;
|
||||
|
||||
let log = Log.repository.getLogger("Sync.RemoteTabs");
|
||||
// A new scope to do the logging thang...
|
||||
(function() {
|
||||
let level = Preferences.get("services.sync.log.logger.tabs");
|
||||
if (level) {
|
||||
let appender = new Log.DumpAppender();
|
||||
log.level = appender.level = Log.Level[level] || Log.Level.Debug;
|
||||
log.addAppender(appender);
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// A private singleton that does the work.
|
||||
let SyncedTabsInternal = {
|
||||
/* Make a "tab" record. Returns a promise */
|
||||
_makeTab: Task.async(function* (client, tab, url, showRemoteIcons) {
|
||||
let icon;
|
||||
if (showRemoteIcons) {
|
||||
icon = tab.icon;
|
||||
}
|
||||
if (!icon) {
|
||||
try {
|
||||
icon = (yield PlacesUtils.promiseFaviconLinkUrl(url)).spec;
|
||||
} catch (ex) { /* no favicon avaiable */ }
|
||||
}
|
||||
if (!icon) {
|
||||
icon = "";
|
||||
}
|
||||
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 */
|
||||
_makeClient: Task.async(function* (client) {
|
||||
return {
|
||||
id: client.id,
|
||||
type: "client",
|
||||
name: Weave.Service.clientsEngine.getClientName(client.id),
|
||||
isMobile: Weave.Service.clientsEngine.isMobile(client.id),
|
||||
lastModified: client.lastModified * 1000, // sec to ms
|
||||
tabs: []
|
||||
};
|
||||
}),
|
||||
|
||||
_tabMatchesFilter(tab, filter) {
|
||||
let reFilter = new RegExp(escapeRegExp(filter), "i");
|
||||
return tab.url.match(reFilter) || tab.title.match(reFilter);
|
||||
},
|
||||
|
||||
getTabClients: Task.async(function* (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 seenURLs = new Set();
|
||||
let parentIndex = 0;
|
||||
let ntabs = 0;
|
||||
|
||||
for (let [guid, client] of Object.entries(engine.getAllClients())) {
|
||||
if (!Weave.Service.clientsEngine.remoteClientExists(client.id)) {
|
||||
continue;
|
||||
}
|
||||
let clientRepr = yield this._makeClient(client);
|
||||
log.debug("Processing client", clientRepr);
|
||||
|
||||
for (let tab of client.tabs) {
|
||||
let url = tab.urlHistory[0];
|
||||
log.debug("remote tab", url);
|
||||
// Note there are some issues with tracking "seen" tabs, including:
|
||||
// * We really can't return the entire urlHistory record as we are
|
||||
// only checking the first entry - others might be different.
|
||||
// * We don't update the |lastUsed| timestamp to reflect the
|
||||
// most-recently-seen time.
|
||||
// In a followup we should consider simply dropping this |seenUrls|
|
||||
// check and return duplicate records - it seems the user will be more
|
||||
// confused by tabs not showing up on a device (because it was detected
|
||||
// as a dupe so it only appears on a different device) than being
|
||||
// confused by seeing the same tab on different clients.
|
||||
if (!url || seenURLs.has(url)) {
|
||||
continue;
|
||||
}
|
||||
let tabRepr = yield this._makeTab(client, tab, url, showRemoteIcons);
|
||||
if (filter && !this._tabMatchesFilter(tabRepr, filter)) {
|
||||
continue;
|
||||
}
|
||||
seenURLs.add(url);
|
||||
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;
|
||||
}),
|
||||
|
||||
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 Promise.resolve(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 Promise.resolve(false);
|
||||
}
|
||||
// Ask Sync to just do the tabs engine if it can.
|
||||
// Sync is currently synchronous, so do it after an event-loop spin to help
|
||||
// keep the UI responsive.
|
||||
return new Promise((resolve, reject) => {
|
||||
Services.tm.currentThread.dispatch(() => {
|
||||
try {
|
||||
log.info("Doing a tab sync.");
|
||||
Weave.Service.sync(["tabs"]);
|
||||
resolve(true);
|
||||
} catch (ex) {
|
||||
log.error("Sync failed", ex);
|
||||
reject(ex);
|
||||
};
|
||||
}, Ci.nsIThread.DISPATCH_NORMAL);
|
||||
});
|
||||
},
|
||||
|
||||
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, null);
|
||||
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, null);
|
||||
break;
|
||||
case "nsPref:changed":
|
||||
Services.obs.notifyObservers(null, TOPIC_TABS_CHANGED, null);
|
||||
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", false);
|
||||
Services.obs.addObserver(SyncedTabsInternal, "weave:service:start-over", false);
|
||||
// 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, false);
|
||||
|
||||
// The public interface.
|
||||
this.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, maxTabs = Infinity) {
|
||||
// First sort and filter 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);
|
||||
if (Number.isFinite(maxTabs)) {
|
||||
client.tabs = tabs.slice(0, maxTabs);
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
676
services/sync/modules/addonsreconciler.js
Normal file
676
services/sync/modules/addonsreconciler.js
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
/* 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";
|
||||
|
||||
var Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://gre/modules/AddonManager.jsm");
|
||||
|
||||
const DEFAULT_STATE_FILE = "addonsreconciler";
|
||||
|
||||
this.CHANGE_INSTALLED = 1;
|
||||
this.CHANGE_UNINSTALLED = 2;
|
||||
this.CHANGE_ENABLED = 3;
|
||||
this.CHANGE_DISABLED = 4;
|
||||
|
||||
this.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();
|
||||
* reconciler.loadState(null, function(error) { ... });
|
||||
*
|
||||
* // At this point, your instance should be ready to use.
|
||||
*
|
||||
* When you are finished with the instance, please call:
|
||||
*
|
||||
* reconciler.stopListening();
|
||||
* reconciler.saveState(...);
|
||||
*
|
||||
* There are 2 classes of listeners in the AddonManager: AddonListener and
|
||||
* InstallListener. This class is a listener for both (member functions just
|
||||
* get called directly).
|
||||
*
|
||||
* When an add-on is installed, listeners are called in the following order:
|
||||
*
|
||||
* IL.onInstallStarted, AL.onInstalling, IL.onInstallEnded, AL.onInstalled
|
||||
*
|
||||
* For non-restartless add-ons, an application restart may occur between
|
||||
* IL.onInstallEnded and AL.onInstalled. Unfortunately, Sync likely will
|
||||
* not be loaded when AL.onInstalled is fired shortly after application
|
||||
* start, so it won't see this event. Therefore, for add-ons requiring a
|
||||
* restart, Sync treats the IL.onInstallEnded event as good enough to
|
||||
* indicate an install. For restartless add-ons, Sync assumes AL.onInstalled
|
||||
* will follow shortly after IL.onInstallEnded and thus it ignores
|
||||
* IL.onInstallEnded.
|
||||
*
|
||||
* The listeners can also see events related to the download of the add-on.
|
||||
* This class isn't interested in those. However, there are failure events,
|
||||
* IL.onDownloadFailed and IL.onDownloadCanceled which get called if a
|
||||
* download doesn't complete successfully.
|
||||
*
|
||||
* For uninstalls, we see AL.onUninstalling then AL.onUninstalled. Like
|
||||
* installs, the events could be separated by an application restart and Sync
|
||||
* may not see the onUninstalled event. Again, if we require a restart, we
|
||||
* react to onUninstalling. If not, we assume we'll get onUninstalled.
|
||||
*
|
||||
* Enabling and disabling work by sending:
|
||||
*
|
||||
* AL.onEnabling, AL.onEnabled
|
||||
* AL.onDisabling, AL.onDisabled
|
||||
*
|
||||
* Again, they may be separated by a restart, so we heed the requiresRestart
|
||||
* flag.
|
||||
*
|
||||
* Actions can be undone. All undoable actions notify the same
|
||||
* AL.onOperationCancelled event. We treat this event like any other.
|
||||
*
|
||||
* Restartless add-ons have interesting behavior during uninstall. These
|
||||
* 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.
|
||||
*/
|
||||
this.AddonsReconciler = function AddonsReconciler() {
|
||||
this._log = Log.repository.getLogger("Sync.AddonsReconciler");
|
||||
let level = Svc.Prefs.get("log.logger.addonsreconciler", "Debug");
|
||||
this._log.level = Log.Level[level];
|
||||
|
||||
Svc.Obs.add("xpcom-shutdown", this.stopListening, this);
|
||||
};
|
||||
AddonsReconciler.prototype = {
|
||||
/** Flag indicating whether we are listening to AddonManager events. */
|
||||
_listening: false,
|
||||
|
||||
/**
|
||||
* Whether state has been loaded from a file.
|
||||
*
|
||||
* State is loaded on demand if an operation requires it.
|
||||
*/
|
||||
_stateLoaded: 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() {
|
||||
this._ensureStateLoaded();
|
||||
return this._addons;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 path
|
||||
* Path to load. ".json" is appended automatically. If not defined,
|
||||
* a default path will be consulted.
|
||||
* @param callback
|
||||
* Callback to be executed upon file load. The callback receives a
|
||||
* truthy error argument signifying whether an error occurred and a
|
||||
* boolean indicating whether data was loaded.
|
||||
*/
|
||||
loadState: function loadState(path, callback) {
|
||||
let file = path || DEFAULT_STATE_FILE;
|
||||
Utils.jsonLoad(file, this, function(json) {
|
||||
this._addons = {};
|
||||
this._changes = [];
|
||||
|
||||
if (!json) {
|
||||
this._log.debug("No data seen in loaded file: " + file);
|
||||
if (callback) {
|
||||
callback(null, false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let version = json.version;
|
||||
if (!version || version != 1) {
|
||||
this._log.error("Could not load JSON file because version not " +
|
||||
"supported: " + version);
|
||||
if (callback) {
|
||||
callback(null, false);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(null, true);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves the current state to a file in the local profile.
|
||||
*
|
||||
* @param path
|
||||
* String path in profile to save to. If not defined, the default
|
||||
* will be used.
|
||||
* @param callback
|
||||
* Function to be invoked on save completion. No parameters will be
|
||||
* passed to callback.
|
||||
*/
|
||||
saveState: function saveState(path, callback) {
|
||||
let file = path || 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);
|
||||
Utils.jsonSave(file, this, state, callback);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.indexOf(listener) == -1) {
|
||||
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(function(element) {
|
||||
if (element == listener) {
|
||||
this._log.debug("Removing change listener.");
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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);
|
||||
AddonManager.addInstallListener(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 listeners.");
|
||||
AddonManager.removeInstallListener(this);
|
||||
AddonManager.removeAddonListener(this);
|
||||
this._listening = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Refreshes the global state of add-ons by querying the AddonManager.
|
||||
*/
|
||||
refreshGlobalState: function refreshGlobalState(callback) {
|
||||
this._log.info("Refreshing global state from AddonManager.");
|
||||
this._ensureStateLoaded();
|
||||
|
||||
let installs;
|
||||
|
||||
AddonManager.getAllAddons(function (addons) {
|
||||
let ids = {};
|
||||
|
||||
for (let addon of addons) {
|
||||
ids[addon.id] = true;
|
||||
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) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
AddonManager.getAllInstalls(cb);
|
||||
installs = Async.waitForSyncCallback(cb);
|
||||
}
|
||||
|
||||
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);
|
||||
this._addChange(new Date(), CHANGE_UNINSTALLED, addon);
|
||||
}
|
||||
}
|
||||
|
||||
// See note for _shouldPersist.
|
||||
if (this._shouldPersist) {
|
||||
this.saveState(null, callback);
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
rectifyStateFromAddon: function rectifyStateFromAddon(addon) {
|
||||
this._log.debug(`Rectifying state for addon ${addon.name} (version=${addon.version}, id=${addon.id})`);
|
||||
this._ensureStateLoaded();
|
||||
|
||||
let id = addon.id;
|
||||
let enabled = !addon.userDisabled;
|
||||
let guid = addon.syncGUID;
|
||||
let now = new Date();
|
||||
|
||||
if (!(id in this._addons)) {
|
||||
let record = {
|
||||
id: id,
|
||||
guid: guid,
|
||||
enabled: 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);
|
||||
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);
|
||||
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.
|
||||
*/
|
||||
_addChange: function _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 {
|
||||
listener.changeListener.call(listener, date, change, state);
|
||||
} catch (ex) {
|
||||
this._log.warn("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: function getChangesSinceDate(date) {
|
||||
this._ensureStateLoaded();
|
||||
|
||||
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: function pruneChangesBeforeDate(date) {
|
||||
this._ensureStateLoaded();
|
||||
|
||||
this._changes = this._changes.filter(function test_age(change) {
|
||||
return change[0] >= date;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtains the set of all known Sync GUIDs for add-ons.
|
||||
*
|
||||
* @return Object with guids as keys and values of true.
|
||||
*/
|
||||
getAllSyncGUIDs: function 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.
|
||||
* @return Object on success on null on failure.
|
||||
*/
|
||||
getAddonStateFromSyncGUID: function getAddonStateFromSyncGUID(guid) {
|
||||
for (let id in this.addons) {
|
||||
let addon = this.addons[id];
|
||||
if (addon.guid == guid) {
|
||||
return addon;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensures that state is loaded before continuing.
|
||||
*
|
||||
* This is called internally by anything that accesses the internal data
|
||||
* structures. It effectively just-in-time loads serialized state.
|
||||
*/
|
||||
_ensureStateLoaded: function _ensureStateLoaded() {
|
||||
if (this._stateLoaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this.loadState(null, cb);
|
||||
cb.wait();
|
||||
this._stateLoaded = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Handler that is invoked as part of the AddonManager listeners.
|
||||
*/
|
||||
_handleListener: function _handlerListener(action, addon, requiresRestart) {
|
||||
// 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);
|
||||
|
||||
// We assume that every event for non-restartless add-ons is
|
||||
// followed by another event and that this follow-up event is the most
|
||||
// appropriate to react to. Currently we ignore onEnabling, onDisabling,
|
||||
// and onUninstalling for non-restartless add-ons.
|
||||
if (requiresRestart === false) {
|
||||
this._log.debug("Ignoring " + action + " for restartless add-on.");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case "onEnabling":
|
||||
case "onEnabled":
|
||||
case "onDisabling":
|
||||
case "onDisabled":
|
||||
case "onInstalled":
|
||||
case "onInstallEnded":
|
||||
case "onOperationCancelled":
|
||||
this.rectifyStateFromAddon(addon);
|
||||
break;
|
||||
|
||||
case "onUninstalling":
|
||||
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);
|
||||
this._addChange(now, CHANGE_UNINSTALLED, record);
|
||||
}
|
||||
}
|
||||
|
||||
// See note for _shouldPersist.
|
||||
if (this._shouldPersist) {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this.saveState(null, cb);
|
||||
cb.wait();
|
||||
}
|
||||
}
|
||||
catch (ex) {
|
||||
this._log.warn("Exception", ex);
|
||||
}
|
||||
},
|
||||
|
||||
// AddonListeners
|
||||
onEnabling: function onEnabling(addon, requiresRestart) {
|
||||
this._handleListener("onEnabling", addon, requiresRestart);
|
||||
},
|
||||
onEnabled: function onEnabled(addon) {
|
||||
this._handleListener("onEnabled", addon);
|
||||
},
|
||||
onDisabling: function onDisabling(addon, requiresRestart) {
|
||||
this._handleListener("onDisabling", addon, requiresRestart);
|
||||
},
|
||||
onDisabled: function onDisabled(addon) {
|
||||
this._handleListener("onDisabled", addon);
|
||||
},
|
||||
onInstalling: function onInstalling(addon, requiresRestart) {
|
||||
this._handleListener("onInstalling", addon, requiresRestart);
|
||||
},
|
||||
onInstalled: function onInstalled(addon) {
|
||||
this._handleListener("onInstalled", addon);
|
||||
},
|
||||
onUninstalling: function onUninstalling(addon, requiresRestart) {
|
||||
this._handleListener("onUninstalling", addon, requiresRestart);
|
||||
},
|
||||
onUninstalled: function onUninstalled(addon) {
|
||||
this._handleListener("onUninstalled", addon);
|
||||
},
|
||||
onOperationCancelled: function onOperationCancelled(addon) {
|
||||
this._handleListener("onOperationCancelled", addon);
|
||||
},
|
||||
|
||||
// InstallListeners
|
||||
onInstallEnded: function onInstallEnded(install, addon) {
|
||||
this._handleListener("onInstallEnded", addon);
|
||||
}
|
||||
};
|
||||
506
services/sync/modules/addonutils.js
Normal file
506
services/sync/modules/addonutils.js
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["AddonUtils"];
|
||||
|
||||
var {interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
|
||||
"resource://gre/modules/AddonManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(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 callback will be invoked with the result of the operation. The
|
||||
* callback receives 2 arguments, error and result. Error will be falsy
|
||||
* on success or some kind of error value otherwise. The result argument
|
||||
* will be an AddonInstall on success or null on failure. It is possible
|
||||
* for the error to be falsy but result to be null. This could happen if
|
||||
* an install was not found.
|
||||
*
|
||||
* @param addon
|
||||
* AddonSearchResult to obtain install from.
|
||||
* @param cb
|
||||
* Function to be called with result of operation.
|
||||
*/
|
||||
getInstallFromSearchResult:
|
||||
function getInstallFromSearchResult(addon, cb) {
|
||||
|
||||
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.
|
||||
AddonManager.getInstallForURL(
|
||||
addon.sourceURI.spec,
|
||||
function handleInstall(install) {
|
||||
cb(null, install);
|
||||
},
|
||||
"application/x-xpinstall",
|
||||
undefined,
|
||||
addon.name,
|
||||
addon.iconURL,
|
||||
addon.version
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* When complete it calls a callback with 2 arguments, error and result.
|
||||
*
|
||||
* If error is falsy, result is an object. If error is truthy, result is
|
||||
* null.
|
||||
*
|
||||
* 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.
|
||||
* @param cb
|
||||
* Function to be invoked with result of operation.
|
||||
*/
|
||||
installAddonFromSearchResult:
|
||||
function installAddonFromSearchResult(addon, options, cb) {
|
||||
this._log.info("Trying to install add-on from search result: " + addon.id);
|
||||
|
||||
this.getInstallFromSearchResult(addon, function onResult(error, install) {
|
||||
if (error) {
|
||||
cb(error, null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!install) {
|
||||
cb(new Error("AddonInstall not available: " + addon.id), null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._log.info("Installing " + addon.id);
|
||||
let log = this._log;
|
||||
|
||||
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.userDisabled = true;
|
||||
}
|
||||
},
|
||||
onInstallEnded: function(install, addon) {
|
||||
install.removeListener(listener);
|
||||
|
||||
cb(null, {id: addon.id, install: install, addon: addon});
|
||||
},
|
||||
onInstallFailed: function(install) {
|
||||
install.removeListener(listener);
|
||||
|
||||
cb(new Error("Install failed: " + install.error), null);
|
||||
},
|
||||
onDownloadFailed: function(install) {
|
||||
install.removeListener(listener);
|
||||
|
||||
cb(new Error("Download failed: " + install.error), null);
|
||||
}
|
||||
};
|
||||
install.addListener(listener);
|
||||
install.install();
|
||||
}
|
||||
catch (ex) {
|
||||
this._log.error("Error installing add-on", ex);
|
||||
cb(ex, null);
|
||||
}
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
/**
|
||||
* Uninstalls the Addon instance and invoke a callback when it is done.
|
||||
*
|
||||
* @param addon
|
||||
* Addon instance to uninstall.
|
||||
* @param cb
|
||||
* Function to be invoked when uninstall has finished. It receives a
|
||||
* truthy value signifying error and the add-on which was uninstalled.
|
||||
*/
|
||||
uninstallAddon: function uninstallAddon(addon, cb) {
|
||||
let listener = {
|
||||
onUninstalling: function(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);
|
||||
cb(null, addon);
|
||||
},
|
||||
onUninstalled: function(uninstalled) {
|
||||
if (addon.id != uninstalled.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(null, 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.
|
||||
* @param cb
|
||||
* Function to be called when all actions are complete.
|
||||
*/
|
||||
installAddons: function installAddons(installs, cb) {
|
||||
if (!cb) {
|
||||
throw new Error("Invalid argument: cb is not defined.");
|
||||
}
|
||||
|
||||
let ids = [];
|
||||
for (let addon of installs) {
|
||||
ids.push(addon.id);
|
||||
}
|
||||
|
||||
AddonRepository.getAddonsByIDs(ids, {
|
||||
searchSucceeded: function searchSucceeded(addons, addonsLength, total) {
|
||||
this._log.info("Found " + addonsLength + "/" + ids.length +
|
||||
" add-ons during repository search.");
|
||||
|
||||
let ourResult = {
|
||||
installedIDs: [],
|
||||
installs: [],
|
||||
addons: [],
|
||||
skipped: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
if (!addonsLength) {
|
||||
cb(null, ourResult);
|
||||
return;
|
||||
}
|
||||
|
||||
let expectedInstallCount = 0;
|
||||
let finishedCount = 0;
|
||||
let installCallback = function installCallback(error, result) {
|
||||
finishedCount++;
|
||||
|
||||
if (error) {
|
||||
ourResult.errors.push(error);
|
||||
} else {
|
||||
ourResult.installedIDs.push(result.id);
|
||||
ourResult.installs.push(result.install);
|
||||
ourResult.addons.push(result.addon);
|
||||
}
|
||||
|
||||
if (finishedCount >= expectedInstallCount) {
|
||||
if (ourResult.errors.length > 0) {
|
||||
cb(new Error("1 or more add-ons failed to install"), ourResult);
|
||||
} else {
|
||||
cb(null, ourResult);
|
||||
}
|
||||
}
|
||||
}.bind(this);
|
||||
|
||||
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";
|
||||
} else {
|
||||
return param;
|
||||
}
|
||||
});
|
||||
|
||||
addon.sourceURI.query = params.join("&");
|
||||
}
|
||||
|
||||
expectedInstallCount = toInstall.length;
|
||||
|
||||
if (!expectedInstallCount) {
|
||||
cb(null, ourResult);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
this.installAddonFromSearchResult(addon, options, installCallback);
|
||||
}
|
||||
|
||||
}.bind(this),
|
||||
|
||||
searchFailed: function searchFailed() {
|
||||
cb(new Error("AddonRepository search failed"), null);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
this._log.info(`Add-on "${addon.id}" is able to be installed`);
|
||||
return true;
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Update the user disabled flag for an add-on.
|
||||
*
|
||||
* The supplied callback will be called when the operation is
|
||||
* complete. If the new flag matches the existing or if the add-on
|
||||
* isn't currently active, the function will fire the callback
|
||||
* immediately. Else, the callback is invoked when the AddonManager
|
||||
* reports the change has taken effect or has been registered.
|
||||
*
|
||||
* The callback receives as arguments:
|
||||
*
|
||||
* (Error) Encountered error during operation or null on success.
|
||||
* (Addon) The add-on instance being operated on.
|
||||
*
|
||||
* @param addon
|
||||
* (Addon) Add-on instance to operate on.
|
||||
* @param value
|
||||
* (bool) New value for add-on's userDisabled property.
|
||||
* @param cb
|
||||
* (function) Callback to be invoked on completion.
|
||||
*/
|
||||
updateUserDisabled: function updateUserDisabled(addon, value, cb) {
|
||||
if (addon.userDisabled == value) {
|
||||
cb(null, addon);
|
||||
return;
|
||||
}
|
||||
|
||||
let listener = {
|
||||
onEnabling: function onEnabling(wrapper, needsRestart) {
|
||||
this._log.debug("onEnabling: " + wrapper.id);
|
||||
if (wrapper.id != addon.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We ignore the restartless case because we'll get onEnabled shortly.
|
||||
if (!needsRestart) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(null, wrapper);
|
||||
}.bind(this),
|
||||
|
||||
onEnabled: function onEnabled(wrapper) {
|
||||
this._log.debug("onEnabled: " + wrapper.id);
|
||||
if (wrapper.id != addon.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(null, wrapper);
|
||||
}.bind(this),
|
||||
|
||||
onDisabling: function onDisabling(wrapper, needsRestart) {
|
||||
this._log.debug("onDisabling: " + wrapper.id);
|
||||
if (wrapper.id != addon.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!needsRestart) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(null, wrapper);
|
||||
}.bind(this),
|
||||
|
||||
onDisabled: function onDisabled(wrapper) {
|
||||
this._log.debug("onDisabled: " + wrapper.id);
|
||||
if (wrapper.id != addon.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(null, wrapper);
|
||||
}.bind(this),
|
||||
|
||||
onOperationCancelled: function onOperationCancelled(wrapper) {
|
||||
this._log.debug("onOperationCancelled: " + wrapper.id);
|
||||
if (wrapper.id != addon.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(new Error("Operation cancelled"), wrapper);
|
||||
}.bind(this)
|
||||
};
|
||||
|
||||
// The add-on listeners are only fired if the add-on is active. If not, the
|
||||
// change is silently updated and made active when/if the add-on is active.
|
||||
|
||||
if (!addon.appDisabled) {
|
||||
AddonManager.addAddonListener(listener);
|
||||
}
|
||||
|
||||
this._log.info("Updating userDisabled flag: " + addon.id + " -> " + value);
|
||||
addon.userDisabled = !!value;
|
||||
|
||||
if (!addon.appDisabled) {
|
||||
cb(null, addon);
|
||||
return;
|
||||
}
|
||||
// Else the listener will handle invoking the callback.
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, "AddonUtils", function() {
|
||||
return new AddonUtilsInternal();
|
||||
});
|
||||
784
services/sync/modules/bookmark_validator.js
Normal file
784
services/sync/modules/bookmark_validator.js
Normal file
|
|
@ -0,0 +1,784 @@
|
|||
/* 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 Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://gre/modules/PlacesUtils.jsm");
|
||||
Cu.import("resource://gre/modules/PlacesSyncUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["BookmarkValidator", "BookmarkProblemData"];
|
||||
|
||||
const LEFT_PANE_ROOT_ANNO = "PlacesOrganizer/OrganizerFolder";
|
||||
const LEFT_PANE_QUERY_ANNO = "PlacesOrganizer/OrganizerQuery";
|
||||
|
||||
// Indicates if a local bookmark tree node should be excluded from syncing.
|
||||
function isNodeIgnored(treeNode) {
|
||||
return treeNode.annos && treeNode.annos.some(anno => anno.name == LEFT_PANE_ROOT_ANNO ||
|
||||
anno.name == LEFT_PANE_QUERY_ANNO);
|
||||
}
|
||||
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,
|
||||
]);
|
||||
|
||||
class BookmarkValidator {
|
||||
|
||||
_followQueries(recordMap) {
|
||||
for (let [guid, entry] of recordMap) {
|
||||
if (entry.type !== "query" && (!entry.bmkUri || !entry.bmkUri.startsWith("place:"))) {
|
||||
continue;
|
||||
}
|
||||
// Might be worth trying to parse the place: query instead so that this
|
||||
// works "automatically" with things like aboutsync.
|
||||
let queryNodeParent = PlacesUtils.getFolderContents(entry, false, true);
|
||||
if (!queryNodeParent || !queryNodeParent.root.hasChildren) {
|
||||
continue;
|
||||
}
|
||||
queryNodeParent = queryNodeParent.root;
|
||||
let queryNode = null;
|
||||
let numSiblings = 0;
|
||||
let containerWasOpen = queryNodeParent.containerOpen;
|
||||
queryNodeParent.containerOpen = true;
|
||||
try {
|
||||
try {
|
||||
numSiblings = queryNodeParent.childCount;
|
||||
} catch (e) {
|
||||
// This throws when we can't actually get the children. This is the
|
||||
// case for history containers, tag queries, ...
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < numSiblings && !queryNode; ++i) {
|
||||
let child = queryNodeParent.getChild(i);
|
||||
if (child && child.bookmarkGuid && child.bookmarkGuid === guid) {
|
||||
queryNode = child;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
queryNodeParent.containerOpen = containerWasOpen;
|
||||
}
|
||||
if (!queryNode) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let concreteId = PlacesUtils.getConcreteItemGuid(queryNode);
|
||||
if (!concreteId) {
|
||||
continue;
|
||||
}
|
||||
let concreteItem = recordMap.get(concreteId);
|
||||
if (!concreteItem) {
|
||||
continue;
|
||||
}
|
||||
entry.concrete = concreteItem;
|
||||
}
|
||||
}
|
||||
|
||||
createClientRecordsFromTree(clientTree) {
|
||||
// Iterate over the treeNode, converting it to something more similar to what
|
||||
// the server stores.
|
||||
let records = [];
|
||||
let recordsByGuid = new Map();
|
||||
let syncedRoots = SYNCED_ROOTS;
|
||||
function traverse(treeNode, synced) {
|
||||
if (!synced) {
|
||||
synced = syncedRoots.includes(treeNode.guid);
|
||||
} else if (isNodeIgnored(treeNode)) {
|
||||
synced = false;
|
||||
}
|
||||
let guid = PlacesSyncUtils.bookmarks.guidToSyncId(treeNode.guid);
|
||||
let itemType = 'item';
|
||||
treeNode.ignored = !synced;
|
||||
treeNode.id = guid;
|
||||
switch (treeNode.type) {
|
||||
case PlacesUtils.TYPE_X_MOZ_PLACE:
|
||||
let query = null;
|
||||
if (treeNode.annos && treeNode.uri.startsWith("place:")) {
|
||||
query = treeNode.annos.find(({name}) =>
|
||||
name === PlacesSyncUtils.bookmarks.SMART_BOOKMARKS_ANNO);
|
||||
}
|
||||
if (query && query.value) {
|
||||
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);
|
||||
// We want to use the "real" guid here.
|
||||
recordsByGuid.set(treeNode.guid, treeNode);
|
||||
if (treeNode.type === 'folder') {
|
||||
treeNode.childGUIDs = [];
|
||||
if (!treeNode.children) {
|
||||
treeNode.children = [];
|
||||
}
|
||||
for (let child of treeNode.children) {
|
||||
traverse(child, synced);
|
||||
child.parent = treeNode;
|
||||
child.parentid = guid;
|
||||
treeNode.childGUIDs.push(child.guid);
|
||||
}
|
||||
}
|
||||
}
|
||||
traverse(clientTree, false);
|
||||
clientTree.id = 'places';
|
||||
this._followQueries(recordsByGuid);
|
||||
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, loadInSidebar
|
||||
* - 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.
|
||||
*/
|
||||
inspectServerRecords(serverRecords) {
|
||||
let deletedItemIds = new Set();
|
||||
let idToRecord = new Map();
|
||||
let deletedRecords = [];
|
||||
|
||||
let folders = [];
|
||||
let problems = [];
|
||||
|
||||
let problemData = new BookmarkProblemData();
|
||||
|
||||
let resultRecords = [];
|
||||
|
||||
for (let record of serverRecords) {
|
||||
if (!record.id) {
|
||||
++problemData.missingIDs;
|
||||
continue;
|
||||
}
|
||||
if (record.deleted) {
|
||||
deletedItemIds.add(record.id);
|
||||
} else {
|
||||
if (idToRecord.has(record.id)) {
|
||||
problemData.duplicates.push(record.id);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
idToRecord.set(record.id, record);
|
||||
|
||||
if (record.children) {
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
// Otherwise we mark it as an error and still try to resolve the children
|
||||
problemData.childrenOnNonFolder.push(record.id);
|
||||
}
|
||||
folders.push(record);
|
||||
|
||||
if (new Set(record.children).size !== record.children.length) {
|
||||
problemData.duplicateChildren.push(record.id)
|
||||
}
|
||||
|
||||
// The children array stores special guids as their local guid values,
|
||||
// e.g. 'menu________' instead of 'menu', but all other parts of the
|
||||
// serverside bookmark info stores it as the special value ('menu').
|
||||
record.childGUIDs = record.children;
|
||||
record.children = record.children.map(childID => {
|
||||
return PlacesSyncUtils.bookmarks.guidToSyncId(childID);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (let deletedId of deletedItemIds) {
|
||||
let record = idToRecord.get(deletedId);
|
||||
if (record && !record.isDeleted) {
|
||||
deletedRecords.push(record);
|
||||
record.isDeleted = true;
|
||||
}
|
||||
}
|
||||
|
||||
let root = idToRecord.get('places');
|
||||
|
||||
if (!root) {
|
||||
// Fabricate a root. We want to remember that it's fake so that we can
|
||||
// avoid complaining about stuff like it missing it's childGUIDs later.
|
||||
root = { id: 'places', children: [], type: 'folder', title: '', fake: true };
|
||||
resultRecords.push(root);
|
||||
idToRecord.set('places', root);
|
||||
} else {
|
||||
problemData.rootOnServer = true;
|
||||
}
|
||||
|
||||
// Build the tree, find orphans, and record most problems having to do with
|
||||
// the tree structure.
|
||||
for (let [id, record] of idToRecord) {
|
||||
if (record === root) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (record.isDeleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parentID = record.parentid;
|
||||
if (!parentID) {
|
||||
problemData.orphans.push({id: record.id, parent: parentID});
|
||||
continue;
|
||||
}
|
||||
|
||||
let parent = idToRecord.get(parentID);
|
||||
if (!parent) {
|
||||
problemData.orphans.push({id: record.id, parent: parentID});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parent.type !== 'folder') {
|
||||
problemData.parentNotFolder.push(record.id);
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
if (!parent.childGUIDs) {
|
||||
parent.childGUIDs = [];
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.isDeleted) {
|
||||
resultRecords.push(record);
|
||||
}
|
||||
|
||||
record.parent = parent;
|
||||
if (parent !== root || problemData.rootOnServer) {
|
||||
let childIndex = parent.children.indexOf(id);
|
||||
if (childIndex < 0) {
|
||||
problemData.parentChildMismatches.push({parent: parent.id, child: record.id});
|
||||
} else {
|
||||
parent.children[childIndex] = record;
|
||||
}
|
||||
} else {
|
||||
parent.children.push(record);
|
||||
}
|
||||
|
||||
if (parent.isDeleted && !record.isDeleted) {
|
||||
problemData.deletedParents.push(record.id);
|
||||
}
|
||||
|
||||
// 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.
|
||||
}
|
||||
|
||||
// Check that we aren't missing any children.
|
||||
for (let folder of folders) {
|
||||
folder.unfilteredChildren = folder.children;
|
||||
folder.children = [];
|
||||
for (let ci = 0; ci < folder.unfilteredChildren.length; ++ci) {
|
||||
let child = folder.unfilteredChildren[ci];
|
||||
let childObject;
|
||||
if (typeof child == "string") {
|
||||
// This can happen the parent refers to a child that has a different
|
||||
// parentid, or if it refers to a missing or deleted child. It shouldn't
|
||||
// be possible with totally valid bookmarks.
|
||||
childObject = idToRecord.get(child);
|
||||
if (!childObject) {
|
||||
problemData.missingChildren.push({parent: folder.id, child});
|
||||
} else {
|
||||
folder.unfilteredChildren[ci] = childObject;
|
||||
if (childObject.isDeleted) {
|
||||
problemData.deletedChildren.push({ parent: folder.id, child });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
childObject = child;
|
||||
}
|
||||
|
||||
if (!childObject) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (childObject.parentid === folder.id) {
|
||||
folder.children.push(childObject);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The child is very probably in multiple `children` arrays --
|
||||
// see if we already have a problem record about it.
|
||||
let currentProblemRecord = problemData.multipleParents.find(pr =>
|
||||
pr.child === child);
|
||||
|
||||
if (currentProblemRecord) {
|
||||
currentProblemRecord.parents.push(folder.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
let otherParent = idToRecord.get(childObject.parentid);
|
||||
// it's really an ... orphan ... sort of.
|
||||
if (!otherParent) {
|
||||
// if we never end up adding to this parent's list, we filter it out after this loop.
|
||||
problemData.multipleParents.push({
|
||||
child,
|
||||
parents: [folder.id]
|
||||
});
|
||||
if (!problemData.orphans.some(r => r.id === child)) {
|
||||
problemData.orphans.push({
|
||||
id: child,
|
||||
parent: childObject.parentid
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (otherParent.isDeleted) {
|
||||
if (!problemData.deletedParents.includes(child)) {
|
||||
problemData.deletedParents.push(child);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (otherParent.childGUIDs && !otherParent.childGUIDs.includes(child)) {
|
||||
if (!problemData.parentChildMismatches.some(r => r.child === child)) {
|
||||
// Might not be possible to get here.
|
||||
problemData.parentChildMismatches.push({ child, parent: folder.id });
|
||||
}
|
||||
}
|
||||
|
||||
problemData.multipleParents.push({
|
||||
child,
|
||||
parents: [childObject.parentid, folder.id]
|
||||
});
|
||||
}
|
||||
}
|
||||
problemData.multipleParents = problemData.multipleParents.filter(record =>
|
||||
record.parents.length >= 2);
|
||||
|
||||
problemData.cycles = this._detectCycles(resultRecords);
|
||||
|
||||
return {
|
||||
deletedRecords,
|
||||
records: resultRecords,
|
||||
problemData,
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
// helper for inspectServerRecords
|
||||
_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 traverse = 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.concrete) {
|
||||
children.push(node.concrete);
|
||||
}
|
||||
if (children) {
|
||||
pathLookup.add(node);
|
||||
currentPath.push(node);
|
||||
for (let child of children) {
|
||||
traverse(child);
|
||||
}
|
||||
currentPath.pop();
|
||||
pathLookup.delete(node);
|
||||
}
|
||||
};
|
||||
for (let record of records) {
|
||||
if (!seenEver.has(record)) {
|
||||
traverse(record);
|
||||
}
|
||||
}
|
||||
|
||||
return cycles;
|
||||
}
|
||||
|
||||
// Perform client-side sanity checking that doesn't involve server data
|
||||
_validateClient(problemData, clientRecords) {
|
||||
problemData.clientCycles = this._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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
compareServerWithClient(serverRecords, clientTree) {
|
||||
|
||||
let clientRecords = this.createClientRecordsFromTree(clientTree);
|
||||
let inspectionInfo = this.inspectServerRecords(serverRecords);
|
||||
inspectionInfo.clientRecords = clientRecords;
|
||||
|
||||
// Mainly do this to remove deleted items and normalize child guids.
|
||||
serverRecords = inspectionInfo.records;
|
||||
let problemData = inspectionInfo.problemData;
|
||||
|
||||
this._validateClient(problemData, clientRecords);
|
||||
|
||||
let matches = [];
|
||||
|
||||
let allRecords = new Map();
|
||||
let serverDeletedLookup = new Set(inspectionInfo.deletedRecords.map(r => r.id));
|
||||
|
||||
for (let sr of serverRecords) {
|
||||
if (sr.fake) {
|
||||
continue;
|
||||
}
|
||||
allRecords.set(sr.id, {client: null, server: sr});
|
||||
}
|
||||
|
||||
for (let cr of clientRecords) {
|
||||
let unified = allRecords.get(cr.id);
|
||||
if (!unified) {
|
||||
allRecords.set(cr.id, {client: cr, server: null});
|
||||
} else {
|
||||
unified.client = cr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for (let [id, {client, server}] of allRecords) {
|
||||
if (!client && server) {
|
||||
problemData.clientMissing.push(id);
|
||||
continue;
|
||||
}
|
||||
if (!server && client) {
|
||||
if (serverDeletedLookup.has(id)) {
|
||||
problemData.serverDeleted.push(id);
|
||||
} else if (!client.ignored && client.id != "places") {
|
||||
problemData.serverMissing.push(id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (server && client && client.ignored) {
|
||||
problemData.serverUnexpected.push(id);
|
||||
}
|
||||
let differences = [];
|
||||
let structuralDifferences = [];
|
||||
|
||||
// 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 || [];
|
||||
let sl = server.tags || [];
|
||||
if (cl.length !== sl.length || !cl.every((tag, i) => sl.indexOf(tag) >= 0)) {
|
||||
differences.push('tags');
|
||||
}
|
||||
}
|
||||
|
||||
let sameType = client.type === server.type;
|
||||
if (!sameType) {
|
||||
if (server.type === "query" && client.type === "bookmark" && client.bmkUri.startsWith("place:")) {
|
||||
sameType = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!sameType) {
|
||||
differences.push('type');
|
||||
} else {
|
||||
switch (server.type) {
|
||||
case 'bookmark':
|
||||
case 'query':
|
||||
if (server.bmkUri !== client.bmkUri) {
|
||||
differences.push('bmkUri');
|
||||
}
|
||||
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' && !problemData.rootOnServer) {
|
||||
// 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 (cl.length !== sl.length || !cl.every((id, i) => sl[i] === id)) {
|
||||
structuralDifferences.push('childGUIDs');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (differences.length) {
|
||||
problemData.differences.push({id, differences});
|
||||
}
|
||||
if (structuralDifferences.length) {
|
||||
problemData.structuralDifferences.push({ id, differences: structuralDifferences });
|
||||
}
|
||||
}
|
||||
return inspectionInfo;
|
||||
}
|
||||
|
||||
_getServerState(engine) {
|
||||
let collection = engine.itemSource();
|
||||
let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name);
|
||||
collection.full = true;
|
||||
let items = [];
|
||||
collection.recordHandler = function(item) {
|
||||
item.decrypt(collectionKey);
|
||||
items.push(item.cleartext);
|
||||
};
|
||||
let resp = collection.getBatched();
|
||||
if (!resp.success) {
|
||||
throw resp;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
validate(engine) {
|
||||
let self = this;
|
||||
return Task.spawn(function*() {
|
||||
let start = Date.now();
|
||||
let clientTree = yield PlacesUtils.promiseBookmarksTree("", {
|
||||
includeItemIds: true
|
||||
});
|
||||
let serverState = self._getServerState(engine);
|
||||
let serverRecordCount = serverState.length;
|
||||
let result = self.compareServerWithClient(serverState, clientTree);
|
||||
let end = Date.now();
|
||||
let duration = end-start;
|
||||
return {
|
||||
duration,
|
||||
version: self.version,
|
||||
problems: result.problemData,
|
||||
recordCount: serverRecordCount
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
BookmarkValidator.prototype.version = BOOKMARK_VALIDATOR_VERSION;
|
||||
|
||||
869
services/sync/modules/browserid_identity.js
Normal file
869
services/sync/modules/browserid_identity.js
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["BrowserIDManager", "AuthenticationError"];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-common/tokenserverclient.js");
|
||||
Cu.import("resource://services-crypto/utils.js");
|
||||
Cu.import("resource://services-sync/identity.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/tokenserverclient.js");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://services-sync/stages/cluster.js");
|
||||
Cu.import("resource://gre/modules/FxAccounts.jsm");
|
||||
|
||||
// Lazy imports to prevent unnecessary load on startup.
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Weave",
|
||||
"resource://services-sync/main.js");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "BulkKeyBundle",
|
||||
"resource://services-sync/keys.js");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
|
||||
"resource://gre/modules/FxAccounts.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, 'log', function() {
|
||||
let log = Log.repository.getLogger("Sync.BrowserIDManager");
|
||||
log.level = Log.Level[Svc.Prefs.get("log.logger.identity")] || Log.Level.Error;
|
||||
return log;
|
||||
});
|
||||
|
||||
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
|
||||
var fxAccountsCommon = {};
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
|
||||
|
||||
const OBSERVER_TOPICS = [
|
||||
fxAccountsCommon.ONLOGIN_NOTIFICATION,
|
||||
fxAccountsCommon.ONLOGOUT_NOTIFICATION,
|
||||
fxAccountsCommon.ON_ACCOUNT_STATE_CHANGE_NOTIFICATION,
|
||||
];
|
||||
|
||||
const PREF_SYNC_SHOW_CUSTOMIZATION = "services.sync-setup.ui.showCustomizationDialog";
|
||||
|
||||
function deriveKeyBundle(kB) {
|
||||
let out = CryptoUtils.hkdf(kB, undefined,
|
||||
"identity.mozilla.com/picl/v1/oldsync", 2*32);
|
||||
let bundle = new BulkKeyBundle();
|
||||
// [encryptionKey, hmacKey]
|
||||
bundle.keyPair = [out.slice(0, 32), out.slice(32, 64)];
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/*
|
||||
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: function() {
|
||||
return "AuthenticationError(" + this.details + ")";
|
||||
}
|
||||
}
|
||||
|
||||
this.BrowserIDManager = 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";
|
||||
// will be a promise that resolves when we are ready to authenticate
|
||||
this.whenReadyToAuthenticate = null;
|
||||
this._log = log;
|
||||
};
|
||||
|
||||
this.BrowserIDManager.prototype = {
|
||||
__proto__: IdentityManager.prototype,
|
||||
|
||||
_fxaService: null,
|
||||
_tokenServerClient: null,
|
||||
// https://docs.services.mozilla.com/token/apis.html
|
||||
_token: null,
|
||||
_signedInUser: null, // the signedinuser we got from FxAccounts.
|
||||
|
||||
// null if no error, otherwise a LOGIN_FAILED_* value that indicates why
|
||||
// we failed to authenticate (but note it might not be an actual
|
||||
// authentication problem, just a transient network error or similar)
|
||||
_authFailureReason: null,
|
||||
|
||||
// it takes some time to fetch a sync key bundle, so until this flag is set,
|
||||
// we don't consider the lack of a keybundle as a failure state.
|
||||
_shouldHaveSyncKeyBundle: false,
|
||||
|
||||
get needsCustomization() {
|
||||
try {
|
||||
return Services.prefs.getBoolPref(PREF_SYNC_SHOW_CUSTOMIZATION);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
hashedUID() {
|
||||
if (!this._token) {
|
||||
throw new Error("hashedUID: Don't have token");
|
||||
}
|
||||
return this._token.hashed_fxa_uid
|
||||
},
|
||||
|
||||
deviceID() {
|
||||
return this._signedInUser && this._signedInUser.deviceId;
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
for (let topic of OBSERVER_TOPICS) {
|
||||
Services.obs.addObserver(this, topic, false);
|
||||
}
|
||||
// and a background fetch of account data just so we can set this.account,
|
||||
// so we have a username available before we've actually done a login.
|
||||
// XXX - this is actually a hack just for tests and really shouldn't be
|
||||
// necessary. Also, you'd think it would be safe to allow this.account to
|
||||
// be set to null when there's no user logged in, but argue with the test
|
||||
// suite, not with me :)
|
||||
this._fxaService.getSignedInUser().then(accountData => {
|
||||
if (accountData) {
|
||||
this.account = accountData.email;
|
||||
}
|
||||
}).catch(err => {
|
||||
// As above, this is only for tests so it is safe to ignore.
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure the user is logged in. Returns a promise that resolves when
|
||||
* the user is logged in, or is rejected if the login attempt has failed.
|
||||
*/
|
||||
ensureLoggedIn: function() {
|
||||
if (!this._shouldHaveSyncKeyBundle && this.whenReadyToAuthenticate) {
|
||||
// We are already in the process of logging in.
|
||||
return this.whenReadyToAuthenticate.promise;
|
||||
}
|
||||
|
||||
// If we are already happy then there is nothing more to do.
|
||||
if (this._syncKeyBundle) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
// Similarly, if we have a previous failure that implies an explicit
|
||||
// re-entering of credentials by the user is necessary we don't take any
|
||||
// further action - an observer will fire when the user does that.
|
||||
if (Weave.Status.login == LOGIN_FAILED_LOGIN_REJECTED) {
|
||||
return Promise.reject(new Error("User needs to re-authenticate"));
|
||||
}
|
||||
|
||||
// So - we've a previous auth problem and aren't currently attempting to
|
||||
// log in - so fire that off.
|
||||
this.initializeWithCurrentIdentity();
|
||||
return this.whenReadyToAuthenticate.promise;
|
||||
},
|
||||
|
||||
finalize: function() {
|
||||
// After this is called, we can expect Service.identity != this.
|
||||
for (let topic of OBSERVER_TOPICS) {
|
||||
Services.obs.removeObserver(this, topic);
|
||||
}
|
||||
this.resetCredentials();
|
||||
this._signedInUser = null;
|
||||
},
|
||||
|
||||
offerSyncOptions: function () {
|
||||
// If the user chose to "Customize sync options" when signing
|
||||
// up with Firefox Accounts, ask them to choose what to sync.
|
||||
const url = "chrome://browser/content/sync/customize.xul";
|
||||
const features = "centerscreen,chrome,modal,dialog,resizable=no";
|
||||
let win = Services.wm.getMostRecentWindow("navigator:browser");
|
||||
|
||||
let data = {accepted: false};
|
||||
win.openDialog(url, "_blank", features, data);
|
||||
|
||||
return data;
|
||||
},
|
||||
|
||||
initializeWithCurrentIdentity: function(isInitialSync=false) {
|
||||
// While this function returns a promise that resolves once we've started
|
||||
// the auth process, that process is complete when
|
||||
// this.whenReadyToAuthenticate.promise resolves.
|
||||
this._log.trace("initializeWithCurrentIdentity");
|
||||
|
||||
// Reset the world before we do anything async.
|
||||
this.whenReadyToAuthenticate = Promise.defer();
|
||||
this.whenReadyToAuthenticate.promise.catch(err => {
|
||||
this._log.error("Could not authenticate", err);
|
||||
});
|
||||
|
||||
// initializeWithCurrentIdentity() can be called after the
|
||||
// identity module was first initialized, e.g., after the
|
||||
// user completes a force authentication, so we should make
|
||||
// sure all credentials are reset before proceeding.
|
||||
this.resetCredentials();
|
||||
this._authFailureReason = null;
|
||||
|
||||
return this._fxaService.getSignedInUser().then(accountData => {
|
||||
if (!accountData) {
|
||||
this._log.info("initializeWithCurrentIdentity has no user logged in");
|
||||
this.account = null;
|
||||
// and we are as ready as we can ever be for auth.
|
||||
this._shouldHaveSyncKeyBundle = true;
|
||||
this.whenReadyToAuthenticate.reject("no user is logged in");
|
||||
return;
|
||||
}
|
||||
|
||||
this.account = accountData.email;
|
||||
this._updateSignedInUser(accountData);
|
||||
// The user must be verified before we can do anything at all; we kick
|
||||
// this and the rest of initialization off in the background (ie, we
|
||||
// don't return the promise)
|
||||
this._log.info("Waiting for user to be verified.");
|
||||
this._fxaService.whenVerified(accountData).then(accountData => {
|
||||
this._updateSignedInUser(accountData);
|
||||
this._log.info("Starting fetch for key bundle.");
|
||||
if (this.needsCustomization) {
|
||||
let data = this.offerSyncOptions();
|
||||
if (data.accepted) {
|
||||
Services.prefs.clearUserPref(PREF_SYNC_SHOW_CUSTOMIZATION);
|
||||
|
||||
// Mark any non-selected engines as declined.
|
||||
Weave.Service.engineManager.declineDisabled();
|
||||
} else {
|
||||
// Log out if the user canceled the dialog.
|
||||
return this._fxaService.signOut();
|
||||
}
|
||||
}
|
||||
}).then(() => {
|
||||
return this._fetchTokenForUser();
|
||||
}).then(token => {
|
||||
this._token = token;
|
||||
this._shouldHaveSyncKeyBundle = true; // and we should actually have one...
|
||||
this.whenReadyToAuthenticate.resolve();
|
||||
this._log.info("Background fetch for key bundle done");
|
||||
Weave.Status.login = LOGIN_SUCCEEDED;
|
||||
if (isInitialSync) {
|
||||
this._log.info("Doing initial sync actions");
|
||||
Svc.Prefs.set("firstSync", "resetClient");
|
||||
Services.obs.notifyObservers(null, "weave:service:setup-complete", null);
|
||||
Weave.Utils.nextTick(Weave.Service.sync, Weave.Service);
|
||||
}
|
||||
}).catch(authErr => {
|
||||
// report what failed...
|
||||
this._log.error("Background fetch for key bundle failed", authErr);
|
||||
this._shouldHaveSyncKeyBundle = true; // but we probably don't have one...
|
||||
this.whenReadyToAuthenticate.reject(authErr);
|
||||
});
|
||||
// and we are done - the fetch continues on in the background...
|
||||
}).catch(err => {
|
||||
this._log.error("Processing logged in account", err);
|
||||
});
|
||||
},
|
||||
|
||||
_updateSignedInUser: function(userData) {
|
||||
// This object should only ever be used for a single user. It is an
|
||||
// error to update the data if the user changes (but updates are still
|
||||
// necessary, as each call may add more attributes to the user).
|
||||
// We start with no user, so an initial update is always ok.
|
||||
if (this._signedInUser && this._signedInUser.email != userData.email) {
|
||||
throw new Error("Attempting to update to a different user.")
|
||||
}
|
||||
this._signedInUser = userData;
|
||||
},
|
||||
|
||||
logout: function() {
|
||||
// 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;
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
this._log.debug("observed " + topic);
|
||||
switch (topic) {
|
||||
case fxAccountsCommon.ONLOGIN_NOTIFICATION:
|
||||
// This should only happen if we've been initialized without a current
|
||||
// user - otherwise we'd have seen the LOGOUT notification and been
|
||||
// thrown away.
|
||||
// The exception is when we've initialized with a user that needs to
|
||||
// reauth with the server - in that case we will also get here, but
|
||||
// should have the same identity.
|
||||
// initializeWithCurrentIdentity will throw and log if these constraints
|
||||
// aren't met (indirectly, via _updateSignedInUser()), so just go ahead
|
||||
// and do the init.
|
||||
this.initializeWithCurrentIdentity(true);
|
||||
break;
|
||||
|
||||
case fxAccountsCommon.ONLOGOUT_NOTIFICATION:
|
||||
Weave.Service.startOver();
|
||||
// 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 and fetch a new one
|
||||
this.resetCredentials();
|
||||
this._ensureValidToken().catch(err =>
|
||||
this._log.error("Error while fetching a new token", err));
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute the sha256 of the message bytes. Return bytes.
|
||||
*/
|
||||
_sha256: function(message) {
|
||||
let hasher = Cc["@mozilla.org/security/hash;1"]
|
||||
.createInstance(Ci.nsICryptoHash);
|
||||
hasher.init(hasher.SHA256);
|
||||
return CryptoUtils.digestBytes(message, hasher);
|
||||
},
|
||||
|
||||
/**
|
||||
* Compute the X-Client-State header given the byte string kB.
|
||||
*
|
||||
* Return string: hex(first16Bytes(sha256(kBbytes)))
|
||||
*/
|
||||
_computeXClientState: function(kBbytes) {
|
||||
return CommonUtils.bytesAsHex(this._sha256(kBbytes).slice(0, 16), false);
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide override point for testing token expiration.
|
||||
*/
|
||||
_now: function() {
|
||||
return this._fxaService.now()
|
||||
},
|
||||
|
||||
get _localtimeOffsetMsec() {
|
||||
return this._fxaService.localtimeOffsetMsec;
|
||||
},
|
||||
|
||||
usernameFromAccount: function(val) {
|
||||
// we don't differentiate between "username" and "account"
|
||||
return val;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtains the HTTP Basic auth password.
|
||||
*
|
||||
* Returns a string if set or null if it is not set.
|
||||
*/
|
||||
get basicPassword() {
|
||||
this._log.error("basicPassword getter should be not used in BrowserIDManager");
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the HTTP basic password to use.
|
||||
*
|
||||
* Changes will not persist unless persistSyncCredentials() is called.
|
||||
*/
|
||||
set basicPassword(value) {
|
||||
throw "basicPassword setter should be not used in BrowserIDManager";
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain the Sync Key.
|
||||
*
|
||||
* This returns a 26 character "friendly" Base32 encoded string on success or
|
||||
* null if no Sync Key could be found.
|
||||
*
|
||||
* If the Sync Key hasn't been set in this session, this will look in the
|
||||
* password manager for the sync key.
|
||||
*/
|
||||
get syncKey() {
|
||||
if (this.syncKeyBundle) {
|
||||
// TODO: This is probably fine because the code shouldn't be
|
||||
// using the sync key directly (it should use the sync key
|
||||
// bundle), but I don't like it. We should probably refactor
|
||||
// code that is inspecting this to not do validation on this
|
||||
// field directly and instead call a isSyncKeyValid() function
|
||||
// that we can override.
|
||||
return "99999999999999999999999999";
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
set syncKey(value) {
|
||||
throw "syncKey setter should be not used in BrowserIDManager";
|
||||
},
|
||||
|
||||
get syncKeyBundle() {
|
||||
return this._syncKeyBundle;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets/Drops all credentials we hold for the current user.
|
||||
*/
|
||||
resetCredentials: function() {
|
||||
this.resetSyncKey();
|
||||
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;
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets/Drops the sync key we hold for the current user.
|
||||
*/
|
||||
resetSyncKey: function() {
|
||||
this._syncKey = null;
|
||||
this._syncKeyBundle = null;
|
||||
this._syncKeyUpdated = true;
|
||||
this._shouldHaveSyncKeyBundle = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: function(service) {
|
||||
// nothing to do here until we decide to migrate away from FxA.
|
||||
},
|
||||
|
||||
/**
|
||||
* Return credentials hosts for this identity only.
|
||||
*/
|
||||
_getSyncCredentialsHosts: function() {
|
||||
return Utils.getSyncCredentialsHostsFxA();
|
||||
},
|
||||
|
||||
/**
|
||||
* The current state of the auth credentials.
|
||||
*
|
||||
* This essentially validates that enough credentials are available to use
|
||||
* Sync. It doesn't check we have all the keys we need as the master-password
|
||||
* may have been locked when we tried to get them - we rely on
|
||||
* unlockAndVerifyAuthState to check that for us.
|
||||
*/
|
||||
get currentAuthState() {
|
||||
if (this._authFailureReason) {
|
||||
this._log.info("currentAuthState returning " + this._authFailureReason +
|
||||
" due to previous failure");
|
||||
return this._authFailureReason;
|
||||
}
|
||||
// TODO: need to revisit this. Currently this isn't ready to go until
|
||||
// both the username and syncKeyBundle are both configured and having no
|
||||
// username seems to make things fail fast so that's good.
|
||||
if (!this.username) {
|
||||
return LOGIN_FAILED_NO_USERNAME;
|
||||
}
|
||||
|
||||
return STATUS_OK;
|
||||
},
|
||||
|
||||
// Do we currently have keys, or do we have enough that we should be able
|
||||
// to successfully fetch them?
|
||||
_canFetchKeys: function() {
|
||||
let userData = this._signedInUser;
|
||||
// a keyFetchToken means we can almost certainly grab them.
|
||||
// kA and kB means we already have them.
|
||||
return userData && (userData.keyFetchToken || (userData.kA && userData.kB));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
unlockAndVerifyAuthState: function() {
|
||||
if (this._canFetchKeys()) {
|
||||
log.debug("unlockAndVerifyAuthState already has (or can fetch) sync keys");
|
||||
return Promise.resolve(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 Promise.resolve(MASTER_PASSWORD_LOCKED);
|
||||
}
|
||||
// now we are unlocked we must re-fetch the user data as we may now have
|
||||
// the details that were previously locked away.
|
||||
return this._fxaService.getSignedInUser().then(
|
||||
accountData => {
|
||||
this._updateSignedInUser(accountData);
|
||||
// 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 (this._canFetchKeys()) {
|
||||
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: function() {
|
||||
// If pref is set to ignore cached authentication credentials for debugging,
|
||||
// then return false to force the fetching of a new token.
|
||||
let ignoreCachedAuthCredentials = false;
|
||||
try {
|
||||
ignoreCachedAuthCredentials = Svc.Prefs.get("debug.ignoreCachedAuthCredentials");
|
||||
} catch(e) {
|
||||
// Pref doesn't exist
|
||||
}
|
||||
if (ignoreCachedAuthCredentials) {
|
||||
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 (which may be null in one sad edge-case), or rejects with an
|
||||
// error.
|
||||
_fetchTokenForUser: function() {
|
||||
// tokenServerURI is mis-named - convention is uri means nsISomething...
|
||||
let tokenServerURI = this._tokenServerUrl;
|
||||
let log = this._log;
|
||||
let client = this._tokenServerClient;
|
||||
let fxa = this._fxaService;
|
||||
let userData = this._signedInUser;
|
||||
|
||||
// We need kA and kB 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 (!this._canFetchKeys()) {
|
||||
log.info("Unable to fetch keys (master-password locked?), so aborting token fetch");
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
let maybeFetchKeys = () => {
|
||||
// This is called at login time and every time we need a new token - in
|
||||
// the latter case we already have kA and kB, so optimise that case.
|
||||
if (userData.kA && userData.kB) {
|
||||
return;
|
||||
}
|
||||
log.info("Fetching new keys");
|
||||
return this._fxaService.getKeys().then(
|
||||
newUserData => {
|
||||
userData = newUserData;
|
||||
this._updateSignedInUser(userData); // throws if the user changed.
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
let getToken = assertion => {
|
||||
log.debug("Getting a token");
|
||||
let deferred = Promise.defer();
|
||||
let cb = function (err, token) {
|
||||
if (err) {
|
||||
return deferred.reject(err);
|
||||
}
|
||||
log.debug("Successfully got a sync token");
|
||||
return deferred.resolve(token);
|
||||
};
|
||||
|
||||
let kBbytes = CommonUtils.hexToBytes(userData.kB);
|
||||
let headers = {"X-Client-State": this._computeXClientState(kBbytes)};
|
||||
client.getTokenFromBrowserIDAssertion(tokenServerURI, assertion, cb, headers);
|
||||
return deferred.promise;
|
||||
}
|
||||
|
||||
let getAssertion = () => {
|
||||
log.info("Getting an assertion from", tokenServerURI);
|
||||
let audience = Services.io.newURI(tokenServerURI, null, null).prePath;
|
||||
return fxa.getAssertion(audience);
|
||||
};
|
||||
|
||||
// wait until the account email is verified and we know that
|
||||
// getAssertion() will return a real assertion (not null).
|
||||
return fxa.whenVerified(this._signedInUser)
|
||||
.then(() => maybeFetchKeys())
|
||||
.then(() => getAssertion())
|
||||
.then(assertion => getToken(assertion))
|
||||
.catch(err => {
|
||||
// If we get a 401 fetching the token it may be that our certificate
|
||||
// needs to be regenerated.
|
||||
if (!err.response || err.response.status !== 401) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
log.warn("Token server returned 401, refreshing certificate and retrying token fetch");
|
||||
return fxa.invalidateCertificate()
|
||||
.then(() => getAssertion())
|
||||
.then(assertion => getToken(assertion))
|
||||
})
|
||||
.then(token => {
|
||||
// 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 get a nasty notification bar briefly. Bug 966568.
|
||||
token.expiration = this._now() + (token.duration * 1000) * 0.80;
|
||||
if (!this._syncKeyBundle) {
|
||||
// We are given kA/kB as hex.
|
||||
this._syncKeyBundle = deriveKeyBundle(Utils.hexToBytes(userData.kB));
|
||||
}
|
||||
return token;
|
||||
})
|
||||
.catch(err => {
|
||||
// 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.
|
||||
this._authFailureReason = 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)
|
||||
this._authFailureReason = LOGIN_FAILED_NETWORK_ERROR;
|
||||
}
|
||||
// this._authFailureReason being set to be non-null in the above if clause
|
||||
// ensures we are in the correct currentAuthState, and
|
||||
// this._shouldHaveSyncKeyBundle being true ensures everything that cares knows
|
||||
// that there is no authentication dance still under way.
|
||||
this._shouldHaveSyncKeyBundle = true;
|
||||
Weave.Status.login = this._authFailureReason;
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
// Returns a promise that is resolved when we have a valid token for the
|
||||
// current user stored in this._token. When resolved, this._token is valid.
|
||||
_ensureValidToken: function() {
|
||||
if (this.hasValidToken()) {
|
||||
this._log.debug("_ensureValidToken already has one");
|
||||
return Promise.resolve();
|
||||
}
|
||||
const notifyStateChanged =
|
||||
() => Services.obs.notifyObservers(null, "weave:service:login:change", null);
|
||||
// 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;
|
||||
return this._fetchTokenForUser().then(
|
||||
token => {
|
||||
this._token = token;
|
||||
notifyStateChanged();
|
||||
},
|
||||
error => {
|
||||
notifyStateChanged();
|
||||
throw error
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
getResourceAuthenticator: function () {
|
||||
return this._getAuthenticationHeader.bind(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a function to be used for adding auth to RESTRequest instances.
|
||||
*/
|
||||
getRESTRequestAuthenticator: function() {
|
||||
return this._addAuthenticationHeader.bind(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* @return a Hawk HTTP Authorization Header, lightly wrapped, for the .uri
|
||||
* of a RESTRequest or AsyncResponse object.
|
||||
*/
|
||||
_getAuthenticationHeader: function(httpObject, method) {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this._ensureValidToken().then(cb, cb);
|
||||
// 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 {
|
||||
cb.wait();
|
||||
} catch (ex) {
|
||||
if (Async.isShutdownException(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
this._log.error("Failed to fetch a token for authentication", ex);
|
||||
return null;
|
||||
}
|
||||
if (!this._token) {
|
||||
return null;
|
||||
}
|
||||
let credentials = {algorithm: "sha256",
|
||||
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: credentials,
|
||||
};
|
||||
|
||||
let headerValue = CryptoUtils.computeHAWK(httpObject.uri, method, options);
|
||||
return {headers: {authorization: headerValue.field}};
|
||||
},
|
||||
|
||||
_addAuthenticationHeader: function(request, method) {
|
||||
let header = this._getAuthenticationHeader(request, method);
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
request.setHeader("authorization", header.headers.authorization);
|
||||
return request;
|
||||
},
|
||||
|
||||
createClusterManager: function(service) {
|
||||
return new BrowserIDClusterManager(service);
|
||||
},
|
||||
|
||||
// Tell Sync what the login status should be if it saw a 401 fetching
|
||||
// info/collections as part of login verification (typically immediately
|
||||
// after login.)
|
||||
// In our case, it almost certainly means a transient error fetching a token
|
||||
// (and hitting this will cause us to logout, which will correctly handle an
|
||||
// authoritative login issue.)
|
||||
loginStatusFromVerification404() {
|
||||
return LOGIN_FAILED_NETWORK_ERROR;
|
||||
},
|
||||
};
|
||||
|
||||
/* An implementation of the ClusterManager for this identity
|
||||
*/
|
||||
|
||||
function BrowserIDClusterManager(service) {
|
||||
ClusterManager.call(this, service);
|
||||
}
|
||||
|
||||
BrowserIDClusterManager.prototype = {
|
||||
__proto__: ClusterManager.prototype,
|
||||
|
||||
_findCluster: function() {
|
||||
let endPointFromIdentityToken = function() {
|
||||
// The only reason (in theory ;) that we can end up with a null token
|
||||
// is when this.identity._canFetchKeys() returned false. In turn, this
|
||||
// should only happen if the master-password is locked or the credentials
|
||||
// storage is screwed, and in those cases we shouldn't have started
|
||||
// syncing so shouldn't get here anyway.
|
||||
// But better safe than sorry! To keep things clearer, throw an explicit
|
||||
// exception - the message will appear in the logs and the error will be
|
||||
// treated as transient.
|
||||
if (!this.identity._token) {
|
||||
throw new Error("Can't get a cluster URL as we can't fetch keys.");
|
||||
}
|
||||
let endpoint = this.identity._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 += "/";
|
||||
}
|
||||
log.debug("_findCluster returning " + endpoint);
|
||||
return endpoint;
|
||||
}.bind(this);
|
||||
|
||||
// Spinningly ensure we are ready to authenticate and have a valid token.
|
||||
let promiseClusterURL = function() {
|
||||
return this.identity.whenReadyToAuthenticate.promise.then(
|
||||
() => {
|
||||
// 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.
|
||||
if (this.service.clusterURL) {
|
||||
log.debug("_findCluster has a pre-existing clusterURL, so discarding the current token");
|
||||
this.identity._token = null;
|
||||
}
|
||||
return this.identity._ensureValidToken();
|
||||
}
|
||||
).then(endPointFromIdentityToken
|
||||
);
|
||||
}.bind(this);
|
||||
|
||||
let cb = Async.makeSpinningCallback();
|
||||
promiseClusterURL().then(function (clusterURL) {
|
||||
cb(null, clusterURL);
|
||||
}).then(
|
||||
null, err => {
|
||||
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) {
|
||||
// callback with no error and a null result - cb.wait() returns null.
|
||||
cb(null, null);
|
||||
} else {
|
||||
// callback with an error - cb.wait() completes by raising an exception.
|
||||
cb(err);
|
||||
}
|
||||
});
|
||||
return cb.wait();
|
||||
},
|
||||
|
||||
getUserBaseURL: function() {
|
||||
// Legacy Sync and FxA Sync construct the userBaseURL differently. Legacy
|
||||
// Sync appends path components onto an empty path, and in FxA Sync the
|
||||
// token server constructs this for us in an opaque manner. Since the
|
||||
// cluster manager already sets the clusterURL on Service and also has
|
||||
// access to the current identity, we added this functionality here.
|
||||
return this.service.clusterURL;
|
||||
}
|
||||
}
|
||||
204
services/sync/modules/collection_validator.js
Normal file
204
services/sync/modules/collection_validator.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/* 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 Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/main.js");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["CollectionValidator", "CollectionProblemData"];
|
||||
|
||||
class CollectionProblemData {
|
||||
constructor() {
|
||||
this.missingIDs = 0;
|
||||
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: "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;
|
||||
}
|
||||
|
||||
// Should a custom ProblemData type be needed, return it here.
|
||||
emptyProblemData() {
|
||||
return new CollectionProblemData();
|
||||
}
|
||||
|
||||
getServerItems(engine) {
|
||||
let collection = engine.itemSource();
|
||||
let collectionKey = engine.service.collectionKeys.keyForCollection(engine.name);
|
||||
collection.full = true;
|
||||
let items = [];
|
||||
collection.recordHandler = function(item) {
|
||||
item.decrypt(collectionKey);
|
||||
items.push(item.cleartext);
|
||||
};
|
||||
let resp = collection.getBatched();
|
||||
if (!resp.success) {
|
||||
throw resp;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Should return a promise that resolves to an array of client items.
|
||||
getClientItems() {
|
||||
return Promise.reject("Must implement");
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
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.
|
||||
compareClientWithServer(clientItems, serverItems) {
|
||||
clientItems = clientItems.map(item => this.normalizeClientItem(item));
|
||||
serverItems = serverItems.map(item => this.normalizeServerItem(item));
|
||||
let problems = this.emptyProblemData();
|
||||
let seenServer = new Map();
|
||||
let serverDeleted = new Set();
|
||||
let allRecords = new Map();
|
||||
|
||||
for (let record of serverItems) {
|
||||
let id = record[this.idProp];
|
||||
if (!id) {
|
||||
++problems.missingIDs;
|
||||
continue;
|
||||
}
|
||||
if (record.deleted) {
|
||||
serverDeleted.add(record);
|
||||
} else {
|
||||
let possibleDupe = seenServer.get(id);
|
||||
if (possibleDupe) {
|
||||
problems.duplicates.push(id);
|
||||
} else {
|
||||
seenServer.set(id, record);
|
||||
allRecords.set(id, { server: record, client: null, });
|
||||
}
|
||||
record.understood = this.clientUnderstands(record);
|
||||
}
|
||||
}
|
||||
|
||||
let recordPairs = [];
|
||||
let seenClient = new Map();
|
||||
for (let record of clientItems) {
|
||||
let id = record[this.idProp];
|
||||
record.shouldSync = this.syncedByClient(record);
|
||||
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 (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: clientItems,
|
||||
records: serverItems,
|
||||
deletedRecords: [...serverDeleted]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default to 0, some engines may override.
|
||||
CollectionValidator.prototype.version = 0;
|
||||
198
services/sync/modules/constants.js
Normal file
198
services/sync/modules/constants.js
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
#filter substitution
|
||||
/* 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
|
||||
this.EXPORTED_SYMBOLS = [];
|
||||
for (let [key, val] of Object.entries({
|
||||
|
||||
WEAVE_VERSION: "@weave_version@",
|
||||
|
||||
// Sync Server API version that the client supports.
|
||||
SYNC_API_VERSION: "1.1",
|
||||
USER_API_VERSION: "1.0",
|
||||
MISC_API_VERSION: "1.0",
|
||||
|
||||
// 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.",
|
||||
|
||||
// Host "key" to access Weave Identity in the password manager
|
||||
PWDMGR_HOST: "chrome://weave",
|
||||
PWDMGR_PASSWORD_REALM: "Mozilla Services Password",
|
||||
PWDMGR_PASSPHRASE_REALM: "Mozilla Services Encryption Passphrase",
|
||||
PWDMGR_KEYBUNDLE_REALM: "Mozilla Services Key Bundles",
|
||||
|
||||
// Put in [] because those aren't allowed in a collection name.
|
||||
DEFAULT_KEYBUNDLE_NAME: "[default]",
|
||||
|
||||
// Our extra input to SHA256-HMAC in generateEntry.
|
||||
// This includes the full crypto spec; change this when our algo changes.
|
||||
HMAC_INPUT: "Sync-AES_256_CBC-HMAC256",
|
||||
|
||||
// Key dimensions.
|
||||
SYNC_KEY_ENCODED_LENGTH: 26,
|
||||
SYNC_KEY_DECODED_LENGTH: 16,
|
||||
SYNC_KEY_HYPHENATED_LENGTH: 31, // 26 chars, 5 hyphens.
|
||||
|
||||
NO_SYNC_NODE_INTERVAL: 10 * 60 * 1000, // 10 minutes
|
||||
|
||||
MAX_ERROR_COUNT_BEFORE_BACKOFF: 3,
|
||||
MAX_IGNORE_ERROR_COUNT: 5,
|
||||
|
||||
// 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
|
||||
|
||||
// The default for how long we "block" sync from running when doing a migration.
|
||||
DEFAULT_BLOCK_PERIOD: 2 * 24 * 60 * 60 * 1000, // 2 days
|
||||
|
||||
// Separate from the ID fetch batch size to allow tuning for mobile.
|
||||
MOBILE_BATCH_SIZE: 50,
|
||||
|
||||
// 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_MOBILE_GUID_FETCH_BATCH_SIZE: 50,
|
||||
|
||||
// Default batch size for applying incoming records.
|
||||
DEFAULT_STORE_BATCH_SIZE: 1,
|
||||
HISTORY_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE
|
||||
FORMS_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE
|
||||
PASSWORDS_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE
|
||||
ADDONS_STORE_BATCH_SIZE: 1000000, // process all addons at once
|
||||
APPS_STORE_BATCH_SIZE: 50, // same as MOBILE_BATCH_SIZE
|
||||
|
||||
// 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,
|
||||
|
||||
// Max number of records or bytes to upload in a single POST - we'll do multiple POSTS if either
|
||||
// MAX_UPLOAD_RECORDS or MAX_UPLOAD_BYTES is hit)
|
||||
MAX_UPLOAD_RECORDS: 100,
|
||||
MAX_UPLOAD_BYTES: 1024 * 1023, // just under 1MB
|
||||
MAX_HISTORY_UPLOAD: 5000,
|
||||
MAX_HISTORY_DOWNLOAD: 5000,
|
||||
|
||||
// TTL of the message sent to another device when sending a tab
|
||||
NOTIFY_TAB_SENT_TTL_SECS: 1 * 3600, // 1 hour
|
||||
|
||||
// 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_PASSWORD: "error.login.reason.no_password2",
|
||||
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",
|
||||
DESKTOP_VERSION_OUT_OF_DATE: "error.sync.reason.desktop_version_out_of_date",
|
||||
SETUP_FAILED_NO_PASSPHRASE: "error.sync.reason.setup_failed_no_passphrase",
|
||||
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",
|
||||
PROLONGED_SYNC_FAILURE: "error.sync.prolonged_failure",
|
||||
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",
|
||||
ENGINE_METARECORD_DOWNLOAD_FAIL: "error.engine.reason.metarecord_download_fail",
|
||||
ENGINE_METARECORD_UPLOAD_FAIL: "error.engine.reason.metarecord_upload_fail",
|
||||
// an upload failure where the batch was interrupted with a 412
|
||||
ENGINE_BATCH_INTERRUPTED: "error.engine.reason.batch_interrupted",
|
||||
|
||||
JPAKE_ERROR_CHANNEL: "jpake.error.channel",
|
||||
JPAKE_ERROR_NETWORK: "jpake.error.network",
|
||||
JPAKE_ERROR_SERVER: "jpake.error.server",
|
||||
JPAKE_ERROR_TIMEOUT: "jpake.error.timeout",
|
||||
JPAKE_ERROR_INTERNAL: "jpake.error.internal",
|
||||
JPAKE_ERROR_INVALID: "jpake.error.invalid",
|
||||
JPAKE_ERROR_NODATA: "jpake.error.nodata",
|
||||
JPAKE_ERROR_KEYMISMATCH: "jpake.error.keymismatch",
|
||||
JPAKE_ERROR_WRONGMESSAGE: "jpake.error.wrongmessage",
|
||||
JPAKE_ERROR_USERABORT: "jpake.error.userabort",
|
||||
JPAKE_ERROR_DELAYUNSUPPORTED: "jpake.error.delayunsupported",
|
||||
|
||||
// info types for Service.getStorageInfo
|
||||
INFO_COLLECTIONS: "collections",
|
||||
INFO_COLLECTION_USAGE: "collection_usage",
|
||||
INFO_COLLECTION_COUNTS: "collection_counts",
|
||||
INFO_QUOTA: "quota",
|
||||
|
||||
// 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",
|
||||
|
||||
// Application IDs
|
||||
FIREFOX_ID: "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}",
|
||||
FENNEC_ID: "{a23983c0-fd0e-11dc-95ff-0800200c9a66}",
|
||||
SEAMONKEY_ID: "{92650c4d-4b8e-4d2a-b7eb-24ecf4f6b63a}",
|
||||
TEST_HARNESS_ID: "xuth@mozilla.org",
|
||||
|
||||
MIN_PP_LENGTH: 12,
|
||||
MIN_PASS_LENGTH: 8,
|
||||
|
||||
DEVICE_TYPE_DESKTOP: "desktop",
|
||||
DEVICE_TYPE_MOBILE: "mobile",
|
||||
|
||||
})) {
|
||||
this[key] = val;
|
||||
this.EXPORTED_SYMBOLS.push(key);
|
||||
}
|
||||
1813
services/sync/modules/engines.js
Normal file
1813
services/sync/modules/engines.js
Normal file
File diff suppressed because it is too large
Load diff
813
services/sync/modules/engines/addons.js
Normal file
813
services/sync/modules/engines/addons.js
Normal file
|
|
@ -0,0 +1,813 @@
|
|||
/* 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 services-sync.js for the behavior of these prefs.
|
||||
*/
|
||||
"use strict";
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/addonutils.js");
|
||||
Cu.import("resource://services-sync/addonsreconciler.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/collection_validator.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
|
||||
"resource://gre/modules/AddonManager.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository",
|
||||
"resource://gre/modules/addons/AddonRepository.jsm");
|
||||
|
||||
this.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.
|
||||
*/
|
||||
this.AddonsEngine = function AddonsEngine(service) {
|
||||
SyncEngine.call(this, "Addons", service);
|
||||
|
||||
this._reconciler = new AddonsReconciler();
|
||||
}
|
||||
AddonsEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: AddonsStore,
|
||||
_trackerObj: AddonsTracker,
|
||||
_recordObj: AddonRecord,
|
||||
version: 1,
|
||||
|
||||
syncPriority: 5,
|
||||
|
||||
_reconciler: null,
|
||||
|
||||
/**
|
||||
* Override parent method to find add-ons by their public ID, not Sync GUID.
|
||||
*/
|
||||
_findDupe: function _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.
|
||||
*/
|
||||
getChangedIDs: function getChangedIDs() {
|
||||
let changes = {};
|
||||
for (let [id, modified] of Object.entries(this._tracker.changedIDs)) {
|
||||
changes[id] = modified;
|
||||
}
|
||||
|
||||
let lastSyncDate = new Date(this.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 (!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.
|
||||
*/
|
||||
_syncStartup: function _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.
|
||||
this._refreshReconcilerState();
|
||||
|
||||
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.
|
||||
*/
|
||||
_syncCleanup: function _syncCleanup() {
|
||||
let ms = 1000 * this.lastSync - PRUNE_ADDON_CHANGES_THRESHOLD;
|
||||
this._reconciler.pruneChangesBeforeDate(new Date(ms));
|
||||
|
||||
SyncEngine.prototype._syncCleanup.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper function to ensure reconciler is up to date.
|
||||
*
|
||||
* This will synchronously load the reconciler's state from the file
|
||||
* system (if needed) and refresh the state of the reconciler.
|
||||
*/
|
||||
_refreshReconcilerState: function _refreshReconcilerState() {
|
||||
this._log.debug("Refreshing reconciler state");
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this._reconciler.refreshGlobalState(cb);
|
||||
cb.wait();
|
||||
},
|
||||
|
||||
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.
|
||||
*/
|
||||
applyIncoming: function 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.
|
||||
let existingMeta = this.reconciler.addons[record.addonID];
|
||||
if (existingMeta && !this.isAddonSyncable(existingMeta)) {
|
||||
this._log.info("Ignoring incoming record for an existing but non-syncable addon", record.addonID);
|
||||
return;
|
||||
}
|
||||
|
||||
Store.prototype.applyIncoming.call(this, record);
|
||||
},
|
||||
|
||||
|
||||
/**
|
||||
* Provides core Store API to create/install an add-on from a record.
|
||||
*/
|
||||
create: function create(record) {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
AddonUtils.installAddons([{
|
||||
id: record.addonID,
|
||||
syncGUID: record.id,
|
||||
enabled: record.enabled,
|
||||
requireSecureURI: this._extensionsPrefs.get("install.requireSecureOrigin", true),
|
||||
}], cb);
|
||||
|
||||
// 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.
|
||||
let results = cb.wait();
|
||||
|
||||
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.
|
||||
*/
|
||||
remove: function remove(record) {
|
||||
// If this is called, the payload is empty, so we have to find by GUID.
|
||||
let addon = 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);
|
||||
let cb = Async.makeSpinningCallback();
|
||||
AddonUtils.uninstallAddon(addon, cb);
|
||||
cb.wait();
|
||||
},
|
||||
|
||||
/**
|
||||
* Provides core Store API to update an add-on from a record.
|
||||
*/
|
||||
update: function update(record) {
|
||||
let addon = 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) {
|
||||
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.
|
||||
}
|
||||
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this.updateUserDisabled(addon, !record.enabled, cb);
|
||||
cb.wait();
|
||||
},
|
||||
|
||||
/**
|
||||
* Provide core Store API to determine if a record exists.
|
||||
*/
|
||||
itemExists: function 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
|
||||
*/
|
||||
createRecord: function 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.
|
||||
*/
|
||||
changeItemID: function 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;
|
||||
let cb = Async.makeSpinningCallback();
|
||||
this.reconciler.saveState(null, cb);
|
||||
cb.wait();
|
||||
}
|
||||
|
||||
let addon = 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.
|
||||
*/
|
||||
getAllIDs: function getAllIDs() {
|
||||
let ids = {};
|
||||
|
||||
let addons = this.reconciler.addons;
|
||||
for (let id in addons) {
|
||||
let addon = addons[id];
|
||||
if (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.
|
||||
*/
|
||||
wipe: function wipe() {
|
||||
this._log.info("Processing wipe.");
|
||||
|
||||
this.engine._refreshReconcilerState();
|
||||
|
||||
// We only wipe syncable add-ons. Wipe is a Sync feature not a security
|
||||
// feature.
|
||||
for (let guid in this.getAllIDs()) {
|
||||
let addon = 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);
|
||||
Utils.catch.call(this, () => addon.uninstall())();
|
||||
}
|
||||
},
|
||||
|
||||
/***************************************************************************
|
||||
* Functions below are unique to this store and not part of the Store API *
|
||||
***************************************************************************/
|
||||
|
||||
/**
|
||||
* Synchronously obtain an add-on from its public ID.
|
||||
*
|
||||
* @param id
|
||||
* Add-on ID
|
||||
* @return Addon or undefined if not found
|
||||
*/
|
||||
getAddonByID: function getAddonByID(id) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
AddonManager.getAddonByID(id, cb);
|
||||
return Async.waitForSyncCallback(cb);
|
||||
},
|
||||
|
||||
/**
|
||||
* Synchronously obtain an add-on from its Sync GUID.
|
||||
*
|
||||
* @param guid
|
||||
* Add-on Sync GUID
|
||||
* @return DBAddonInternal or null
|
||||
*/
|
||||
getAddonByGUID: function getAddonByGUID(guid) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
AddonManager.getAddonBySyncGUID(guid, cb);
|
||||
return Async.waitForSyncCallback(cb);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
isAddonSyncable: function 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.indexOf(addon.type) == -1) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Ignore hotfix extensions (bug 741670). The pref may not be defined.
|
||||
// XXX - note that addon.isSyncable will be false for hotfix addons, so
|
||||
// this check isn't strictly necessary - except for Sync tests which aren't
|
||||
// setup to create a "real" hotfix addon. This can be removed once those
|
||||
// tests are fixed (but keeping it doesn't hurt either)
|
||||
if (this._extensionsPrefs.get("hotfix.id", null) == addon.id) {
|
||||
this._log.debug(addon.id + " not syncable: is a hotfix.");
|
||||
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 cb = Async.makeSyncCallback();
|
||||
AddonRepository.getCachedAddonByID(addon.id, cb);
|
||||
let result = Async.waitForSyncCallback(cb);
|
||||
|
||||
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.indexOf(uri.host) == -1) {
|
||||
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 and call the supplied callback when
|
||||
* the action is complete. If no action is needed, the callback gets called
|
||||
* immediately.
|
||||
*
|
||||
* @param addon
|
||||
* Addon instance to manipulate.
|
||||
* @param value
|
||||
* Boolean to which to set userDisabled on the passed Addon.
|
||||
* @param callback
|
||||
* Function to be called when action is complete. Will receive 2
|
||||
* arguments, a truthy value that signifies error, and the Addon
|
||||
* instance passed to this function.
|
||||
*/
|
||||
updateUserDisabled: function updateUserDisabled(addon, value, callback) {
|
||||
if (addon.userDisabled == value) {
|
||||
callback(null, addon);
|
||||
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);
|
||||
callback(null, addon);
|
||||
return;
|
||||
}
|
||||
|
||||
AddonUtils.updateUserDisabled(addon, value, callback);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
Tracker.call(this, name, engine);
|
||||
}
|
||||
AddonsTracker.prototype = {
|
||||
__proto__: Tracker.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().
|
||||
*/
|
||||
changeListener: function changeHandler(date, change, addon) {
|
||||
this._log.debug("changeListener invoked: " + change + " " + addon.id);
|
||||
// Ignore changes that occur during sync.
|
||||
if (this.ignoreAll) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.store.isAddonSyncable(addon)) {
|
||||
this._log.debug("Ignoring change because add-on isn't syncable: " +
|
||||
addon.id);
|
||||
return;
|
||||
}
|
||||
|
||||
this.addChangedID(addon.guid, date.getTime() / 1000);
|
||||
this.score += SCORE_INCREMENT_XLARGE;
|
||||
},
|
||||
|
||||
startTracking: function() {
|
||||
if (this.engine.enabled) {
|
||||
this.reconciler.startListening();
|
||||
}
|
||||
|
||||
this.reconciler.addChangeListener(this);
|
||||
},
|
||||
|
||||
stopTracking: function() {
|
||||
this.reconciler.removeChangeListener(this);
|
||||
this.reconciler.stopListening();
|
||||
},
|
||||
};
|
||||
|
||||
class AddonValidator extends CollectionValidator {
|
||||
constructor(engine = null) {
|
||||
super("addons", "id", [
|
||||
"addonID",
|
||||
"enabled",
|
||||
"applicationID",
|
||||
"source"
|
||||
]);
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
getClientItems() {
|
||||
return Promise.all([
|
||||
new Promise(resolve =>
|
||||
AddonManager.getAllAddons(resolve)),
|
||||
new Promise(resolve =>
|
||||
AddonManager.getAddonsWithOperationsByTypes(["extension", "theme"], resolve)),
|
||||
]).then(([installed, addonsWithPendingOperation]) => {
|
||||
// Addons pending install won't be in the first list, but addons pending
|
||||
// uninstall/enable/disable will be in both lists.
|
||||
let all = new Map(installed.map(addon => [addon.id, addon]));
|
||||
for (let addon of addonsWithPendingOperation) {
|
||||
all.set(addon.id, addon);
|
||||
}
|
||||
// Convert to an array since Map.prototype.values returns an iterable
|
||||
return [...all.values()];
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
normalizeServerItem(item) {
|
||||
let guid = this.engine._findDupe(item);
|
||||
if (guid) {
|
||||
item.id = guid;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
clientUnderstands(item) {
|
||||
return item.applicationID === Services.appinfo.ID;
|
||||
}
|
||||
|
||||
syncedByClient(item) {
|
||||
return !item.original.hidden &&
|
||||
!item.original.isSystem &&
|
||||
!(item.original.pendingOperations & AddonManager.PENDING_UNINSTALL) &&
|
||||
this.engine.isAddonSyncable(item.original, true);
|
||||
}
|
||||
}
|
||||
1378
services/sync/modules/engines/bookmarks.js
Normal file
1378
services/sync/modules/engines/bookmarks.js
Normal file
File diff suppressed because it is too large
Load diff
782
services/sync/modules/engines/clients.js
Normal file
782
services/sync/modules/engines/clients.js
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
/* 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.
|
||||
*/
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"ClientEngine",
|
||||
"ClientsRec"
|
||||
];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://services-common/stringbundle.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/resource.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
|
||||
"resource://gre/modules/FxAccounts.jsm");
|
||||
|
||||
const CLIENTS_TTL = 1814400; // 21 days
|
||||
const CLIENTS_TTL_REFRESH = 604800; // 7 days
|
||||
const STALE_CLIENT_REMOTE_AGE = 604800; // 7 days
|
||||
|
||||
const SUPPORTED_PROTOCOL_VERSIONS = ["1.1", "1.5"];
|
||||
|
||||
function hasDupeCommand(commands, action) {
|
||||
if (!commands) {
|
||||
return false;
|
||||
}
|
||||
return commands.some(other => other.command == action.command &&
|
||||
Utils.deepEquals(other.args, action.args));
|
||||
}
|
||||
|
||||
this.ClientsRec = 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"]);
|
||||
|
||||
|
||||
this.ClientEngine = function ClientEngine(service) {
|
||||
SyncEngine.call(this, "Clients", service);
|
||||
|
||||
// Reset the last sync timestamp on every startup so that we fetch all clients
|
||||
this.resetLastSync();
|
||||
}
|
||||
ClientEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: ClientStore,
|
||||
_recordObj: ClientsRec,
|
||||
_trackerObj: ClientsTracker,
|
||||
allowSkippedRecord: false,
|
||||
|
||||
// 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);
|
||||
},
|
||||
|
||||
remoteClientExists(id) {
|
||||
let client = this._store._remoteClients[id];
|
||||
return !!(client && !client.stale);
|
||||
},
|
||||
|
||||
// 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.
|
||||
*/
|
||||
get deviceTypes() {
|
||||
let counts = new Map();
|
||||
|
||||
counts.set(this.localType, 1);
|
||||
|
||||
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 localID() {
|
||||
// Generate a random GUID id we don't have one
|
||||
let localID = Svc.Prefs.get("client.GUID", "");
|
||||
return localID == "" ? this.localID = Utils.makeGUID() : localID;
|
||||
},
|
||||
set localID(value) {
|
||||
Svc.Prefs.set("client.GUID", value);
|
||||
},
|
||||
|
||||
get brandName() {
|
||||
let brand = new StringBundle("chrome://branding/locale/brand.properties");
|
||||
return brand.get("brandShortName");
|
||||
},
|
||||
|
||||
get localName() {
|
||||
let name = Utils.getDeviceName();
|
||||
// If `getDeviceName` returns the default name, set the pref. FxA registers
|
||||
// the device before syncing, so we don't need to update the registration
|
||||
// in this case.
|
||||
Svc.Prefs.set("client.name", name);
|
||||
return name;
|
||||
},
|
||||
set localName(value) {
|
||||
Svc.Prefs.set("client.name", value);
|
||||
// Update the registration in the background.
|
||||
fxAccounts.updateDeviceRegistration().catch(error => {
|
||||
this._log.warn("failed to update fxa device registration", error);
|
||||
});
|
||||
},
|
||||
|
||||
get localType() {
|
||||
return Utils.getDeviceType();
|
||||
},
|
||||
set localType(value) {
|
||||
Svc.Prefs.set("client.type", value);
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
isMobile: function isMobile(id) {
|
||||
if (this._store._remoteClients[id])
|
||||
return this._store._remoteClients[id].type == DEVICE_TYPE_MOBILE;
|
||||
return false;
|
||||
},
|
||||
|
||||
_readCommands() {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
Utils.jsonLoad("commands", this, commands => cb(null, commands));
|
||||
return cb.wait() || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* Low level function, do not use directly (use _addClientCommand instead).
|
||||
*/
|
||||
_saveCommands(commands) {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
Utils.jsonSave("commands", this, commands, error => {
|
||||
if (error) {
|
||||
this._log.error("Failed to save JSON outgoing commands", error);
|
||||
}
|
||||
cb();
|
||||
});
|
||||
cb.wait();
|
||||
},
|
||||
|
||||
_prepareCommandsForUpload() {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
Utils.jsonMove("commands", "commands-syncing", this).catch(() => {}) // Ignore errors
|
||||
.then(() => {
|
||||
Utils.jsonLoad("commands-syncing", this, commands => cb(null, commands));
|
||||
});
|
||||
return cb.wait() || {};
|
||||
},
|
||||
|
||||
_deleteUploadedCommands() {
|
||||
delete this._currentlySyncingCommands;
|
||||
Async.promiseSpinningly(
|
||||
Utils.jsonRemove("commands-syncing", this).catch(err => {
|
||||
this._log.error("Failed to delete syncing-commands file", err);
|
||||
})
|
||||
);
|
||||
},
|
||||
|
||||
_addClientCommand(clientId, command) {
|
||||
const allCommands = this._readCommands();
|
||||
const clientCommands = allCommands[clientId] || [];
|
||||
if (hasDupeCommand(clientCommands, command)) {
|
||||
return;
|
||||
}
|
||||
allCommands[clientId] = clientCommands.concat(command);
|
||||
this._saveCommands(allCommands);
|
||||
},
|
||||
|
||||
_syncStartup: function _syncStartup() {
|
||||
// Reupload new client record periodically.
|
||||
if (Date.now() / 1000 - this.lastRecordUpload > CLIENTS_TTL_REFRESH) {
|
||||
this._tracker.addChangedID(this.localID);
|
||||
this.lastRecordUpload = Date.now() / 1000;
|
||||
}
|
||||
SyncEngine.prototype._syncStartup.call(this);
|
||||
},
|
||||
|
||||
_processIncoming() {
|
||||
// Fetch all records from the server.
|
||||
this.lastSync = 0;
|
||||
this._incomingClients = {};
|
||||
try {
|
||||
SyncEngine.prototype._processIncoming.call(this);
|
||||
// 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}`);
|
||||
this._removeRemoteClient(id);
|
||||
}
|
||||
}
|
||||
// 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)
|
||||
delete this._incomingClients[this.localID];
|
||||
let names = new Set([this.localName]);
|
||||
for (let id in this._incomingClients) {
|
||||
let record = this._store._remoteClients[id];
|
||||
if (!names.has(record.name)) {
|
||||
names.add(record.name);
|
||||
continue;
|
||||
}
|
||||
let remoteAge = AsyncResource.serverTime - this._incomingClients[id];
|
||||
if (remoteAge > STALE_CLIENT_REMOTE_AGE) {
|
||||
this._log.info(`Hiding stale client ${id} with age ${remoteAge}`);
|
||||
record.stale = true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
this._incomingClients = null;
|
||||
}
|
||||
},
|
||||
|
||||
_uploadOutgoing() {
|
||||
this._currentlySyncingCommands = 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);
|
||||
}
|
||||
}
|
||||
SyncEngine.prototype._uploadOutgoing.call(this);
|
||||
},
|
||||
|
||||
_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.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.
|
||||
clientRecord.commands = 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;
|
||||
}
|
||||
this._addClientCommand(id, commandChanges);
|
||||
}
|
||||
|
||||
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._notifyCollectionChanged(idsToNotify);
|
||||
}
|
||||
},
|
||||
|
||||
_notifyCollectionChanged(ids) {
|
||||
const message = {
|
||||
version: 1,
|
||||
command: "sync:collection_changed",
|
||||
data: {
|
||||
collections: ["clients"]
|
||||
}
|
||||
};
|
||||
fxAccounts.notifyDevices(ids, message, NOTIFY_TAB_SENT_TTL_SECS);
|
||||
},
|
||||
|
||||
_syncFinish() {
|
||||
// Record histograms for our device types, and also write them to a pref
|
||||
// so non-histogram telemetry (eg, UITelemetry) has easy access to them.
|
||||
for (let [deviceType, count] of this.deviceTypes) {
|
||||
let hid;
|
||||
let prefName = this.name + ".devices.";
|
||||
switch (deviceType) {
|
||||
case "desktop":
|
||||
hid = "WEAVE_DEVICE_COUNT_DESKTOP";
|
||||
prefName += "desktop";
|
||||
break;
|
||||
case "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);
|
||||
Svc.Prefs.set(prefName, count);
|
||||
}
|
||||
SyncEngine.prototype._syncFinish.call(this);
|
||||
},
|
||||
|
||||
_reconcile: function _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 (!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.
|
||||
this._store.update(item);
|
||||
return false;
|
||||
},
|
||||
|
||||
// Treat reset the same as wiping for locally cached clients
|
||||
_resetClient() {
|
||||
this._wipeClient();
|
||||
},
|
||||
|
||||
_wipeClient: function _wipeClient() {
|
||||
SyncEngine.prototype._resetClient.call(this);
|
||||
delete this.localCommands;
|
||||
this._store.wipe();
|
||||
const logRemoveError = err => this._log.warn("Could not delete json file", err);
|
||||
Async.promiseSpinningly(
|
||||
Utils.jsonRemove("commands", this).catch(logRemoveError)
|
||||
.then(Utils.jsonRemove("commands-syncing", this).catch(logRemoveError))
|
||||
);
|
||||
},
|
||||
|
||||
removeClientData: function removeClientData() {
|
||||
let res = this.service.resource(this.engineURL + "/" + this.localID);
|
||||
res.delete();
|
||||
},
|
||||
|
||||
// Override the default behavior to delete bad records from the server.
|
||||
handleHMACMismatch: function handleHMACMismatch(item, mayRetry) {
|
||||
this._log.debug("Handling HMAC mismatch for " + item.id);
|
||||
|
||||
let base = 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.");
|
||||
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 and description.
|
||||
*/
|
||||
_commands: {
|
||||
resetAll: { args: 0, desc: "Clear temporary local data for all engines" },
|
||||
resetEngine: { args: 1, desc: "Clear temporary local data for engine" },
|
||||
wipeAll: { args: 0, desc: "Delete all client data for all engines" },
|
||||
wipeEngine: { args: 1, desc: "Delete all client data for engine" },
|
||||
logout: { args: 0, desc: "Log out client" },
|
||||
displayURI: { args: 3, 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
|
||||
*/
|
||||
_sendCommandToClient: function sendCommandToClient(command, args, clientId) {
|
||||
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: command,
|
||||
args: args,
|
||||
};
|
||||
|
||||
this._log.trace("Client " + clientId + " got a new action: " + [command, args]);
|
||||
this._addClientCommand(clientId, action);
|
||||
this._tracker.addChangedID(clientId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the local client has any remote commands and perform them.
|
||||
*
|
||||
* @return false to abort sync
|
||||
*/
|
||||
processIncomingCommands: function processIncomingCommands() {
|
||||
return this._notify("clients:process-commands", "", function() {
|
||||
if (!this.localCommands) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const clearedCommands = this._readCommands()[this.localID];
|
||||
const commands = this.localCommands.filter(command => !hasDupeCommand(clearedCommands, command));
|
||||
|
||||
let URIsToDisplay = [];
|
||||
// Process each command in order.
|
||||
for (let rawCommand of commands) {
|
||||
let {command, args} = rawCommand;
|
||||
this._log.debug("Processing command: " + command + "(" + args + ")");
|
||||
|
||||
let engines = [args[0]];
|
||||
switch (command) {
|
||||
case "resetAll":
|
||||
engines = null;
|
||||
// Fallthrough
|
||||
case "resetEngine":
|
||||
this.service.resetClient(engines);
|
||||
break;
|
||||
case "wipeAll":
|
||||
engines = null;
|
||||
// Fallthrough
|
||||
case "wipeEngine":
|
||||
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.debug("Received an unknown command: " + command);
|
||||
break;
|
||||
}
|
||||
// Add the command to the "cleared" commands list
|
||||
this._addClientCommand(this.localID, rawCommand)
|
||||
}
|
||||
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.
|
||||
*
|
||||
* @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.
|
||||
*/
|
||||
sendCommand: function sendCommand(command, args, clientId) {
|
||||
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;
|
||||
}
|
||||
// Don't send a command with the wrong number of arguments.
|
||||
else if (!args || args.length != commandData.args) {
|
||||
this._log.error("Expected " + commandData.args + " args for '" +
|
||||
command + "', but got " + args);
|
||||
return;
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
this._sendCommandToClient(command, args, clientId);
|
||||
} else {
|
||||
for (let [id, record] of Object.entries(this._store._remoteClients)) {
|
||||
if (!record.stale) {
|
||||
this._sendCommandToClient(command, args, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
sendURIToClientForDisplay: function sendURIToClientForDisplay(uri, clientId, title) {
|
||||
this._log.info("Sending URI to client: " + uri + " -> " +
|
||||
clientId + " (" + title + ")");
|
||||
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.
|
||||
* clientId ID 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: function _handleDisplayURIs(uris) {
|
||||
Svc.Obs.notify("weave:engine:clients:display-uris", uris);
|
||||
},
|
||||
|
||||
_removeRemoteClient(id) {
|
||||
delete this._store._remoteClients[id];
|
||||
this._tracker.removeChangedID(id);
|
||||
},
|
||||
};
|
||||
|
||||
function ClientStore(name, engine) {
|
||||
Store.call(this, name, engine);
|
||||
}
|
||||
ClientStore.prototype = {
|
||||
__proto__: Store.prototype,
|
||||
|
||||
_remoteClients: {},
|
||||
|
||||
create(record) {
|
||||
this.update(record);
|
||||
},
|
||||
|
||||
update: function 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;
|
||||
}
|
||||
},
|
||||
|
||||
createRecord: function 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) {
|
||||
let cb = Async.makeSpinningCallback();
|
||||
fxAccounts.getDeviceId().then(id => cb(null, id), cb);
|
||||
try {
|
||||
record.fxaDeviceId = cb.wait();
|
||||
} 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 = this._remoteClients[id];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
return record;
|
||||
},
|
||||
|
||||
itemExists(id) {
|
||||
return id in this.getAllIDs();
|
||||
},
|
||||
|
||||
getAllIDs: function getAllIDs() {
|
||||
let ids = {};
|
||||
ids[this.engine.localID] = true;
|
||||
for (let id in this._remoteClients)
|
||||
ids[id] = true;
|
||||
return ids;
|
||||
},
|
||||
|
||||
wipe: function wipe() {
|
||||
this._remoteClients = {};
|
||||
},
|
||||
};
|
||||
|
||||
function ClientsTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
Svc.Obs.add("weave:engine:start-tracking", this);
|
||||
Svc.Obs.add("weave:engine:stop-tracking", this);
|
||||
}
|
||||
ClientsTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
_enabled: false,
|
||||
|
||||
observe: function observe(subject, topic, data) {
|
||||
switch (topic) {
|
||||
case "weave:engine:start-tracking":
|
||||
if (!this._enabled) {
|
||||
Svc.Prefs.observe("client.name", this);
|
||||
this._enabled = true;
|
||||
}
|
||||
break;
|
||||
case "weave:engine:stop-tracking":
|
||||
if (this._enabled) {
|
||||
Svc.Prefs.ignore("client.name", this);
|
||||
this._enabled = false;
|
||||
}
|
||||
break;
|
||||
case "nsPref:changed":
|
||||
this._log.debug("client.name preference changed");
|
||||
this.addChangedID(Svc.Prefs.get("client.GUID"));
|
||||
this.score += SCORE_INCREMENT_XLARGE;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
277
services/sync/modules/engines/extension-storage.js
Normal file
277
services/sync/modules/engines/extension-storage.js
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ['ExtensionStorageEngine', 'EncryptionRemoteTransformer',
|
||||
'KeyRingEncryptionRemoteTransformer'];
|
||||
|
||||
const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-crypto/utils.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/keys.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "ExtensionStorageSync",
|
||||
"resource://gre/modules/ExtensionStorageSync.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts",
|
||||
"resource://gre/modules/FxAccounts.jsm");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Task",
|
||||
"resource://gre/modules/Task.jsm");
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
this.ExtensionStorageEngine = function ExtensionStorageEngine(service) {
|
||||
SyncEngine.call(this, "Extension-Storage", service);
|
||||
};
|
||||
ExtensionStorageEngine.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,
|
||||
|
||||
_sync: function () {
|
||||
return Async.promiseSpinningly(ExtensionStorageSync.syncAll());
|
||||
},
|
||||
|
||||
get enabled() {
|
||||
// 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("engine." + this.prefName + ".force", undefined);
|
||||
if (forced !== undefined) {
|
||||
return forced;
|
||||
}
|
||||
return Svc.Prefs.get("engine.addons", false);
|
||||
},
|
||||
};
|
||||
|
||||
function ExtensionStorageTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
}
|
||||
ExtensionStorageTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
startTracking: function () {
|
||||
Svc.Obs.add("ext.storage.sync-changed", this);
|
||||
},
|
||||
|
||||
stopTracking: function () {
|
||||
Svc.Obs.remove("ext.storage.sync-changed", this);
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
Tracker.prototype.observe.call(this, 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;
|
||||
},
|
||||
|
||||
// Override a bunch of methods which don't do anything for us.
|
||||
// This is a performance hack.
|
||||
saveChangedIDs: function() {
|
||||
},
|
||||
loadChangedIDs: function() {
|
||||
},
|
||||
ignoreID: function() {
|
||||
},
|
||||
unignoreID: function() {
|
||||
},
|
||||
addChangedID: function() {
|
||||
},
|
||||
removeChangedID: function() {
|
||||
},
|
||||
clearChangedIDs: function() {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to enforce an order of fields when computing an HMAC.
|
||||
*/
|
||||
function ciphertextHMAC(keyBundle, id, IV, ciphertext) {
|
||||
const hasher = keyBundle.sha256HMACHasher;
|
||||
return Utils.bytesAsHex(Utils.digestUTF8(id + IV + ciphertext, hasher));
|
||||
}
|
||||
|
||||
/**
|
||||
* A "remote transformer" that the Kinto library will use to
|
||||
* encrypt/decrypt records when syncing.
|
||||
*
|
||||
* This is an "abstract base class". Subclass this and override
|
||||
* getKeys() to use it.
|
||||
*/
|
||||
class EncryptionRemoteTransformer {
|
||||
encode(record) {
|
||||
const self = this;
|
||||
return Task.spawn(function* () {
|
||||
const keyBundle = yield self.getKeys();
|
||||
if (record.ciphertext) {
|
||||
throw new Error("Attempt to reencrypt??");
|
||||
}
|
||||
let id = record.id;
|
||||
if (!record.id) {
|
||||
throw new Error("Record ID is missing or invalid");
|
||||
}
|
||||
|
||||
let IV = Svc.Crypto.generateRandomIV();
|
||||
let ciphertext = Svc.Crypto.encrypt(JSON.stringify(record),
|
||||
keyBundle.encryptionKeyB64, IV);
|
||||
let hmac = ciphertextHMAC(keyBundle, id, IV, ciphertext);
|
||||
const encryptedResult = {ciphertext, IV, hmac, id};
|
||||
if (record.hasOwnProperty("last_modified")) {
|
||||
encryptedResult.last_modified = record.last_modified;
|
||||
}
|
||||
return encryptedResult;
|
||||
});
|
||||
}
|
||||
|
||||
decode(record) {
|
||||
const self = this;
|
||||
return Task.spawn(function* () {
|
||||
if (!record.ciphertext) {
|
||||
// This can happen for tombstones if a record is deleted.
|
||||
if (record.deleted) {
|
||||
return record;
|
||||
}
|
||||
throw new Error("No ciphertext: nothing to decrypt?");
|
||||
}
|
||||
const keyBundle = yield self.getKeys();
|
||||
// Authenticate the encrypted blob with the expected HMAC
|
||||
let computedHMAC = ciphertextHMAC(keyBundle, record.id, record.IV, record.ciphertext);
|
||||
|
||||
if (computedHMAC != record.hmac) {
|
||||
Utils.throwHMACMismatch(record.hmac, computedHMAC);
|
||||
}
|
||||
|
||||
// Handle invalid data here. Elsewhere we assume that cleartext is an object.
|
||||
let cleartext = Svc.Crypto.decrypt(record.ciphertext,
|
||||
keyBundle.encryptionKeyB64, record.IV);
|
||||
let jsonResult = JSON.parse(cleartext);
|
||||
if (!jsonResult || typeof jsonResult !== "object") {
|
||||
throw new Error("Decryption failed: result is <" + jsonResult + ">, not an object.");
|
||||
}
|
||||
|
||||
// Verify that the encrypted id matches the requested record's id.
|
||||
// This should always be true, because we compute the HMAC over
|
||||
// the original record's ID, and that was verified already (above).
|
||||
if (jsonResult.id != record.id) {
|
||||
throw new Error("Record id mismatch: " + jsonResult.id + " != " + record.id);
|
||||
}
|
||||
|
||||
if (record.hasOwnProperty("last_modified")) {
|
||||
jsonResult.last_modified = record.last_modified;
|
||||
}
|
||||
|
||||
return jsonResult;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve keys to use during encryption.
|
||||
*
|
||||
* Returns a Promise<KeyBundle>.
|
||||
*/
|
||||
getKeys() {
|
||||
throw new Error("override getKeys in a subclass");
|
||||
}
|
||||
}
|
||||
// You can inject this
|
||||
EncryptionRemoteTransformer.prototype._fxaService = fxAccounts;
|
||||
|
||||
/**
|
||||
* An EncryptionRemoteTransformer that provides a keybundle derived
|
||||
* from the user's kB, suitable for encrypting a keyring.
|
||||
*/
|
||||
class KeyRingEncryptionRemoteTransformer extends EncryptionRemoteTransformer {
|
||||
getKeys() {
|
||||
const self = this;
|
||||
return Task.spawn(function* () {
|
||||
const user = yield self._fxaService.getSignedInUser();
|
||||
// FIXME: we should permit this if the user is self-hosting
|
||||
// their storage
|
||||
if (!user) {
|
||||
throw new Error("user isn't signed in to FxA; can't sync");
|
||||
}
|
||||
|
||||
if (!user.kB) {
|
||||
throw new Error("user doesn't have kB");
|
||||
}
|
||||
|
||||
let kB = Utils.hexToBytes(user.kB);
|
||||
|
||||
let keyMaterial = CryptoUtils.hkdf(kB, undefined,
|
||||
"identity.mozilla.com/picl/v1/chrome.storage.sync", 2*32);
|
||||
let bundle = new BulkKeyBundle();
|
||||
// [encryptionKey, hmacKey]
|
||||
bundle.keyPair = [keyMaterial.slice(0, 32), keyMaterial.slice(32, 64)];
|
||||
return bundle;
|
||||
});
|
||||
}
|
||||
// Pass through the kbHash field from the unencrypted record. If
|
||||
// encryption fails, we can use this to try to detect whether we are
|
||||
// being compromised or if the record here was encoded with a
|
||||
// different kB.
|
||||
encode(record) {
|
||||
const encodePromise = super.encode(record);
|
||||
return Task.spawn(function* () {
|
||||
const encoded = yield encodePromise;
|
||||
encoded.kbHash = record.kbHash;
|
||||
return encoded;
|
||||
});
|
||||
}
|
||||
|
||||
decode(record) {
|
||||
const decodePromise = super.decode(record);
|
||||
return Task.spawn(function* () {
|
||||
try {
|
||||
return yield decodePromise;
|
||||
} catch (e) {
|
||||
if (Utils.isHMACMismatch(e)) {
|
||||
const currentKBHash = yield ExtensionStorageSync.getKBHash();
|
||||
if (record.kbHash != currentKBHash) {
|
||||
// Some other client encoded this with a kB that we don't
|
||||
// have access to.
|
||||
KeyRingEncryptionRemoteTransformer.throwOutdatedKB(currentKBHash, record.kbHash);
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Generator and discriminator for KB-is-outdated exceptions.
|
||||
static throwOutdatedKB(shouldBe, is) {
|
||||
throw new Error(`kB hash on record is outdated: should be ${shouldBe}, is ${is}`);
|
||||
}
|
||||
|
||||
static isOutdatedKB(exc) {
|
||||
const kbMessage = "kB hash on record is outdated: ";
|
||||
return exc && exc.message && exc.message.indexOf &&
|
||||
(exc.message.indexOf(kbMessage) == 0);
|
||||
}
|
||||
}
|
||||
305
services/sync/modules/engines/forms.js
Normal file
305
services/sync/modules/engines/forms.js
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ['FormEngine', 'FormRec', 'FormValidator'];
|
||||
|
||||
var Cc = Components.classes;
|
||||
var Ci = Components.interfaces;
|
||||
var Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/collection_validator.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
|
||||
const FORMS_TTL = 3 * 365 * 24 * 60 * 60; // Three years in seconds.
|
||||
|
||||
this.FormRec = 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"],
|
||||
|
||||
_promiseSearch: function(terms, searchData) {
|
||||
return new Promise(resolve => {
|
||||
let results = [];
|
||||
let callbacks = {
|
||||
handleResult(result) {
|
||||
results.push(result);
|
||||
},
|
||||
handleCompletion(reason) {
|
||||
resolve(results);
|
||||
}
|
||||
};
|
||||
Svc.FormHistory.search(terms, searchData, callbacks);
|
||||
})
|
||||
},
|
||||
|
||||
// Do a "sync" search by spinning the event loop until it completes.
|
||||
_searchSpinningly: function(terms, searchData) {
|
||||
return Async.promiseSpinningly(this._promiseSearch(terms, searchData));
|
||||
},
|
||||
|
||||
_updateSpinningly: function(changes) {
|
||||
if (!Svc.FormHistory.enabled) {
|
||||
return; // update isn't going to do anything.
|
||||
}
|
||||
let cb = Async.makeSpinningCallback();
|
||||
let callbacks = {
|
||||
handleCompletion: function(reason) {
|
||||
cb();
|
||||
}
|
||||
};
|
||||
Svc.FormHistory.update(changes, callbacks);
|
||||
return cb.wait();
|
||||
},
|
||||
|
||||
getEntry: function (guid) {
|
||||
let results = this._searchSpinningly(this._getEntryCols, {guid: guid});
|
||||
if (!results.length) {
|
||||
return null;
|
||||
}
|
||||
return {name: results[0].fieldname, value: results[0].value};
|
||||
},
|
||||
|
||||
getGUID: function (name, value) {
|
||||
// Query for the provided entry.
|
||||
let query = { fieldname: name, value: value };
|
||||
let results = this._searchSpinningly(this._guidCols, query);
|
||||
return results.length ? results[0].guid : null;
|
||||
},
|
||||
|
||||
hasGUID: function (guid) {
|
||||
// We could probably use a count function here, but searchSpinningly exists...
|
||||
return this._searchSpinningly(this._guidCols, {guid: guid}).length != 0;
|
||||
},
|
||||
|
||||
replaceGUID: function (oldGUID, newGUID) {
|
||||
let changes = {
|
||||
op: "update",
|
||||
guid: oldGUID,
|
||||
newGuid: newGUID,
|
||||
}
|
||||
this._updateSpinningly(changes);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
this.FormEngine = function FormEngine(service) {
|
||||
SyncEngine.call(this, "Forms", service);
|
||||
}
|
||||
FormEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: FormStore,
|
||||
_trackerObj: FormTracker,
|
||||
_recordObj: FormRec,
|
||||
applyIncomingBatchSize: FORMS_STORE_BATCH_SIZE,
|
||||
|
||||
syncPriority: 6,
|
||||
|
||||
get prefName() {
|
||||
return "history";
|
||||
},
|
||||
|
||||
_findDupe: function _findDupe(item) {
|
||||
return FormWrapper.getGUID(item.name, item.value);
|
||||
}
|
||||
};
|
||||
|
||||
function FormStore(name, engine) {
|
||||
Store.call(this, name, engine);
|
||||
}
|
||||
FormStore.prototype = {
|
||||
__proto__: Store.prototype,
|
||||
|
||||
_processChange: function (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 synchronously, right now.
|
||||
FormWrapper._updateSpinningly(change);
|
||||
},
|
||||
|
||||
applyIncomingBatch: function (records) {
|
||||
// We collect all the changes to be made then apply them all at once.
|
||||
this._changes = [];
|
||||
let failures = Store.prototype.applyIncomingBatch.call(this, records);
|
||||
if (this._changes.length) {
|
||||
FormWrapper._updateSpinningly(this._changes);
|
||||
}
|
||||
delete this._changes;
|
||||
return failures;
|
||||
},
|
||||
|
||||
getAllIDs: function () {
|
||||
let results = FormWrapper._searchSpinningly(["guid"], [])
|
||||
let guids = {};
|
||||
for (let result of results) {
|
||||
guids[result.guid] = true;
|
||||
}
|
||||
return guids;
|
||||
},
|
||||
|
||||
changeItemID: function (oldID, newID) {
|
||||
FormWrapper.replaceGUID(oldID, newID);
|
||||
},
|
||||
|
||||
itemExists: function (id) {
|
||||
return FormWrapper.hasGUID(id);
|
||||
},
|
||||
|
||||
createRecord: function (id, collection) {
|
||||
let record = new FormRec(collection, id);
|
||||
let entry = FormWrapper.getEntry(id);
|
||||
if (entry != null) {
|
||||
record.name = entry.name;
|
||||
record.value = entry.value;
|
||||
} else {
|
||||
record.deleted = true;
|
||||
}
|
||||
return record;
|
||||
},
|
||||
|
||||
create: function (record) {
|
||||
this._log.trace("Adding form record for " + record.name);
|
||||
let change = {
|
||||
op: "add",
|
||||
fieldname: record.name,
|
||||
value: record.value
|
||||
};
|
||||
this._processChange(change);
|
||||
},
|
||||
|
||||
remove: function (record) {
|
||||
this._log.trace("Removing form record: " + record.id);
|
||||
let change = {
|
||||
op: "remove",
|
||||
guid: record.id
|
||||
};
|
||||
this._processChange(change);
|
||||
},
|
||||
|
||||
update: function (record) {
|
||||
this._log.trace("Ignoring form record update request!");
|
||||
},
|
||||
|
||||
wipe: function () {
|
||||
let change = {
|
||||
op: "remove"
|
||||
};
|
||||
FormWrapper._updateSpinningly(change);
|
||||
}
|
||||
};
|
||||
|
||||
function FormTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
}
|
||||
FormTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
QueryInterface: XPCOMUtils.generateQI([
|
||||
Ci.nsIObserver,
|
||||
Ci.nsISupportsWeakReference]),
|
||||
|
||||
startTracking: function() {
|
||||
Svc.Obs.add("satchel-storage-changed", this);
|
||||
},
|
||||
|
||||
stopTracking: function() {
|
||||
Svc.Obs.remove("satchel-storage-changed", this);
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
Tracker.prototype.observe.call(this, 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();
|
||||
this.trackEntry(guid);
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
trackEntry: function (guid) {
|
||||
this.addChangedID(guid);
|
||||
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"]);
|
||||
}
|
||||
|
||||
emptyProblemData() {
|
||||
return new FormsProblemData();
|
||||
}
|
||||
|
||||
getClientItems() {
|
||||
return FormWrapper._promiseSearch(["guid", "fieldname", "value"], {});
|
||||
}
|
||||
|
||||
normalizeClientItem(item) {
|
||||
return {
|
||||
id: item.guid,
|
||||
guid: item.guid,
|
||||
name: item.fieldname,
|
||||
fieldname: item.fieldname,
|
||||
value: item.value,
|
||||
original: item,
|
||||
};
|
||||
}
|
||||
|
||||
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 = FormWrapper.getGUID(item.name, item.value);
|
||||
if (guid) {
|
||||
res.guid = guid;
|
||||
res.id = guid;
|
||||
res.duped = true;
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
||||
442
services/sync/modules/engines/history.js
Normal file
442
services/sync/modules/engines/history.js
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ['HistoryEngine', 'HistoryRec'];
|
||||
|
||||
var Cc = Components.classes;
|
||||
var Ci = Components.interfaces;
|
||||
var Cu = Components.utils;
|
||||
var Cr = Components.results;
|
||||
|
||||
const HISTORY_TTL = 5184000; // 60 days
|
||||
|
||||
Cu.import("resource://gre/modules/PlacesUtils.jsm", this);
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
this.HistoryRec = 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"]);
|
||||
|
||||
|
||||
this.HistoryEngine = function HistoryEngine(service) {
|
||||
SyncEngine.call(this, "History", service);
|
||||
}
|
||||
HistoryEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_recordObj: HistoryRec,
|
||||
_storeObj: HistoryStore,
|
||||
_trackerObj: HistoryTracker,
|
||||
downloadLimit: MAX_HISTORY_DOWNLOAD,
|
||||
applyIncomingBatchSize: HISTORY_STORE_BATCH_SIZE,
|
||||
|
||||
syncPriority: 7,
|
||||
|
||||
_processIncoming: function (newitems) {
|
||||
// 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();
|
||||
function notifyHistoryObservers(notification) {
|
||||
for (let observer of observers) {
|
||||
try {
|
||||
observer[notification]();
|
||||
} catch (ex) { }
|
||||
}
|
||||
}
|
||||
notifyHistoryObservers("onBeginUpdateBatch");
|
||||
try {
|
||||
return SyncEngine.prototype._processIncoming.call(this, newitems);
|
||||
} finally {
|
||||
notifyHistoryObservers("onEndUpdateBatch");
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function HistoryStore(name, engine) {
|
||||
Store.call(this, name, engine);
|
||||
|
||||
// Explicitly nullify our references to our cached services so we don't leak
|
||||
Svc.Obs.add("places-shutdown", function() {
|
||||
for (let query in this._stmts) {
|
||||
let stmt = this._stmts;
|
||||
stmt.finalize();
|
||||
}
|
||||
this._stmts = {};
|
||||
}, this);
|
||||
}
|
||||
HistoryStore.prototype = {
|
||||
__proto__: Store.prototype,
|
||||
|
||||
__asyncHistory: null,
|
||||
get _asyncHistory() {
|
||||
if (!this.__asyncHistory) {
|
||||
this.__asyncHistory = Cc["@mozilla.org/browser/history;1"]
|
||||
.getService(Ci.mozIAsyncHistory);
|
||||
}
|
||||
return this.__asyncHistory;
|
||||
},
|
||||
|
||||
_stmts: {},
|
||||
_getStmt: function(query) {
|
||||
if (query in this._stmts) {
|
||||
return this._stmts[query];
|
||||
}
|
||||
|
||||
this._log.trace("Creating SQL statement: " + query);
|
||||
let db = PlacesUtils.history.QueryInterface(Ci.nsPIPlacesDatabase)
|
||||
.DBConnection;
|
||||
return this._stmts[query] = db.createAsyncStatement(query);
|
||||
},
|
||||
|
||||
get _setGUIDStm() {
|
||||
return this._getStmt(
|
||||
"UPDATE moz_places " +
|
||||
"SET guid = :guid " +
|
||||
"WHERE url_hash = hash(:page_url) AND url = :page_url");
|
||||
},
|
||||
|
||||
// Some helper functions to handle GUIDs
|
||||
setGUID: function setGUID(uri, guid) {
|
||||
uri = uri.spec ? uri.spec : uri;
|
||||
|
||||
if (!guid) {
|
||||
guid = Utils.makeGUID();
|
||||
}
|
||||
|
||||
let stmt = this._setGUIDStm;
|
||||
stmt.params.guid = guid;
|
||||
stmt.params.page_url = uri;
|
||||
Async.querySpinningly(stmt);
|
||||
return guid;
|
||||
},
|
||||
|
||||
get _guidStm() {
|
||||
return this._getStmt(
|
||||
"SELECT guid " +
|
||||
"FROM moz_places " +
|
||||
"WHERE url_hash = hash(:page_url) AND url = :page_url");
|
||||
},
|
||||
_guidCols: ["guid"],
|
||||
|
||||
GUIDForUri: function GUIDForUri(uri, create) {
|
||||
let stm = this._guidStm;
|
||||
stm.params.page_url = uri.spec ? uri.spec : uri;
|
||||
|
||||
// Use the existing GUID if it exists
|
||||
let result = Async.querySpinningly(stm, this._guidCols)[0];
|
||||
if (result && result.guid)
|
||||
return result.guid;
|
||||
|
||||
// Give the uri a GUID if it doesn't have one
|
||||
if (create)
|
||||
return this.setGUID(uri);
|
||||
},
|
||||
|
||||
get _visitStm() {
|
||||
return this._getStmt(`/* do not warn (bug 599936) */
|
||||
SELECT visit_type type, visit_date date
|
||||
FROM moz_historyvisits
|
||||
JOIN moz_places h ON h.id = place_id
|
||||
WHERE url_hash = hash(:url) AND url = :url
|
||||
ORDER BY date DESC LIMIT 20`);
|
||||
},
|
||||
_visitCols: ["date", "type"],
|
||||
|
||||
get _urlStm() {
|
||||
return this._getStmt(
|
||||
"SELECT url, title, frecency " +
|
||||
"FROM moz_places " +
|
||||
"WHERE guid = :guid");
|
||||
},
|
||||
_urlCols: ["url", "title", "frecency"],
|
||||
|
||||
get _allUrlStm() {
|
||||
return this._getStmt(
|
||||
"SELECT url " +
|
||||
"FROM moz_places " +
|
||||
"WHERE last_visit_date > :cutoff_date " +
|
||||
"ORDER BY frecency DESC " +
|
||||
"LIMIT :max_results");
|
||||
},
|
||||
_allUrlCols: ["url"],
|
||||
|
||||
// See bug 320831 for why we use SQL here
|
||||
_getVisits: function HistStore__getVisits(uri) {
|
||||
this._visitStm.params.url = uri;
|
||||
return Async.querySpinningly(this._visitStm, this._visitCols);
|
||||
},
|
||||
|
||||
// See bug 468732 for why we use SQL here
|
||||
_findURLByGUID: function HistStore__findURLByGUID(guid) {
|
||||
this._urlStm.params.guid = guid;
|
||||
return Async.querySpinningly(this._urlStm, this._urlCols)[0];
|
||||
},
|
||||
|
||||
changeItemID: function HStore_changeItemID(oldID, newID) {
|
||||
this.setGUID(this._findURLByGUID(oldID).url, newID);
|
||||
},
|
||||
|
||||
|
||||
getAllIDs: function HistStore_getAllIDs() {
|
||||
// Only get places visited within the last 30 days (30*24*60*60*1000ms)
|
||||
this._allUrlStm.params.cutoff_date = (Date.now() - 2592000000) * 1000;
|
||||
this._allUrlStm.params.max_results = MAX_HISTORY_UPLOAD;
|
||||
|
||||
let urls = Async.querySpinningly(this._allUrlStm, this._allUrlCols);
|
||||
let self = this;
|
||||
return urls.reduce(function(ids, item) {
|
||||
ids[self.GUIDForUri(item.url, true)] = item.url;
|
||||
return ids;
|
||||
}, {});
|
||||
},
|
||||
|
||||
applyIncomingBatch: function applyIncomingBatch(records) {
|
||||
let failed = [];
|
||||
|
||||
// Convert incoming records to mozIPlaceInfo objects. Some records can be
|
||||
// ignored or handled directly, so we're rewriting the array in-place.
|
||||
let i, k;
|
||||
for (i = 0, k = 0; i < records.length; i++) {
|
||||
let record = records[k] = records[i];
|
||||
let shouldApply;
|
||||
|
||||
// This is still synchronous I/O for now.
|
||||
try {
|
||||
if (record.deleted) {
|
||||
// Consider using nsIBrowserHistory::removePages() here.
|
||||
this.remove(record);
|
||||
// No further processing needed. Remove it from the list.
|
||||
shouldApply = false;
|
||||
} else {
|
||||
shouldApply = this._recordToPlaceInfo(record);
|
||||
}
|
||||
} catch (ex) {
|
||||
if (Async.isShutdownException(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
failed.push(record.id);
|
||||
shouldApply = false;
|
||||
}
|
||||
|
||||
if (shouldApply) {
|
||||
k += 1;
|
||||
}
|
||||
}
|
||||
records.length = k; // truncate array
|
||||
|
||||
// Nothing to do.
|
||||
if (!records.length) {
|
||||
return failed;
|
||||
}
|
||||
|
||||
let updatePlacesCallback = {
|
||||
handleResult: function handleResult() {},
|
||||
handleError: function handleError(resultCode, placeInfo) {
|
||||
failed.push(placeInfo.guid);
|
||||
},
|
||||
handleCompletion: Async.makeSyncCallback()
|
||||
};
|
||||
this._asyncHistory.updatePlaces(records, updatePlacesCallback);
|
||||
Async.waitForSyncCallback(updatePlacesCallback.handleCompletion);
|
||||
return failed;
|
||||
},
|
||||
|
||||
/**
|
||||
* Converts a Sync history record to a mozIPlaceInfo.
|
||||
*
|
||||
* Throws if an invalid record is encountered (invalid URI, etc.),
|
||||
* returns true if the record is to be applied, false otherwise
|
||||
* (no visits to add, etc.),
|
||||
*/
|
||||
_recordToPlaceInfo: function _recordToPlaceInfo(record) {
|
||||
// Sort out invalid URIs and ones Places just simply doesn't want.
|
||||
record.uri = Utils.makeURI(record.histUri);
|
||||
if (!record.uri) {
|
||||
this._log.warn("Attempted to process invalid URI, skipping.");
|
||||
throw "Invalid URI in record";
|
||||
}
|
||||
|
||||
if (!Utils.checkGUID(record.id)) {
|
||||
this._log.warn("Encountered record with invalid GUID: " + record.id);
|
||||
return false;
|
||||
}
|
||||
record.guid = record.id;
|
||||
|
||||
if (!PlacesUtils.history.canAddURI(record.uri)) {
|
||||
this._log.trace("Ignoring record " + record.id + " with URI "
|
||||
+ record.uri.spec + ": can't add this URI.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 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 curVisits = this._getVisits(record.histUri);
|
||||
let i, k;
|
||||
for (i = 0; i < curVisits.length; i++) {
|
||||
curVisits[i] = curVisits[i].date + "," + curVisits[i].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") {
|
||||
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.
|
||||
visit.date = Math.round(visit.date);
|
||||
|
||||
if (curVisits.indexOf(visit.date + "," + visit.type) != -1) {
|
||||
// Visit is a dupe, don't increment 'k' so the element will be
|
||||
// overwritten.
|
||||
continue;
|
||||
}
|
||||
|
||||
visit.visitDate = visit.date;
|
||||
visit.transitionType = visit.type;
|
||||
k += 1;
|
||||
}
|
||||
record.visits.length = k; // truncate array
|
||||
|
||||
// No update if there aren't any visits to apply.
|
||||
// mozIAsyncHistory::updatePlaces() 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 false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
remove: function HistStore_remove(record) {
|
||||
let page = this._findURLByGUID(record.id);
|
||||
if (page == null) {
|
||||
this._log.debug("Page already removed: " + record.id);
|
||||
return;
|
||||
}
|
||||
|
||||
let uri = Utils.makeURI(page.url);
|
||||
PlacesUtils.history.removePage(uri);
|
||||
this._log.trace("Removed page: " + [record.id, page.url, page.title]);
|
||||
},
|
||||
|
||||
itemExists: function HistStore_itemExists(id) {
|
||||
return !!this._findURLByGUID(id);
|
||||
},
|
||||
|
||||
createRecord: function createRecord(id, collection) {
|
||||
let foo = this._findURLByGUID(id);
|
||||
let record = new HistoryRec(collection, id);
|
||||
if (foo) {
|
||||
record.histUri = foo.url;
|
||||
record.title = foo.title;
|
||||
record.sortindex = foo.frecency;
|
||||
record.visits = this._getVisits(record.histUri);
|
||||
} else {
|
||||
record.deleted = true;
|
||||
}
|
||||
|
||||
return record;
|
||||
},
|
||||
|
||||
wipe: function HistStore_wipe() {
|
||||
let cb = Async.makeSyncCallback();
|
||||
PlacesUtils.history.clear().then(result => {cb(null, result)}, err => {cb(err)});
|
||||
return Async.waitForSyncCallback(cb);
|
||||
}
|
||||
};
|
||||
|
||||
function HistoryTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
}
|
||||
HistoryTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
startTracking: function() {
|
||||
this._log.info("Adding Places observer.");
|
||||
PlacesUtils.history.addObserver(this, true);
|
||||
},
|
||||
|
||||
stopTracking: function() {
|
||||
this._log.info("Removing Places observer.");
|
||||
PlacesUtils.history.removeObserver(this);
|
||||
},
|
||||
|
||||
QueryInterface: XPCOMUtils.generateQI([
|
||||
Ci.nsINavHistoryObserver,
|
||||
Ci.nsISupportsWeakReference
|
||||
]),
|
||||
|
||||
onDeleteAffectsGUID: function (uri, guid, reason, source, increment) {
|
||||
if (this.ignoreAll || reason == Ci.nsINavHistoryObserver.REASON_EXPIRED) {
|
||||
return;
|
||||
}
|
||||
this._log.trace(source + ": " + uri.spec + ", reason " + reason);
|
||||
if (this.addChangedID(guid)) {
|
||||
this.score += increment;
|
||||
}
|
||||
},
|
||||
|
||||
onDeleteVisits: function (uri, visitTime, guid, reason) {
|
||||
this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteVisits", SCORE_INCREMENT_SMALL);
|
||||
},
|
||||
|
||||
onDeleteURI: function (uri, guid, reason) {
|
||||
this.onDeleteAffectsGUID(uri, guid, reason, "onDeleteURI", SCORE_INCREMENT_XLARGE);
|
||||
},
|
||||
|
||||
onVisit: function (uri, vid, time, session, referrer, trans, guid) {
|
||||
if (this.ignoreAll) {
|
||||
this._log.trace("ignoreAll: ignoring visit for " + guid);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.trace("onVisit: " + uri.spec);
|
||||
if (this.addChangedID(guid)) {
|
||||
this.score += SCORE_INCREMENT_SMALL;
|
||||
}
|
||||
},
|
||||
|
||||
onClearHistory: function () {
|
||||
this._log.trace("onClearHistory");
|
||||
// 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;
|
||||
},
|
||||
|
||||
onBeginUpdateBatch: function () {},
|
||||
onEndUpdateBatch: function () {},
|
||||
onPageChanged: function () {},
|
||||
onTitleChanged: function () {},
|
||||
onBeforeDeleteURI: function () {},
|
||||
};
|
||||
371
services/sync/modules/engines/passwords.js
Normal file
371
services/sync/modules/engines/passwords.js
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ['PasswordEngine', 'LoginRec', 'PasswordValidator'];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/collection_validator.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
|
||||
this.LoginRec = function LoginRec(collection, id) {
|
||||
CryptoWrapper.call(this, collection, id);
|
||||
}
|
||||
LoginRec.prototype = {
|
||||
__proto__: CryptoWrapper.prototype,
|
||||
_logName: "Sync.Record.Login",
|
||||
};
|
||||
|
||||
Utils.deferGetSet(LoginRec, "cleartext", [
|
||||
"hostname", "formSubmitURL",
|
||||
"httpRealm", "username", "password", "usernameField", "passwordField",
|
||||
"timeCreated", "timePasswordChanged",
|
||||
]);
|
||||
|
||||
|
||||
this.PasswordEngine = function PasswordEngine(service) {
|
||||
SyncEngine.call(this, "Passwords", service);
|
||||
}
|
||||
PasswordEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: PasswordStore,
|
||||
_trackerObj: PasswordTracker,
|
||||
_recordObj: LoginRec,
|
||||
|
||||
applyIncomingBatchSize: PASSWORDS_STORE_BATCH_SIZE,
|
||||
|
||||
syncPriority: 2,
|
||||
|
||||
_syncFinish: function () {
|
||||
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(Components.interfaces.nsILoginMetaInfo).guid);
|
||||
}
|
||||
}
|
||||
if (ids.length) {
|
||||
let coll = new Collection(this.engineURL, null, this.service);
|
||||
coll.ids = ids;
|
||||
let ret = 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
_findDupe: function (item) {
|
||||
let login = this._store._nsLoginInfoFromRecord(item);
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
let logins = Services.logins.findLogins({}, login.hostname, login.formSubmitURL, login.httpRealm);
|
||||
|
||||
this._store._sleep(0); // Yield back to main thread after synchronous operation.
|
||||
|
||||
// Look for existing logins that match the hostname, but ignore the password.
|
||||
for (let local of logins) {
|
||||
if (login.matches(local, true) && local instanceof Ci.nsILoginMetaInfo) {
|
||||
return local.guid;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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: function () {
|
||||
return Cc["@mozilla.org/hash-property-bag;1"].createInstance(Ci.nsIWritablePropertyBag2);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return an instance of nsILoginInfo (and, implicitly, nsILoginMetaInfo).
|
||||
*/
|
||||
_nsLoginInfoFromRecord: function (record) {
|
||||
function nullUndefined(x) {
|
||||
return (x == undefined) ? 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),
|
||||
record.username,
|
||||
record.password,
|
||||
record.usernameField,
|
||||
record.passwordField);
|
||||
|
||||
info.QueryInterface(Ci.nsILoginMetaInfo);
|
||||
info.guid = record.id;
|
||||
if (record.timeCreated) {
|
||||
info.timeCreated = record.timeCreated;
|
||||
}
|
||||
if (record.timePasswordChanged) {
|
||||
info.timePasswordChanged = record.timePasswordChanged;
|
||||
}
|
||||
|
||||
return info;
|
||||
},
|
||||
|
||||
_getLoginFromGUID: function (id) {
|
||||
let prop = this._newPropertyBag();
|
||||
prop.setPropertyAsAUTF8String("guid", id);
|
||||
|
||||
let logins = Services.logins.searchLogins({}, prop);
|
||||
this._sleep(0); // 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;
|
||||
},
|
||||
|
||||
getAllIDs: function () {
|
||||
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.hostname)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items[metaInfo.guid] = metaInfo;
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
|
||||
changeItemID: function (oldID, newID) {
|
||||
this._log.trace("Changing item ID: " + oldID + " to " + newID);
|
||||
|
||||
let oldLogin = this._getLoginFromGUID(oldID);
|
||||
if (!oldLogin) {
|
||||
this._log.trace("Can't change item ID: item doesn't exist");
|
||||
return;
|
||||
}
|
||||
if (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);
|
||||
},
|
||||
|
||||
itemExists: function (id) {
|
||||
return !!this._getLoginFromGUID(id);
|
||||
},
|
||||
|
||||
createRecord: function (id, collection) {
|
||||
let record = new LoginRec(collection, id);
|
||||
let login = this._getLoginFromGUID(id);
|
||||
|
||||
if (!login) {
|
||||
record.deleted = true;
|
||||
return record;
|
||||
}
|
||||
|
||||
record.hostname = login.hostname;
|
||||
record.formSubmitURL = login.formSubmitURL;
|
||||
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;
|
||||
},
|
||||
|
||||
create: function (record) {
|
||||
let login = this._nsLoginInfoFromRecord(record);
|
||||
if (!login) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.debug("Adding login for " + record.hostname);
|
||||
this._log.trace("httpRealm: " + JSON.stringify(login.httpRealm) + "; " +
|
||||
"formSubmitURL: " + JSON.stringify(login.formSubmitURL));
|
||||
try {
|
||||
Services.logins.addLogin(login);
|
||||
} catch(ex) {
|
||||
this._log.debug(`Adding record ${record.id} resulted in exception`, ex);
|
||||
}
|
||||
},
|
||||
|
||||
remove: function (record) {
|
||||
this._log.trace("Removing login " + record.id);
|
||||
|
||||
let loginItem = this._getLoginFromGUID(record.id);
|
||||
if (!loginItem) {
|
||||
this._log.trace("Asked to remove record that doesn't exist, ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
Services.logins.removeLogin(loginItem);
|
||||
},
|
||||
|
||||
update: function (record) {
|
||||
let loginItem = this._getLoginFromGUID(record.id);
|
||||
if (!loginItem) {
|
||||
this._log.debug("Skipping update for unknown item: " + record.hostname);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.debug("Updating " + record.hostname);
|
||||
let newinfo = this._nsLoginInfoFromRecord(record);
|
||||
if (!newinfo) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
Services.logins.modifyLogin(loginItem, newinfo);
|
||||
} catch(ex) {
|
||||
this._log.debug(`Modifying record ${record.id} resulted in exception; not modifying`, ex);
|
||||
}
|
||||
},
|
||||
|
||||
wipe: function () {
|
||||
Services.logins.removeAllLogins();
|
||||
},
|
||||
};
|
||||
|
||||
function PasswordTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
Svc.Obs.add("weave:engine:start-tracking", this);
|
||||
Svc.Obs.add("weave:engine:stop-tracking", this);
|
||||
}
|
||||
PasswordTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
startTracking: function () {
|
||||
Svc.Obs.add("passwordmgr-storage-changed", this);
|
||||
},
|
||||
|
||||
stopTracking: function () {
|
||||
Svc.Obs.remove("passwordmgr-storage-changed", this);
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
Tracker.prototype.observe.call(this, 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 = subject.QueryInterface(Ci.nsIArray).queryElementAt(1, Ci.nsILoginMetaInfo);
|
||||
// Fall through.
|
||||
case "addLogin":
|
||||
case "removeLogin":
|
||||
// Skip over Weave password/passphrase changes.
|
||||
subject.QueryInterface(Ci.nsILoginMetaInfo).QueryInterface(Ci.nsILoginInfo);
|
||||
if (Utils.getSyncCredentialsHosts().has(subject.hostname)) {
|
||||
break;
|
||||
}
|
||||
|
||||
this.score += SCORE_INCREMENT_XLARGE;
|
||||
this._log.trace(data + ": " + subject.guid);
|
||||
this.addChangedID(subject.guid);
|
||||
break;
|
||||
case "removeAllLogins":
|
||||
this._log.trace(data);
|
||||
this.score += SCORE_INCREMENT_XLARGE;
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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.hostname));
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
normalizeServerItem(item) {
|
||||
return Object.assign({ guid: item.id }, item);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
273
services/sync/modules/engines/prefs.js
Normal file
273
services/sync/modules/engines/prefs.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ['PrefsEngine', 'PrefRec'];
|
||||
|
||||
var Cc = Components.classes;
|
||||
var Ci = Components.interfaces;
|
||||
var Cu = Components.utils;
|
||||
|
||||
const PREF_SYNC_PREFS_PREFIX = "services.sync.prefs.sync.";
|
||||
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://gre/modules/LightweightThemeManager.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
|
||||
const PREFS_GUID = CommonUtils.encodeBase64URL(Services.appinfo.ID);
|
||||
|
||||
this.PrefRec = function PrefRec(collection, id) {
|
||||
CryptoWrapper.call(this, collection, id);
|
||||
}
|
||||
PrefRec.prototype = {
|
||||
__proto__: CryptoWrapper.prototype,
|
||||
_logName: "Sync.Record.Pref",
|
||||
};
|
||||
|
||||
Utils.deferGetSet(PrefRec, "cleartext", ["value"]);
|
||||
|
||||
|
||||
this.PrefsEngine = 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,
|
||||
|
||||
getChangedIDs: function () {
|
||||
// No need for a proper timestamp (no conflict resolution needed).
|
||||
let changedIDs = {};
|
||||
if (this._tracker.modified)
|
||||
changedIDs[PREFS_GUID] = 0;
|
||||
return changedIDs;
|
||||
},
|
||||
|
||||
_wipeClient: function () {
|
||||
SyncEngine.prototype._wipeClient.call(this);
|
||||
this.justWiped = true;
|
||||
},
|
||||
|
||||
_reconcile: function (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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
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: function () {
|
||||
let syncPrefs = Cc["@mozilla.org/preferences-service;1"]
|
||||
.getService(Ci.nsIPrefService)
|
||||
.getBranch(PREF_SYNC_PREFS_PREFIX)
|
||||
.getChildList("", {});
|
||||
// Also sync preferences that determine which prefs get synced.
|
||||
let controlPrefs = syncPrefs.map(pref => PREF_SYNC_PREFS_PREFIX + pref);
|
||||
return controlPrefs.concat(syncPrefs);
|
||||
},
|
||||
|
||||
_isSynced: function (pref) {
|
||||
return pref.startsWith(PREF_SYNC_PREFS_PREFIX) ||
|
||||
this._prefs.get(PREF_SYNC_PREFS_PREFIX + pref, false);
|
||||
},
|
||||
|
||||
_getAllPrefs: function () {
|
||||
let values = {};
|
||||
for (let pref of this._getSyncPrefs()) {
|
||||
if (this._isSynced(pref)) {
|
||||
// Missing and default prefs get the null value.
|
||||
values[pref] = this._prefs.isSet(pref) ? this._prefs.get(pref, null) : null;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
},
|
||||
|
||||
_updateLightWeightTheme (themeID) {
|
||||
let themeObject = null;
|
||||
if (themeID) {
|
||||
themeObject = LightweightThemeManager.getUsedTheme(themeID);
|
||||
}
|
||||
LightweightThemeManager.currentTheme = themeObject;
|
||||
},
|
||||
|
||||
_setAllPrefs: function (values) {
|
||||
let selectedThemeIDPref = "lightweightThemes.selectedThemeID";
|
||||
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) {
|
||||
if (!this._isSynced(pref)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = values[pref];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the lightweight theme manager if the selected theme has changed.
|
||||
if (selectedThemeIDBefore != selectedThemeIDAfter) {
|
||||
this._updateLightWeightTheme(selectedThemeIDAfter);
|
||||
}
|
||||
},
|
||||
|
||||
getAllIDs: function () {
|
||||
/* We store all prefs in just one WBO, with just one GUID */
|
||||
let allprefs = {};
|
||||
allprefs[PREFS_GUID] = true;
|
||||
return allprefs;
|
||||
},
|
||||
|
||||
changeItemID: function (oldID, newID) {
|
||||
this._log.trace("PrefStore GUID is constant!");
|
||||
},
|
||||
|
||||
itemExists: function (id) {
|
||||
return (id === PREFS_GUID);
|
||||
},
|
||||
|
||||
createRecord: function (id, collection) {
|
||||
let record = new PrefRec(collection, id);
|
||||
|
||||
if (id == PREFS_GUID) {
|
||||
record.value = this._getAllPrefs();
|
||||
} else {
|
||||
record.deleted = true;
|
||||
}
|
||||
|
||||
return record;
|
||||
},
|
||||
|
||||
create: function (record) {
|
||||
this._log.trace("Ignoring create request");
|
||||
},
|
||||
|
||||
remove: function (record) {
|
||||
this._log.trace("Ignoring remove request");
|
||||
},
|
||||
|
||||
update: function (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);
|
||||
},
|
||||
|
||||
wipe: function () {
|
||||
this._log.trace("Ignoring wipe request");
|
||||
}
|
||||
};
|
||||
|
||||
function PrefTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
Svc.Obs.add("profile-before-change", this);
|
||||
Svc.Obs.add("weave:engine:start-tracking", this);
|
||||
Svc.Obs.add("weave:engine:stop-tracking", this);
|
||||
}
|
||||
PrefTracker.prototype = {
|
||||
__proto__: Tracker.prototype,
|
||||
|
||||
get modified() {
|
||||
return Svc.Prefs.get("engine.prefs.modified", false);
|
||||
},
|
||||
set modified(value) {
|
||||
Svc.Prefs.set("engine.prefs.modified", value);
|
||||
},
|
||||
|
||||
loadChangedIDs: function loadChangedIDs() {
|
||||
// Don't read changed IDs from disk at start up.
|
||||
},
|
||||
|
||||
clearChangedIDs: function clearChangedIDs() {
|
||||
this.modified = false;
|
||||
},
|
||||
|
||||
__prefs: null,
|
||||
get _prefs() {
|
||||
if (!this.__prefs) {
|
||||
this.__prefs = new Preferences();
|
||||
}
|
||||
return this.__prefs;
|
||||
},
|
||||
|
||||
startTracking: function () {
|
||||
Services.prefs.addObserver("", this, false);
|
||||
},
|
||||
|
||||
stopTracking: function () {
|
||||
this.__prefs = null;
|
||||
Services.prefs.removeObserver("", this);
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
Tracker.prototype.observe.call(this, subject, topic, data);
|
||||
|
||||
switch (topic) {
|
||||
case "profile-before-change":
|
||||
this.stopTracking();
|
||||
break;
|
||||
case "nsPref:changed":
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
393
services/sync/modules/engines/tabs.js
Normal file
393
services/sync/modules/engines/tabs.js
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ["TabEngine", "TabSetRecord"];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu} = Components;
|
||||
|
||||
const TABS_TTL = 604800; // 7 days.
|
||||
const TAB_ENTRIES_LIMIT = 25; // How many URLs to include in tab history.
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/engines/clients.js");
|
||||
Cu.import("resource://services-sync/record.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "PrivateBrowsingUtils",
|
||||
"resource://gre/modules/PrivateBrowsingUtils.jsm");
|
||||
|
||||
this.TabSetRecord = 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"]);
|
||||
|
||||
|
||||
this.TabEngine = function TabEngine(service) {
|
||||
SyncEngine.call(this, "Tabs", service);
|
||||
|
||||
// Reset the client on every startup so that we fetch recent tabs.
|
||||
this._resetClient();
|
||||
}
|
||||
TabEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
_storeObj: TabStore,
|
||||
_trackerObj: TabTracker,
|
||||
_recordObj: TabSetRecord,
|
||||
// A flag to indicate if we have synced in this session. This is to help
|
||||
// consumers of remote tabs that may want to differentiate between "I've an
|
||||
// empty tab list as I haven't yet synced" vs "I've an empty tab list
|
||||
// as there really are no tabs"
|
||||
hasSyncedThisSession: false,
|
||||
|
||||
syncPriority: 3,
|
||||
|
||||
getChangedIDs: function () {
|
||||
// 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: function () {
|
||||
return this._store._remoteClients;
|
||||
},
|
||||
|
||||
getClientById: function (id) {
|
||||
return this._store._remoteClients[id];
|
||||
},
|
||||
|
||||
_resetClient: function () {
|
||||
SyncEngine.prototype._resetClient.call(this);
|
||||
this._store.wipe();
|
||||
this._tracker.modified = true;
|
||||
this.hasSyncedThisSession = false;
|
||||
},
|
||||
|
||||
removeClientData: function () {
|
||||
let url = this.engineURL + "/" + this.service.clientsEngine.localID;
|
||||
this.service.resource(url).delete();
|
||||
},
|
||||
|
||||
/**
|
||||
* Return a Set of open URLs.
|
||||
*/
|
||||
getOpenURLs: function () {
|
||||
let urls = new Set();
|
||||
for (let entry of this._store.getAllTabs()) {
|
||||
urls.add(entry.urlHistory[0]);
|
||||
}
|
||||
return urls;
|
||||
},
|
||||
|
||||
_reconcile: function (item) {
|
||||
// Skip our own record.
|
||||
// TabStore.itemExists tests only against our local client ID.
|
||||
if (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);
|
||||
},
|
||||
|
||||
_syncFinish() {
|
||||
this.hasSyncedThisSession = true;
|
||||
return SyncEngine.prototype._syncFinish.call(this);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function TabStore(name, engine) {
|
||||
Store.call(this, name, engine);
|
||||
}
|
||||
TabStore.prototype = {
|
||||
__proto__: Store.prototype,
|
||||
|
||||
itemExists: function (id) {
|
||||
return id == this.engine.service.clientsEngine.localID;
|
||||
},
|
||||
|
||||
getWindowEnumerator: function () {
|
||||
return Services.wm.getEnumerator("navigator:browser");
|
||||
},
|
||||
|
||||
shouldSkipWindow: function (win) {
|
||||
return win.closed ||
|
||||
PrivateBrowsingUtils.isWindowPrivate(win);
|
||||
},
|
||||
|
||||
getTabState: function (tab) {
|
||||
return JSON.parse(Svc.Session.getTabState(tab));
|
||||
},
|
||||
|
||||
getAllTabs: function (filter) {
|
||||
let filteredUrls = new RegExp(Svc.Prefs.get("engine.tabs.filteredUrls"), "i");
|
||||
|
||||
let allTabs = [];
|
||||
|
||||
let winEnum = this.getWindowEnumerator();
|
||||
while (winEnum.hasMoreElements()) {
|
||||
let win = winEnum.getNext();
|
||||
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 >= (MAX_UPLOAD_BYTES - 1000)) {
|
||||
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;
|
||||
}
|
||||
|
||||
allTabs.push({
|
||||
title: current.title || "",
|
||||
urlHistory: urls,
|
||||
icon: tabState.image ||
|
||||
(tabState.attributes && tabState.attributes.image) ||
|
||||
"",
|
||||
lastUsed: Math.floor((tabState.lastAccessed || 0) / 1000),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allTabs;
|
||||
},
|
||||
|
||||
createRecord: function (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 = this.getAllTabs(true).sort(function (a, b) {
|
||||
return b.lastUsed - a.lastUsed;
|
||||
});
|
||||
|
||||
// Figure out how many tabs we can pack into a payload. Starting with a 28KB
|
||||
// payload, we can estimate various overheads from encryption/JSON/WBO.
|
||||
let size = JSON.stringify(tabs).length;
|
||||
let origLength = tabs.length;
|
||||
const MAX_TAB_SIZE = 20000;
|
||||
if (size > MAX_TAB_SIZE) {
|
||||
// Estimate a little more than the direct fraction to maximize packing
|
||||
let cutoff = Math.ceil(tabs.length * MAX_TAB_SIZE / size);
|
||||
tabs = tabs.slice(0, cutoff + 1);
|
||||
|
||||
// Keep dropping off the last entry until the data fits
|
||||
while (JSON.stringify(tabs).length > MAX_TAB_SIZE)
|
||||
tabs.pop();
|
||||
}
|
||||
|
||||
this._log.trace("Created tabs " + tabs.length + " of " + origLength);
|
||||
tabs.forEach(function (tab) {
|
||||
this._log.trace("Wrapping tab: " + JSON.stringify(tab));
|
||||
}, this);
|
||||
|
||||
record.tabs = tabs;
|
||||
return record;
|
||||
},
|
||||
|
||||
getAllIDs: function () {
|
||||
// Don't report any tabs if all windows are in private browsing for
|
||||
// first syncs.
|
||||
let ids = {};
|
||||
let allWindowsArePrivate = false;
|
||||
let wins = Services.wm.getEnumerator("navigator:browser");
|
||||
while (wins.hasMoreElements()) {
|
||||
if (PrivateBrowsingUtils.isWindowPrivate(wins.getNext())) {
|
||||
// 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;
|
||||
},
|
||||
|
||||
wipe: function () {
|
||||
this._remoteClients = {};
|
||||
},
|
||||
|
||||
create: function (record) {
|
||||
this._log.debug("Adding remote tabs from " + record.clientName);
|
||||
this._remoteClients[record.id] = Object.assign({}, record.cleartext, {
|
||||
lastModified: record.modified
|
||||
});
|
||||
},
|
||||
|
||||
update: function (record) {
|
||||
this._log.trace("Ignoring tab updates as local ones win");
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
function TabTracker(name, engine) {
|
||||
Tracker.call(this, name, engine);
|
||||
Svc.Obs.add("weave:engine:start-tracking", this);
|
||||
Svc.Obs.add("weave:engine:stop-tracking", this);
|
||||
|
||||
// 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: XPCOMUtils.generateQI([Ci.nsIObserver]),
|
||||
|
||||
loadChangedIDs: function () {
|
||||
// Don't read changed IDs from disk at start up.
|
||||
},
|
||||
|
||||
clearChangedIDs: function () {
|
||||
this.modified = false;
|
||||
},
|
||||
|
||||
_topics: ["pageshow", "TabOpen", "TabClose", "TabSelect"],
|
||||
|
||||
_registerListenersForWindow: function (window) {
|
||||
this._log.trace("Registering tab listeners in window");
|
||||
for (let topic of this._topics) {
|
||||
window.addEventListener(topic, this.onTab, false);
|
||||
}
|
||||
window.addEventListener("unload", this._unregisterListeners, false);
|
||||
// If it's got a tab browser we can listen for things like navigation.
|
||||
if (window.gBrowser) {
|
||||
window.gBrowser.addProgressListener(this);
|
||||
}
|
||||
},
|
||||
|
||||
_unregisterListeners: function (event) {
|
||||
this._unregisterListenersForWindow(event.target);
|
||||
},
|
||||
|
||||
_unregisterListenersForWindow: function (window) {
|
||||
this._log.trace("Removing tab listeners in window");
|
||||
window.removeEventListener("unload", this._unregisterListeners, false);
|
||||
for (let topic of this._topics) {
|
||||
window.removeEventListener(topic, this.onTab, false);
|
||||
}
|
||||
if (window.gBrowser) {
|
||||
window.gBrowser.removeProgressListener(this);
|
||||
}
|
||||
},
|
||||
|
||||
startTracking: function () {
|
||||
Svc.Obs.add("domwindowopened", this);
|
||||
let wins = Services.wm.getEnumerator("navigator:browser");
|
||||
while (wins.hasMoreElements()) {
|
||||
this._registerListenersForWindow(wins.getNext());
|
||||
}
|
||||
},
|
||||
|
||||
stopTracking: function () {
|
||||
Svc.Obs.remove("domwindowopened", this);
|
||||
let wins = Services.wm.getEnumerator("navigator:browser");
|
||||
while (wins.hasMoreElements()) {
|
||||
this._unregisterListenersForWindow(wins.getNext());
|
||||
}
|
||||
},
|
||||
|
||||
observe: function (subject, topic, data) {
|
||||
Tracker.prototype.observe.call(this, subject, topic, data);
|
||||
|
||||
switch (topic) {
|
||||
case "domwindowopened":
|
||||
let onLoad = () => {
|
||||
subject.removeEventListener("load", onLoad, false);
|
||||
// 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, false);
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
onTab: function (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() < .1) {
|
||||
this.score += SCORE_INCREMENT_SMALL;
|
||||
}
|
||||
},
|
||||
|
||||
// web progress listeners.
|
||||
onLocationChange: function (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;
|
||||
}
|
||||
},
|
||||
};
|
||||
605
services/sync/modules/identity.js
Normal file
605
services/sync/modules/identity.js
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["IdentityManager"];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/Promise.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
|
||||
// Lazy import to prevent unnecessary load on startup.
|
||||
for (let symbol of ["BulkKeyBundle", "SyncKeyBundle"]) {
|
||||
XPCOMUtils.defineLazyModuleGetter(this, symbol,
|
||||
"resource://services-sync/keys.js",
|
||||
symbol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages "legacy" identity and authentication for Sync.
|
||||
* See browserid_identity for the Firefox Accounts based identity manager.
|
||||
*
|
||||
* The following entities are managed:
|
||||
*
|
||||
* account - The main Sync/services account. This is typically an email
|
||||
* address.
|
||||
* username - A normalized version of your account. This is what's
|
||||
* transmitted to the server.
|
||||
* basic password - UTF-8 password used for authenticating when using HTTP
|
||||
* basic authentication.
|
||||
* sync key - The main encryption key used by Sync.
|
||||
* sync key bundle - A representation of your sync key.
|
||||
*
|
||||
* When changes are made to entities that are stored in the password manager
|
||||
* (basic password, sync key), those changes are merely staged. To commit them
|
||||
* to the password manager, you'll need to call persistCredentials().
|
||||
*
|
||||
* This type also manages authenticating Sync's network requests. Sync's
|
||||
* network code calls into getRESTRequestAuthenticator and
|
||||
* getResourceAuthenticator (depending on the network layer being used). Each
|
||||
* returns a function which can be used to add authentication information to an
|
||||
* outgoing request.
|
||||
*
|
||||
* In theory, this type supports arbitrary identity and authentication
|
||||
* mechanisms. You can add support for them by monkeypatching the global
|
||||
* instance of this type. Specifically, you'll need to redefine the
|
||||
* aforementioned network code functions to do whatever your authentication
|
||||
* mechanism needs them to do. In addition, you may wish to install custom
|
||||
* functions to support your API. Although, that is certainly not required.
|
||||
* If you do monkeypatch, please be advised that Sync expects the core
|
||||
* attributes to have values. You will need to carry at least account and
|
||||
* username forward. If you do not wish to support one of the built-in
|
||||
* authentication mechanisms, you'll probably want to redefine currentAuthState
|
||||
* and any other function that involves the built-in functionality.
|
||||
*/
|
||||
this.IdentityManager = function IdentityManager() {
|
||||
this._log = Log.repository.getLogger("Sync.Identity");
|
||||
this._log.Level = Log.Level[Svc.Prefs.get("log.logger.identity")];
|
||||
|
||||
this._basicPassword = null;
|
||||
this._basicPasswordAllowLookup = true;
|
||||
this._basicPasswordUpdated = false;
|
||||
this._syncKey = null;
|
||||
this._syncKeyAllowLookup = true;
|
||||
this._syncKeySet = false;
|
||||
this._syncKeyBundle = null;
|
||||
}
|
||||
IdentityManager.prototype = {
|
||||
_log: null,
|
||||
|
||||
_basicPassword: null,
|
||||
_basicPasswordAllowLookup: true,
|
||||
_basicPasswordUpdated: false,
|
||||
|
||||
_syncKey: null,
|
||||
_syncKeyAllowLookup: true,
|
||||
_syncKeySet: false,
|
||||
|
||||
_syncKeyBundle: null,
|
||||
|
||||
/**
|
||||
* Initialize the identity provider.
|
||||
*/
|
||||
initialize: function() {
|
||||
// Nothing to do for this identity provider.
|
||||
},
|
||||
|
||||
finalize: function() {
|
||||
// Nothing to do for this identity provider.
|
||||
},
|
||||
|
||||
/**
|
||||
* Called whenever Service.logout() is called.
|
||||
*/
|
||||
logout: function() {
|
||||
// nothing to do for this identity provider.
|
||||
},
|
||||
|
||||
/**
|
||||
* Ensure the user is logged in. Returns a promise that resolves when
|
||||
* the user is logged in, or is rejected if the login attempt has failed.
|
||||
*/
|
||||
ensureLoggedIn: function() {
|
||||
// nothing to do for this identity provider
|
||||
return Promise.resolve();
|
||||
},
|
||||
|
||||
get account() {
|
||||
return Svc.Prefs.get("account", this.username);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sets the active account name.
|
||||
*
|
||||
* This should almost always be called in favor of setting username, as
|
||||
* username is derived from account.
|
||||
*
|
||||
* Changing the account name has the side-effect of wiping out stored
|
||||
* credentials. Keep in mind that persistCredentials() will need to be called
|
||||
* to flush the changes to disk.
|
||||
*
|
||||
* Set this value to null to clear out identity information.
|
||||
*/
|
||||
set account(value) {
|
||||
if (value) {
|
||||
value = value.toLowerCase();
|
||||
Svc.Prefs.set("account", value);
|
||||
} else {
|
||||
Svc.Prefs.reset("account");
|
||||
}
|
||||
|
||||
this.username = this.usernameFromAccount(value);
|
||||
},
|
||||
|
||||
get username() {
|
||||
return Svc.Prefs.get("username", null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the username value.
|
||||
*
|
||||
* Changing the username has the side-effect of wiping credentials.
|
||||
*/
|
||||
set username(value) {
|
||||
if (value) {
|
||||
value = value.toLowerCase();
|
||||
|
||||
if (value == this.username) {
|
||||
return;
|
||||
}
|
||||
|
||||
Svc.Prefs.set("username", value);
|
||||
} else {
|
||||
Svc.Prefs.reset("username");
|
||||
}
|
||||
|
||||
// If we change the username, we interpret this as a major change event
|
||||
// and wipe out the credentials.
|
||||
this._log.info("Username changed. Removing stored credentials.");
|
||||
this.resetCredentials();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets/Drops all credentials we hold for the current user.
|
||||
*/
|
||||
resetCredentials: function() {
|
||||
this.basicPassword = null;
|
||||
this.resetSyncKey();
|
||||
},
|
||||
|
||||
/**
|
||||
* Resets/Drops the sync key we hold for the current user.
|
||||
*/
|
||||
resetSyncKey: function() {
|
||||
this.syncKey = null;
|
||||
// syncKeyBundle cleared as a result of setting syncKey.
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtains the HTTP Basic auth password.
|
||||
*
|
||||
* Returns a string if set or null if it is not set.
|
||||
*/
|
||||
get basicPassword() {
|
||||
if (this._basicPasswordAllowLookup) {
|
||||
// We need a username to find the credentials.
|
||||
let username = this.username;
|
||||
if (!username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let login of this._getLogins(PWDMGR_PASSWORD_REALM)) {
|
||||
if (login.username.toLowerCase() == username) {
|
||||
// It should already be UTF-8 encoded, but we don't take any chances.
|
||||
this._basicPassword = Utils.encodeUTF8(login.password);
|
||||
}
|
||||
}
|
||||
|
||||
this._basicPasswordAllowLookup = false;
|
||||
}
|
||||
|
||||
return this._basicPassword;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the HTTP basic password to use.
|
||||
*
|
||||
* Changes will not persist unless persistSyncCredentials() is called.
|
||||
*/
|
||||
set basicPassword(value) {
|
||||
// Wiping out value.
|
||||
if (!value) {
|
||||
this._log.info("Basic password has no value. Removing.");
|
||||
this._basicPassword = null;
|
||||
this._basicPasswordUpdated = true;
|
||||
this._basicPasswordAllowLookup = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let username = this.username;
|
||||
if (!username) {
|
||||
throw new Error("basicPassword cannot be set before username.");
|
||||
}
|
||||
|
||||
this._log.info("Basic password being updated.");
|
||||
this._basicPassword = Utils.encodeUTF8(value);
|
||||
this._basicPasswordUpdated = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain the Sync Key.
|
||||
*
|
||||
* This returns a 26 character "friendly" Base32 encoded string on success or
|
||||
* null if no Sync Key could be found.
|
||||
*
|
||||
* If the Sync Key hasn't been set in this session, this will look in the
|
||||
* password manager for the sync key.
|
||||
*/
|
||||
get syncKey() {
|
||||
if (this._syncKeyAllowLookup) {
|
||||
let username = this.username;
|
||||
if (!username) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let login of this._getLogins(PWDMGR_PASSPHRASE_REALM)) {
|
||||
if (login.username.toLowerCase() == username) {
|
||||
this._syncKey = login.password;
|
||||
}
|
||||
}
|
||||
|
||||
this._syncKeyAllowLookup = false;
|
||||
}
|
||||
|
||||
return this._syncKey;
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the active Sync Key.
|
||||
*
|
||||
* If being set to null, the Sync Key and its derived SyncKeyBundle are
|
||||
* removed. However, the Sync Key won't be deleted from the password manager
|
||||
* until persistSyncCredentials() is called.
|
||||
*
|
||||
* If a value is provided, it should be a 26 or 32 character "friendly"
|
||||
* Base32 string for which Utils.isPassphrase() returns true.
|
||||
*
|
||||
* A side-effect of setting the Sync Key is that a SyncKeyBundle is
|
||||
* generated. For historical reasons, this will silently error out if the
|
||||
* value is not a proper Sync Key (!Utils.isPassphrase()). This should be
|
||||
* fixed in the future (once service.js is more sane) to throw if the passed
|
||||
* value is not valid.
|
||||
*/
|
||||
set syncKey(value) {
|
||||
if (!value) {
|
||||
this._log.info("Sync Key has no value. Deleting.");
|
||||
this._syncKey = null;
|
||||
this._syncKeyBundle = null;
|
||||
this._syncKeyUpdated = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.username) {
|
||||
throw new Error("syncKey cannot be set before username.");
|
||||
}
|
||||
|
||||
this._log.info("Sync Key being updated.");
|
||||
this._syncKey = value;
|
||||
|
||||
// Clear any cached Sync Key Bundle and regenerate it.
|
||||
this._syncKeyBundle = null;
|
||||
let bundle = this.syncKeyBundle;
|
||||
|
||||
this._syncKeyUpdated = true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain the active SyncKeyBundle.
|
||||
*
|
||||
* This returns a SyncKeyBundle representing a key pair derived from the
|
||||
* Sync Key on success. If no Sync Key is present or if the Sync Key is not
|
||||
* valid, this returns null.
|
||||
*
|
||||
* The SyncKeyBundle should be treated as immutable.
|
||||
*/
|
||||
get syncKeyBundle() {
|
||||
// We can't obtain a bundle without a username set.
|
||||
if (!this.username) {
|
||||
this._log.warn("Attempted to obtain Sync Key Bundle with no username set!");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this.syncKey) {
|
||||
this._log.warn("Attempted to obtain Sync Key Bundle with no Sync Key " +
|
||||
"set!");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!this._syncKeyBundle) {
|
||||
try {
|
||||
this._syncKeyBundle = new SyncKeyBundle(this.username, this.syncKey);
|
||||
} catch (ex) {
|
||||
this._log.warn("Failed to create sync bundle", ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return this._syncKeyBundle;
|
||||
},
|
||||
|
||||
/**
|
||||
* The current state of the auth credentials.
|
||||
*
|
||||
* This essentially validates that enough credentials are available to use
|
||||
* Sync.
|
||||
*/
|
||||
get currentAuthState() {
|
||||
if (!this.username) {
|
||||
return LOGIN_FAILED_NO_USERNAME;
|
||||
}
|
||||
|
||||
if (Utils.mpLocked()) {
|
||||
return STATUS_OK;
|
||||
}
|
||||
|
||||
if (!this.basicPassword) {
|
||||
return LOGIN_FAILED_NO_PASSWORD;
|
||||
}
|
||||
|
||||
if (!this.syncKey) {
|
||||
return LOGIN_FAILED_NO_PASSPHRASE;
|
||||
}
|
||||
|
||||
// If we have a Sync Key but no bundle, bundle creation failed, which
|
||||
// implies a bad Sync Key.
|
||||
if (!this.syncKeyBundle) {
|
||||
return LOGIN_FAILED_INVALID_PASSPHRASE;
|
||||
}
|
||||
|
||||
return STATUS_OK;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
unlockAndVerifyAuthState: function() {
|
||||
// Try to fetch the passphrase - this will prompt for MP unlock as a
|
||||
// side-effect...
|
||||
try {
|
||||
this.syncKey;
|
||||
} catch (ex) {
|
||||
this._log.debug("Fetching passphrase threw " + ex +
|
||||
"; assuming master password locked.");
|
||||
return Promise.resolve(MASTER_PASSWORD_LOCKED);
|
||||
}
|
||||
return Promise.resolve(STATUS_OK);
|
||||
},
|
||||
|
||||
/**
|
||||
* Persist credentials to password store.
|
||||
*
|
||||
* When credentials are updated, they are changed in memory only. This will
|
||||
* need to be called to save them to the underlying password store.
|
||||
*
|
||||
* If the password store is locked (e.g. if the master password hasn't been
|
||||
* entered), this could throw an exception.
|
||||
*/
|
||||
persistCredentials: function persistCredentials(force) {
|
||||
if (this._basicPasswordUpdated || force) {
|
||||
if (this._basicPassword) {
|
||||
this._setLogin(PWDMGR_PASSWORD_REALM, this.username,
|
||||
this._basicPassword);
|
||||
} else {
|
||||
for (let login of this._getLogins(PWDMGR_PASSWORD_REALM)) {
|
||||
Services.logins.removeLogin(login);
|
||||
}
|
||||
}
|
||||
|
||||
this._basicPasswordUpdated = false;
|
||||
}
|
||||
|
||||
if (this._syncKeyUpdated || force) {
|
||||
if (this._syncKey) {
|
||||
this._setLogin(PWDMGR_PASSPHRASE_REALM, this.username, this._syncKey);
|
||||
} else {
|
||||
for (let login of this._getLogins(PWDMGR_PASSPHRASE_REALM)) {
|
||||
Services.logins.removeLogin(login);
|
||||
}
|
||||
}
|
||||
|
||||
this._syncKeyUpdated = false;
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes the Sync Key from the system.
|
||||
*/
|
||||
deleteSyncKey: function deleteSyncKey() {
|
||||
this.syncKey = null;
|
||||
this.persistCredentials();
|
||||
},
|
||||
|
||||
hasBasicCredentials: function hasBasicCredentials() {
|
||||
// Because JavaScript.
|
||||
return this.username && this.basicPassword && true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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: function(service) {
|
||||
// Try and fetch the migration sentinel - it will end up in the recordManager
|
||||
// cache.
|
||||
try {
|
||||
service.recordManager.get(service.storageURL + "meta/fxa_credentials");
|
||||
} catch (ex) {
|
||||
if (Async.isShutdownException(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
this._log.warn("Failed to pre-fetch the migration sentinel", ex);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtains the array of basic logins from nsiPasswordManager.
|
||||
*/
|
||||
_getLogins: function _getLogins(realm) {
|
||||
return Services.logins.findLogins({}, PWDMGR_HOST, null, realm);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a login in the password manager.
|
||||
*
|
||||
* This has the side-effect of deleting any other logins for the specified
|
||||
* realm.
|
||||
*/
|
||||
_setLogin: function _setLogin(realm, username, password) {
|
||||
let exists = false;
|
||||
for (let login of this._getLogins(realm)) {
|
||||
if (login.username == username && login.password == password) {
|
||||
exists = true;
|
||||
} else {
|
||||
this._log.debug("Pruning old login for " + username + " from " + realm);
|
||||
Services.logins.removeLogin(login);
|
||||
}
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.debug("Updating saved password for " + username + " in " +
|
||||
realm);
|
||||
|
||||
let loginInfo = new Components.Constructor(
|
||||
"@mozilla.org/login-manager/loginInfo;1", Ci.nsILoginInfo, "init");
|
||||
let login = new loginInfo(PWDMGR_HOST, null, realm, username,
|
||||
password, "", "");
|
||||
Services.logins.addLogin(login);
|
||||
},
|
||||
|
||||
/**
|
||||
* Return credentials hosts for this identity only.
|
||||
*/
|
||||
_getSyncCredentialsHosts: function() {
|
||||
return Utils.getSyncCredentialsHostsLegacy();
|
||||
},
|
||||
|
||||
/**
|
||||
* Deletes Sync credentials from the password manager.
|
||||
*/
|
||||
deleteSyncCredentials: function deleteSyncCredentials() {
|
||||
for (let host of this._getSyncCredentialsHosts()) {
|
||||
let logins = Services.logins.findLogins({}, host, "", "");
|
||||
for (let login of logins) {
|
||||
Services.logins.removeLogin(login);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait until after store is updated in case it fails.
|
||||
this._basicPassword = null;
|
||||
this._basicPasswordAllowLookup = true;
|
||||
this._basicPasswordUpdated = false;
|
||||
|
||||
this._syncKey = null;
|
||||
// this._syncKeyBundle is nullified as part of _syncKey setter.
|
||||
this._syncKeyAllowLookup = true;
|
||||
this._syncKeyUpdated = false;
|
||||
},
|
||||
|
||||
usernameFromAccount: function usernameFromAccount(value) {
|
||||
// If we encounter characters not allowed by the API (as found for
|
||||
// instance in an email address), hash the value.
|
||||
if (value && value.match(/[^A-Z0-9._-]/i)) {
|
||||
return Utils.sha1Base32(value.toLowerCase()).toLowerCase();
|
||||
}
|
||||
|
||||
return value ? value.toLowerCase() : value;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a function to be used for adding auth to Resource HTTP requests.
|
||||
*/
|
||||
getResourceAuthenticator: function getResourceAuthenticator() {
|
||||
if (this.hasBasicCredentials()) {
|
||||
return this._onResourceRequestBasic.bind(this);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Helper method to return an authenticator for basic Resource requests.
|
||||
*/
|
||||
getBasicResourceAuthenticator:
|
||||
function getBasicResourceAuthenticator(username, password) {
|
||||
|
||||
return function basicAuthenticator(resource) {
|
||||
let value = "Basic " + btoa(username + ":" + password);
|
||||
return {headers: {authorization: value}};
|
||||
};
|
||||
},
|
||||
|
||||
_onResourceRequestBasic: function _onResourceRequestBasic(resource) {
|
||||
let value = "Basic " + btoa(this.username + ":" + this.basicPassword);
|
||||
return {headers: {authorization: value}};
|
||||
},
|
||||
|
||||
_onResourceRequestMAC: function _onResourceRequestMAC(resource, method) {
|
||||
// TODO Get identifier and key from somewhere.
|
||||
let identifier;
|
||||
let key;
|
||||
let result = Utils.computeHTTPMACSHA1(identifier, key, method, resource.uri);
|
||||
|
||||
return {headers: {authorization: result.header}};
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain a function to be used for adding auth to RESTRequest instances.
|
||||
*/
|
||||
getRESTRequestAuthenticator: function getRESTRequestAuthenticator() {
|
||||
if (this.hasBasicCredentials()) {
|
||||
return this.onRESTRequestBasic.bind(this);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
onRESTRequestBasic: function onRESTRequestBasic(request) {
|
||||
let up = this.username + ":" + this.basicPassword;
|
||||
request.setHeader("authorization", "Basic " + btoa(up));
|
||||
},
|
||||
|
||||
createClusterManager: function(service) {
|
||||
Cu.import("resource://services-sync/stages/cluster.js");
|
||||
return new ClusterManager(service);
|
||||
},
|
||||
|
||||
offerSyncOptions: function () {
|
||||
// Do nothing for Sync 1.1.
|
||||
return {accepted: true};
|
||||
},
|
||||
|
||||
// Tell Sync what the login status should be if it saw a 401 fetching
|
||||
// info/collections as part of login verification (typically immediately
|
||||
// after login.)
|
||||
// In our case it means an authoritative "password is incorrect".
|
||||
loginStatusFromVerification404() {
|
||||
return LOGIN_FAILED_LOGIN_REJECTED;
|
||||
}
|
||||
|
||||
};
|
||||
773
services/sync/modules/jpakeclient.js
Normal file
773
services/sync/modules/jpakeclient.js
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ["JPAKEClient", "SendCredentialsController"];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/rest.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
const REQUEST_TIMEOUT = 60; // 1 minute
|
||||
const KEYEXCHANGE_VERSION = 3;
|
||||
|
||||
const JPAKE_SIGNERID_SENDER = "sender";
|
||||
const JPAKE_SIGNERID_RECEIVER = "receiver";
|
||||
const JPAKE_LENGTH_SECRET = 8;
|
||||
const JPAKE_LENGTH_CLIENTID = 256;
|
||||
const JPAKE_VERIFY_VALUE = "0123456789ABCDEF";
|
||||
|
||||
|
||||
/**
|
||||
* Client to exchange encrypted data using the J-PAKE algorithm.
|
||||
* The exchange between two clients of this type looks like this:
|
||||
*
|
||||
*
|
||||
* Mobile Server Desktop
|
||||
* ===================================================================
|
||||
* |
|
||||
* retrieve channel <---------------|
|
||||
* generate random secret |
|
||||
* show PIN = secret + channel | ask user for PIN
|
||||
* upload Mobile's message 1 ------>|
|
||||
* |----> retrieve Mobile's message 1
|
||||
* |<----- upload Desktop's message 1
|
||||
* retrieve Desktop's message 1 <---|
|
||||
* upload Mobile's message 2 ------>|
|
||||
* |----> retrieve Mobile's message 2
|
||||
* | compute key
|
||||
* |<----- upload Desktop's message 2
|
||||
* retrieve Desktop's message 2 <---|
|
||||
* compute key |
|
||||
* encrypt known value ------------>|
|
||||
* |-------> retrieve encrypted value
|
||||
* | verify against local known value
|
||||
*
|
||||
* At this point Desktop knows whether the PIN was entered correctly.
|
||||
* If it wasn't, Desktop deletes the session. If it was, the account
|
||||
* setup can proceed. If Desktop doesn't yet have an account set up,
|
||||
* it will keep the channel open and let the user connect to or
|
||||
* create an account.
|
||||
*
|
||||
* | encrypt credentials
|
||||
* |<------------- upload credentials
|
||||
* retrieve credentials <-----------|
|
||||
* verify HMAC |
|
||||
* decrypt credentials |
|
||||
* delete session ----------------->|
|
||||
* start syncing |
|
||||
*
|
||||
*
|
||||
* Create a client object like so:
|
||||
*
|
||||
* let client = new JPAKEClient(controller);
|
||||
*
|
||||
* The 'controller' object must implement the following methods:
|
||||
*
|
||||
* displayPIN(pin) -- Called when a PIN has been generated and is ready to
|
||||
* be displayed to the user. Only called on the client where the pairing
|
||||
* was initiated with 'receiveNoPIN()'.
|
||||
*
|
||||
* onPairingStart() -- Called when the pairing has started and messages are
|
||||
* being sent back and forth over the channel. Only called on the client
|
||||
* where the pairing was initiated with 'receiveNoPIN()'.
|
||||
*
|
||||
* onPaired() -- Called when the device pairing has been established and
|
||||
* we're ready to send the credentials over. To do that, the controller
|
||||
* must call 'sendAndComplete()' while the channel is active.
|
||||
*
|
||||
* onComplete(data) -- Called after transfer has been completed. On
|
||||
* the sending side this is called with no parameter and as soon as the
|
||||
* data has been uploaded. This does not mean the receiving side has
|
||||
* actually retrieved them yet.
|
||||
*
|
||||
* onAbort(error) -- Called whenever an error is encountered. All errors lead
|
||||
* to an abort and the process has to be started again on both sides.
|
||||
*
|
||||
* To start the data transfer on the receiving side, call
|
||||
*
|
||||
* client.receiveNoPIN();
|
||||
*
|
||||
* This will allocate a new channel on the server, generate a PIN, have it
|
||||
* displayed and then do the transfer once the protocol has been completed
|
||||
* with the sending side.
|
||||
*
|
||||
* To initiate the transfer from the sending side, call
|
||||
*
|
||||
* client.pairWithPIN(pin, true);
|
||||
*
|
||||
* Once the pairing has been established, the controller's 'onPaired()' method
|
||||
* will be called. To then transmit the data, call
|
||||
*
|
||||
* client.sendAndComplete(data);
|
||||
*
|
||||
* To abort the process, call
|
||||
*
|
||||
* client.abort();
|
||||
*
|
||||
* Note that after completion or abort, the 'client' instance may not be reused.
|
||||
* You will have to create a new one in case you'd like to restart the process.
|
||||
*/
|
||||
this.JPAKEClient = function JPAKEClient(controller) {
|
||||
this.controller = controller;
|
||||
|
||||
this._log = Log.repository.getLogger("Sync.JPAKEClient");
|
||||
this._log.level = Log.Level[Svc.Prefs.get(
|
||||
"log.logger.service.jpakeclient", "Debug")];
|
||||
|
||||
this._serverURL = Svc.Prefs.get("jpake.serverURL");
|
||||
this._pollInterval = Svc.Prefs.get("jpake.pollInterval");
|
||||
this._maxTries = Svc.Prefs.get("jpake.maxTries");
|
||||
if (this._serverURL.slice(-1) != "/") {
|
||||
this._serverURL += "/";
|
||||
}
|
||||
|
||||
this._jpake = Cc["@mozilla.org/services-crypto/sync-jpake;1"]
|
||||
.createInstance(Ci.nsISyncJPAKE);
|
||||
|
||||
this._setClientID();
|
||||
}
|
||||
JPAKEClient.prototype = {
|
||||
|
||||
_chain: Async.chain,
|
||||
|
||||
/*
|
||||
* Public API
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initiate pairing and receive data without providing a PIN. The PIN will
|
||||
* be generated and passed on to the controller to be displayed to the user.
|
||||
*
|
||||
* This is typically called on mobile devices where typing is tedious.
|
||||
*/
|
||||
receiveNoPIN: function receiveNoPIN() {
|
||||
this._my_signerid = JPAKE_SIGNERID_RECEIVER;
|
||||
this._their_signerid = JPAKE_SIGNERID_SENDER;
|
||||
|
||||
this._secret = this._createSecret();
|
||||
|
||||
// Allow a large number of tries first while we wait for the PIN
|
||||
// to be entered on the other device.
|
||||
this._maxTries = Svc.Prefs.get("jpake.firstMsgMaxTries");
|
||||
this._chain(this._getChannel,
|
||||
this._computeStepOne,
|
||||
this._putStep,
|
||||
this._getStep,
|
||||
function(callback) {
|
||||
// We fetched the first response from the other client.
|
||||
// Notify controller of the pairing starting.
|
||||
Utils.nextTick(this.controller.onPairingStart,
|
||||
this.controller);
|
||||
|
||||
// Now we can switch back to the smaller timeout.
|
||||
this._maxTries = Svc.Prefs.get("jpake.maxTries");
|
||||
callback();
|
||||
},
|
||||
this._computeStepTwo,
|
||||
this._putStep,
|
||||
this._getStep,
|
||||
this._computeFinal,
|
||||
this._computeKeyVerification,
|
||||
this._putStep,
|
||||
function(callback) {
|
||||
// Allow longer time-out for the last message.
|
||||
this._maxTries = Svc.Prefs.get("jpake.lastMsgMaxTries");
|
||||
callback();
|
||||
},
|
||||
this._getStep,
|
||||
this._decryptData,
|
||||
this._complete)();
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiate pairing based on the PIN entered by the user.
|
||||
*
|
||||
* This is typically called on desktop devices where typing is easier than
|
||||
* on mobile.
|
||||
*
|
||||
* @param pin
|
||||
* 12 character string (in human-friendly base32) containing the PIN
|
||||
* entered by the user.
|
||||
* @param expectDelay
|
||||
* Flag that indicates that a significant delay between the pairing
|
||||
* and the sending should be expected. v2 and earlier of the protocol
|
||||
* did not allow for this and the pairing to a v2 or earlier client
|
||||
* will be aborted if this flag is 'true'.
|
||||
*/
|
||||
pairWithPIN: function pairWithPIN(pin, expectDelay) {
|
||||
this._my_signerid = JPAKE_SIGNERID_SENDER;
|
||||
this._their_signerid = JPAKE_SIGNERID_RECEIVER;
|
||||
|
||||
this._channel = pin.slice(JPAKE_LENGTH_SECRET);
|
||||
this._channelURL = this._serverURL + this._channel;
|
||||
this._secret = pin.slice(0, JPAKE_LENGTH_SECRET);
|
||||
|
||||
this._chain(this._computeStepOne,
|
||||
this._getStep,
|
||||
function (callback) {
|
||||
// Ensure that the other client can deal with a delay for
|
||||
// the last message if that's requested by the caller.
|
||||
if (!expectDelay) {
|
||||
return callback();
|
||||
}
|
||||
if (!this._incoming.version || this._incoming.version < 3) {
|
||||
return this.abort(JPAKE_ERROR_DELAYUNSUPPORTED);
|
||||
}
|
||||
return callback();
|
||||
},
|
||||
this._putStep,
|
||||
this._computeStepTwo,
|
||||
this._getStep,
|
||||
this._putStep,
|
||||
this._computeFinal,
|
||||
this._getStep,
|
||||
this._verifyPairing)();
|
||||
},
|
||||
|
||||
/**
|
||||
* Send data after a successful pairing.
|
||||
*
|
||||
* @param obj
|
||||
* Object containing the data to send. It will be serialized as JSON.
|
||||
*/
|
||||
sendAndComplete: function sendAndComplete(obj) {
|
||||
if (!this._paired || this._finished) {
|
||||
this._log.error("Can't send data, no active pairing!");
|
||||
throw "No active pairing!";
|
||||
}
|
||||
this._data = JSON.stringify(obj);
|
||||
this._chain(this._encryptData,
|
||||
this._putStep,
|
||||
this._complete)();
|
||||
},
|
||||
|
||||
/**
|
||||
* Abort the current pairing. The channel on the server will be deleted
|
||||
* if the abort wasn't due to a network or server error. The controller's
|
||||
* 'onAbort()' method is notified in all cases.
|
||||
*
|
||||
* @param error [optional]
|
||||
* Error constant indicating the reason for the abort. Defaults to
|
||||
* user abort.
|
||||
*/
|
||||
abort: function abort(error) {
|
||||
this._log.debug("Aborting...");
|
||||
this._finished = true;
|
||||
let self = this;
|
||||
|
||||
// Default to "user aborted".
|
||||
if (!error) {
|
||||
error = JPAKE_ERROR_USERABORT;
|
||||
}
|
||||
|
||||
if (error == JPAKE_ERROR_CHANNEL ||
|
||||
error == JPAKE_ERROR_NETWORK ||
|
||||
error == JPAKE_ERROR_NODATA) {
|
||||
Utils.nextTick(function() { this.controller.onAbort(error); }, this);
|
||||
} else {
|
||||
this._reportFailure(error, function() { self.controller.onAbort(error); });
|
||||
}
|
||||
},
|
||||
|
||||
/*
|
||||
* Utilities
|
||||
*/
|
||||
|
||||
_setClientID: function _setClientID() {
|
||||
let rng = Cc["@mozilla.org/security/random-generator;1"]
|
||||
.createInstance(Ci.nsIRandomGenerator);
|
||||
let bytes = rng.generateRandomBytes(JPAKE_LENGTH_CLIENTID / 2);
|
||||
this._clientID = bytes.map(byte => ("0" + byte.toString(16)).slice(-2)).join("");
|
||||
},
|
||||
|
||||
_createSecret: function _createSecret() {
|
||||
// 0-9a-z without 1,l,o,0
|
||||
const key = "23456789abcdefghijkmnpqrstuvwxyz";
|
||||
let rng = Cc["@mozilla.org/security/random-generator;1"]
|
||||
.createInstance(Ci.nsIRandomGenerator);
|
||||
let bytes = rng.generateRandomBytes(JPAKE_LENGTH_SECRET);
|
||||
return bytes.map(byte => key[Math.floor(byte * key.length / 256)]).join("");
|
||||
},
|
||||
|
||||
_newRequest: function _newRequest(uri) {
|
||||
let request = new RESTRequest(uri);
|
||||
request.setHeader("X-KeyExchange-Id", this._clientID);
|
||||
request.timeout = REQUEST_TIMEOUT;
|
||||
return request;
|
||||
},
|
||||
|
||||
/*
|
||||
* Steps of J-PAKE procedure
|
||||
*/
|
||||
|
||||
_getChannel: function _getChannel(callback) {
|
||||
this._log.trace("Requesting channel.");
|
||||
let request = this._newRequest(this._serverURL + "new_channel");
|
||||
request.get(Utils.bind2(this, function handleChannel(error) {
|
||||
if (this._finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this._log.error("Error acquiring channel ID. " + error);
|
||||
this.abort(JPAKE_ERROR_CHANNEL);
|
||||
return;
|
||||
}
|
||||
if (request.response.status != 200) {
|
||||
this._log.error("Error acquiring channel ID. Server responded with HTTP "
|
||||
+ request.response.status);
|
||||
this.abort(JPAKE_ERROR_CHANNEL);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._channel = JSON.parse(request.response.body);
|
||||
} catch (ex) {
|
||||
this._log.error("Server responded with invalid JSON.");
|
||||
this.abort(JPAKE_ERROR_CHANNEL);
|
||||
return;
|
||||
}
|
||||
this._log.debug("Using channel " + this._channel);
|
||||
this._channelURL = this._serverURL + this._channel;
|
||||
|
||||
// Don't block on UI code.
|
||||
let pin = this._secret + this._channel;
|
||||
Utils.nextTick(function() { this.controller.displayPIN(pin); }, this);
|
||||
callback();
|
||||
}));
|
||||
},
|
||||
|
||||
// Generic handler for uploading data.
|
||||
_putStep: function _putStep(callback) {
|
||||
this._log.trace("Uploading message " + this._outgoing.type);
|
||||
let request = this._newRequest(this._channelURL);
|
||||
if (this._their_etag) {
|
||||
request.setHeader("If-Match", this._their_etag);
|
||||
} else {
|
||||
request.setHeader("If-None-Match", "*");
|
||||
}
|
||||
request.put(this._outgoing, Utils.bind2(this, function (error) {
|
||||
if (this._finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this._log.error("Error uploading data. " + error);
|
||||
this.abort(JPAKE_ERROR_NETWORK);
|
||||
return;
|
||||
}
|
||||
if (request.response.status != 200) {
|
||||
this._log.error("Could not upload data. Server responded with HTTP "
|
||||
+ request.response.status);
|
||||
this.abort(JPAKE_ERROR_SERVER);
|
||||
return;
|
||||
}
|
||||
// There's no point in returning early here since the next step will
|
||||
// always be a GET so let's pause for twice the poll interval.
|
||||
this._my_etag = request.response.headers["etag"];
|
||||
Utils.namedTimer(function () { callback(); }, this._pollInterval * 2,
|
||||
this, "_pollTimer");
|
||||
}));
|
||||
},
|
||||
|
||||
// Generic handler for polling for and retrieving data.
|
||||
_pollTries: 0,
|
||||
_getStep: function _getStep(callback) {
|
||||
this._log.trace("Retrieving next message.");
|
||||
let request = this._newRequest(this._channelURL);
|
||||
if (this._my_etag) {
|
||||
request.setHeader("If-None-Match", this._my_etag);
|
||||
}
|
||||
|
||||
request.get(Utils.bind2(this, function (error) {
|
||||
if (this._finished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
this._log.error("Error fetching data. " + error);
|
||||
this.abort(JPAKE_ERROR_NETWORK);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.response.status == 304) {
|
||||
this._log.trace("Channel hasn't been updated yet. Will try again later.");
|
||||
if (this._pollTries >= this._maxTries) {
|
||||
this._log.error("Tried for " + this._pollTries + " times, aborting.");
|
||||
this.abort(JPAKE_ERROR_TIMEOUT);
|
||||
return;
|
||||
}
|
||||
this._pollTries += 1;
|
||||
Utils.namedTimer(function() { this._getStep(callback); },
|
||||
this._pollInterval, this, "_pollTimer");
|
||||
return;
|
||||
}
|
||||
this._pollTries = 0;
|
||||
|
||||
if (request.response.status == 404) {
|
||||
this._log.error("No data found in the channel.");
|
||||
this.abort(JPAKE_ERROR_NODATA);
|
||||
return;
|
||||
}
|
||||
if (request.response.status != 200) {
|
||||
this._log.error("Could not retrieve data. Server responded with HTTP "
|
||||
+ request.response.status);
|
||||
this.abort(JPAKE_ERROR_SERVER);
|
||||
return;
|
||||
}
|
||||
|
||||
this._their_etag = request.response.headers["etag"];
|
||||
if (!this._their_etag) {
|
||||
this._log.error("Server did not supply ETag for message: "
|
||||
+ request.response.body);
|
||||
this.abort(JPAKE_ERROR_SERVER);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._incoming = JSON.parse(request.response.body);
|
||||
} catch (ex) {
|
||||
this._log.error("Server responded with invalid JSON.");
|
||||
this.abort(JPAKE_ERROR_INVALID);
|
||||
return;
|
||||
}
|
||||
this._log.trace("Fetched message " + this._incoming.type);
|
||||
callback();
|
||||
}));
|
||||
},
|
||||
|
||||
_reportFailure: function _reportFailure(reason, callback) {
|
||||
this._log.debug("Reporting failure to server.");
|
||||
let request = this._newRequest(this._serverURL + "report");
|
||||
request.setHeader("X-KeyExchange-Cid", this._channel);
|
||||
request.setHeader("X-KeyExchange-Log", reason);
|
||||
request.post("", Utils.bind2(this, function (error) {
|
||||
if (error) {
|
||||
this._log.warn("Report failed: " + error);
|
||||
} else if (request.response.status != 200) {
|
||||
this._log.warn("Report failed. Server responded with HTTP "
|
||||
+ request.response.status);
|
||||
}
|
||||
|
||||
// Do not block on errors, we're done or aborted by now anyway.
|
||||
callback();
|
||||
}));
|
||||
},
|
||||
|
||||
_computeStepOne: function _computeStepOne(callback) {
|
||||
this._log.trace("Computing round 1.");
|
||||
let gx1 = {};
|
||||
let gv1 = {};
|
||||
let r1 = {};
|
||||
let gx2 = {};
|
||||
let gv2 = {};
|
||||
let r2 = {};
|
||||
try {
|
||||
this._jpake.round1(this._my_signerid, gx1, gv1, r1, gx2, gv2, r2);
|
||||
} catch (ex) {
|
||||
this._log.error("JPAKE round 1 threw: " + ex);
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
let one = {gx1: gx1.value,
|
||||
gx2: gx2.value,
|
||||
zkp_x1: {gr: gv1.value, b: r1.value, id: this._my_signerid},
|
||||
zkp_x2: {gr: gv2.value, b: r2.value, id: this._my_signerid}};
|
||||
this._outgoing = {type: this._my_signerid + "1",
|
||||
version: KEYEXCHANGE_VERSION,
|
||||
payload: one};
|
||||
this._log.trace("Generated message " + this._outgoing.type);
|
||||
callback();
|
||||
},
|
||||
|
||||
_computeStepTwo: function _computeStepTwo(callback) {
|
||||
this._log.trace("Computing round 2.");
|
||||
if (this._incoming.type != this._their_signerid + "1") {
|
||||
this._log.error("Invalid round 1 message: "
|
||||
+ JSON.stringify(this._incoming));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let step1 = this._incoming.payload;
|
||||
if (!step1 || !step1.zkp_x1 || step1.zkp_x1.id != this._their_signerid
|
||||
|| !step1.zkp_x2 || step1.zkp_x2.id != this._their_signerid) {
|
||||
this._log.error("Invalid round 1 payload: " + JSON.stringify(step1));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let A = {};
|
||||
let gvA = {};
|
||||
let rA = {};
|
||||
|
||||
try {
|
||||
this._jpake.round2(this._their_signerid, this._secret,
|
||||
step1.gx1, step1.zkp_x1.gr, step1.zkp_x1.b,
|
||||
step1.gx2, step1.zkp_x2.gr, step1.zkp_x2.b,
|
||||
A, gvA, rA);
|
||||
} catch (ex) {
|
||||
this._log.error("JPAKE round 2 threw: " + ex);
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
let two = {A: A.value,
|
||||
zkp_A: {gr: gvA.value, b: rA.value, id: this._my_signerid}};
|
||||
this._outgoing = {type: this._my_signerid + "2",
|
||||
version: KEYEXCHANGE_VERSION,
|
||||
payload: two};
|
||||
this._log.trace("Generated message " + this._outgoing.type);
|
||||
callback();
|
||||
},
|
||||
|
||||
_computeFinal: function _computeFinal(callback) {
|
||||
if (this._incoming.type != this._their_signerid + "2") {
|
||||
this._log.error("Invalid round 2 message: "
|
||||
+ JSON.stringify(this._incoming));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let step2 = this._incoming.payload;
|
||||
if (!step2 || !step2.zkp_A || step2.zkp_A.id != this._their_signerid) {
|
||||
this._log.error("Invalid round 2 payload: " + JSON.stringify(step1));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
let aes256Key = {};
|
||||
let hmac256Key = {};
|
||||
|
||||
try {
|
||||
this._jpake.final(step2.A, step2.zkp_A.gr, step2.zkp_A.b, HMAC_INPUT,
|
||||
aes256Key, hmac256Key);
|
||||
} catch (ex) {
|
||||
this._log.error("JPAKE final round threw: " + ex);
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
|
||||
this._crypto_key = aes256Key.value;
|
||||
let hmac_key = Utils.makeHMACKey(Utils.safeAtoB(hmac256Key.value));
|
||||
this._hmac_hasher = Utils.makeHMACHasher(Ci.nsICryptoHMAC.SHA256, hmac_key);
|
||||
|
||||
callback();
|
||||
},
|
||||
|
||||
_computeKeyVerification: function _computeKeyVerification(callback) {
|
||||
this._log.trace("Encrypting key verification value.");
|
||||
let iv, ciphertext;
|
||||
try {
|
||||
iv = Svc.Crypto.generateRandomIV();
|
||||
ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
|
||||
this._crypto_key, iv);
|
||||
} catch (ex) {
|
||||
this._log.error("Failed to encrypt key verification value.");
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
this._outgoing = {type: this._my_signerid + "3",
|
||||
version: KEYEXCHANGE_VERSION,
|
||||
payload: {ciphertext: ciphertext, IV: iv}};
|
||||
this._log.trace("Generated message " + this._outgoing.type);
|
||||
callback();
|
||||
},
|
||||
|
||||
_verifyPairing: function _verifyPairing(callback) {
|
||||
this._log.trace("Verifying their key.");
|
||||
if (this._incoming.type != this._their_signerid + "3") {
|
||||
this._log.error("Invalid round 3 data: " +
|
||||
JSON.stringify(this._incoming));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
let step3 = this._incoming.payload;
|
||||
let ciphertext;
|
||||
try {
|
||||
ciphertext = Svc.Crypto.encrypt(JPAKE_VERIFY_VALUE,
|
||||
this._crypto_key, step3.IV);
|
||||
if (ciphertext != step3.ciphertext) {
|
||||
throw "Key mismatch!";
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log.error("Keys don't match!");
|
||||
this.abort(JPAKE_ERROR_KEYMISMATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.debug("Verified pairing!");
|
||||
this._paired = true;
|
||||
Utils.nextTick(function () { this.controller.onPaired(); }, this);
|
||||
callback();
|
||||
},
|
||||
|
||||
_encryptData: function _encryptData(callback) {
|
||||
this._log.trace("Encrypting data.");
|
||||
let iv, ciphertext, hmac;
|
||||
try {
|
||||
iv = Svc.Crypto.generateRandomIV();
|
||||
ciphertext = Svc.Crypto.encrypt(this._data, this._crypto_key, iv);
|
||||
hmac = Utils.bytesAsHex(Utils.digestUTF8(ciphertext, this._hmac_hasher));
|
||||
} catch (ex) {
|
||||
this._log.error("Failed to encrypt data.");
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
this._outgoing = {type: this._my_signerid + "3",
|
||||
version: KEYEXCHANGE_VERSION,
|
||||
payload: {ciphertext: ciphertext, IV: iv, hmac: hmac}};
|
||||
this._log.trace("Generated message " + this._outgoing.type);
|
||||
callback();
|
||||
},
|
||||
|
||||
_decryptData: function _decryptData(callback) {
|
||||
this._log.trace("Verifying their key.");
|
||||
if (this._incoming.type != this._their_signerid + "3") {
|
||||
this._log.error("Invalid round 3 data: "
|
||||
+ JSON.stringify(this._incoming));
|
||||
this.abort(JPAKE_ERROR_WRONGMESSAGE);
|
||||
return;
|
||||
}
|
||||
let step3 = this._incoming.payload;
|
||||
try {
|
||||
let hmac = Utils.bytesAsHex(
|
||||
Utils.digestUTF8(step3.ciphertext, this._hmac_hasher));
|
||||
if (hmac != step3.hmac) {
|
||||
throw "HMAC validation failed!";
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log.error("HMAC validation failed.");
|
||||
this.abort(JPAKE_ERROR_KEYMISMATCH);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.trace("Decrypting data.");
|
||||
let cleartext;
|
||||
try {
|
||||
cleartext = Svc.Crypto.decrypt(step3.ciphertext, this._crypto_key,
|
||||
step3.IV);
|
||||
} catch (ex) {
|
||||
this._log.error("Failed to decrypt data.");
|
||||
this.abort(JPAKE_ERROR_INTERNAL);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this._newData = JSON.parse(cleartext);
|
||||
} catch (ex) {
|
||||
this._log.error("Invalid data data: " + JSON.stringify(cleartext));
|
||||
this.abort(JPAKE_ERROR_INVALID);
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.trace("Decrypted data.");
|
||||
callback();
|
||||
},
|
||||
|
||||
_complete: function _complete() {
|
||||
this._log.debug("Exchange completed.");
|
||||
this._finished = true;
|
||||
Utils.nextTick(function () { this.controller.onComplete(this._newData); },
|
||||
this);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Send credentials over an active J-PAKE channel.
|
||||
*
|
||||
* This object is designed to take over as the JPAKEClient controller,
|
||||
* presumably replacing one that is UI-based which would either cause
|
||||
* DOM objects to leak or the JPAKEClient to be GC'ed when the DOM
|
||||
* context disappears. This object stays alive for the duration of the
|
||||
* transfer by being strong-ref'ed as an nsIObserver.
|
||||
*
|
||||
* Credentials are sent after the first sync has been completed
|
||||
* (successfully or not.)
|
||||
*
|
||||
* Usage:
|
||||
*
|
||||
* jpakeclient.controller = new SendCredentialsController(jpakeclient,
|
||||
* service);
|
||||
*
|
||||
*/
|
||||
this.SendCredentialsController =
|
||||
function SendCredentialsController(jpakeclient, service) {
|
||||
this._log = Log.repository.getLogger("Sync.SendCredentialsController");
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")];
|
||||
|
||||
this._log.trace("Loading.");
|
||||
this.jpakeclient = jpakeclient;
|
||||
this.service = service;
|
||||
|
||||
// Register ourselves as observers the first Sync finishing (either
|
||||
// successfully or unsuccessfully, we don't care) or for removing
|
||||
// this device's sync configuration, in case that happens while we
|
||||
// haven't finished the first sync yet.
|
||||
Services.obs.addObserver(this, "weave:service:sync:finish", false);
|
||||
Services.obs.addObserver(this, "weave:service:sync:error", false);
|
||||
Services.obs.addObserver(this, "weave:service:start-over", false);
|
||||
}
|
||||
SendCredentialsController.prototype = {
|
||||
|
||||
unload: function unload() {
|
||||
this._log.trace("Unloading.");
|
||||
try {
|
||||
Services.obs.removeObserver(this, "weave:service:sync:finish");
|
||||
Services.obs.removeObserver(this, "weave:service:sync:error");
|
||||
Services.obs.removeObserver(this, "weave:service:start-over");
|
||||
} catch (ex) {
|
||||
// Ignore.
|
||||
}
|
||||
},
|
||||
|
||||
observe: function observe(subject, topic, data) {
|
||||
switch (topic) {
|
||||
case "weave:service:sync:finish":
|
||||
case "weave:service:sync:error":
|
||||
Utils.nextTick(this.sendCredentials, this);
|
||||
break;
|
||||
case "weave:service:start-over":
|
||||
// This will call onAbort which will call unload().
|
||||
this.jpakeclient.abort();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
sendCredentials: function sendCredentials() {
|
||||
this._log.trace("Sending credentials.");
|
||||
let credentials = {account: this.service.identity.account,
|
||||
password: this.service.identity.basicPassword,
|
||||
synckey: this.service.identity.syncKey,
|
||||
serverURL: this.service.serverURL};
|
||||
this.jpakeclient.sendAndComplete(credentials);
|
||||
},
|
||||
|
||||
// JPAKEClient controller API
|
||||
|
||||
onComplete: function onComplete() {
|
||||
this._log.debug("Exchange was completed successfully!");
|
||||
this.unload();
|
||||
|
||||
// Schedule a Sync for soonish to fetch the data uploaded by the
|
||||
// device with which we just paired.
|
||||
this.service.scheduler.scheduleNextSync(this.service.scheduler.activeInterval);
|
||||
},
|
||||
|
||||
onAbort: function onAbort(error) {
|
||||
// It doesn't really matter why we aborted, but the channel is closed
|
||||
// for sure, so we won't be able to do anything with it.
|
||||
this._log.debug("Exchange was aborted with error: " + error);
|
||||
this.unload();
|
||||
},
|
||||
|
||||
// Irrelevant methods for this controller:
|
||||
displayPIN: function displayPIN() {},
|
||||
onPairingStart: function onPairingStart() {},
|
||||
onPaired: function onPaired() {},
|
||||
};
|
||||
214
services/sync/modules/keys.js
Normal file
214
services/sync/modules/keys.js
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"BulkKeyBundle",
|
||||
"SyncKeyBundle"
|
||||
];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.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.
|
||||
*/
|
||||
generateRandom: function generateRandom() {
|
||||
let generatedHMAC = Svc.Crypto.generateRandomKey();
|
||||
let generatedEncr = Svc.Crypto.generateRandomKey();
|
||||
this.keyPairB64 = [generatedEncr, generatedHMAC];
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a KeyBundle associated with a collection.
|
||||
*
|
||||
* This is just a KeyBundle with a collection attached.
|
||||
*/
|
||||
this.BulkKeyBundle = function BulkKeyBundle(collection) {
|
||||
let log = Log.repository.getLogger("Sync.BulkKeyBundle");
|
||||
log.info("BulkKeyBundle being created for " + collection);
|
||||
KeyBundle.call(this);
|
||||
|
||||
this._collection = collection;
|
||||
}
|
||||
|
||||
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 = Utils.safeAtoB(value[0]);
|
||||
this.hmacKey = Utils.safeAtoB(value[1]);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a key pair derived from a Sync Key via HKDF.
|
||||
*
|
||||
* Instances of this type should be considered immutable. You create an
|
||||
* instance by specifying the username and 26 character "friendly" Base32
|
||||
* encoded Sync Key. The Sync Key is derived at instance creation time.
|
||||
*
|
||||
* If the username or Sync Key is invalid, an Error will be thrown.
|
||||
*/
|
||||
this.SyncKeyBundle = function SyncKeyBundle(username, syncKey) {
|
||||
let log = Log.repository.getLogger("Sync.SyncKeyBundle");
|
||||
log.info("SyncKeyBundle being created.");
|
||||
KeyBundle.call(this);
|
||||
|
||||
this.generateFromKey(username, syncKey);
|
||||
}
|
||||
SyncKeyBundle.prototype = {
|
||||
__proto__: KeyBundle.prototype,
|
||||
|
||||
/*
|
||||
* If we've got a string, hash it into keys and store them.
|
||||
*/
|
||||
generateFromKey: function generateFromKey(username, syncKey) {
|
||||
if (!username || (typeof username != "string")) {
|
||||
throw new Error("Sync Key cannot be generated from non-string username.");
|
||||
}
|
||||
|
||||
if (!syncKey || (typeof syncKey != "string")) {
|
||||
throw new Error("Sync Key cannot be generated from non-string key.");
|
||||
}
|
||||
|
||||
if (!Utils.isPassphrase(syncKey)) {
|
||||
throw new Error("Provided key is not a passphrase, cannot derive Sync " +
|
||||
"Key Bundle.");
|
||||
}
|
||||
|
||||
// Expand the base32 Sync Key to an AES 256 and 256 bit HMAC key.
|
||||
let prk = Utils.decodeKeyBase32(syncKey);
|
||||
let info = HMAC_INPUT + username;
|
||||
let okm = Utils.hkdfExpand(prk, info, 32 * 2);
|
||||
this.encryptionKey = okm.slice(0, 32);
|
||||
this.hmacKey = okm.slice(32, 64);
|
||||
},
|
||||
};
|
||||
|
||||
30
services/sync/modules/main.js
Normal file
30
services/sync/modules/main.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ['Weave'];
|
||||
|
||||
this.Weave = {};
|
||||
Components.utils.import("resource://services-sync/constants.js", Weave);
|
||||
var lazies = {
|
||||
"jpakeclient.js": ["JPAKEClient", "SendCredentialsController"],
|
||||
"service.js": ["Service"],
|
||||
"status.js": ["Status"],
|
||||
"util.js": ['Utils', 'Svc']
|
||||
};
|
||||
|
||||
function lazyImport(module, dest, props) {
|
||||
function getter(prop) {
|
||||
return function() {
|
||||
let ns = {};
|
||||
Components.utils.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]);
|
||||
}
|
||||
983
services/sync/modules/policies.js
Normal file
983
services/sync/modules/policies.js
Normal file
|
|
@ -0,0 +1,983 @@
|
|||
/* 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.EXPORTED_SYMBOLS = [
|
||||
"ErrorHandler",
|
||||
"SyncScheduler",
|
||||
];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/logmanager.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "Status",
|
||||
"resource://services-sync/status.js");
|
||||
XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
|
||||
"resource://gre/modules/AddonManager.jsm");
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.SyncScheduler = function SyncScheduler(service) {
|
||||
this.service = service;
|
||||
this.init();
|
||||
}
|
||||
SyncScheduler.prototype = {
|
||||
_log: Log.repository.getLogger("Sync.SyncScheduler"),
|
||||
|
||||
_fatalLoginStatus: [LOGIN_FAILED_NO_USERNAME,
|
||||
LOGIN_FAILED_NO_PASSWORD,
|
||||
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.");
|
||||
|
||||
let service = Cc["@mozilla.org/weave/service;1"]
|
||||
.getService(Ci.nsISupports)
|
||||
.wrappedJSObject;
|
||||
|
||||
let part = service.fxAccountsEnabled ? "fxa" : "sync11";
|
||||
let prefSDInterval = "scheduler." + part + ".singleDeviceInterval";
|
||||
this.singleDeviceInterval = getThrottledIntervalPreference(prefSDInterval);
|
||||
|
||||
this.idleInterval = getThrottledIntervalPreference("scheduler.idleInterval");
|
||||
this.activeInterval = getThrottledIntervalPreference("scheduler.activeInterval");
|
||||
this.immediateInterval = getThrottledIntervalPreference("scheduler.immediateInterval");
|
||||
this.eolInterval = getThrottledIntervalPreference("scheduler.eolInterval");
|
||||
|
||||
// A user is non-idle on startup by default.
|
||||
this.idle = false;
|
||||
|
||||
this.hasIncomingItems = false;
|
||||
|
||||
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 syncInterval() {
|
||||
return Svc.Prefs.get("syncInterval", this.singleDeviceInterval);
|
||||
},
|
||||
set syncInterval(value) {
|
||||
Svc.Prefs.set("syncInterval", value);
|
||||
},
|
||||
|
||||
get syncThreshold() {
|
||||
return Svc.Prefs.get("syncThreshold", SINGLE_USER_THRESHOLD);
|
||||
},
|
||||
set syncThreshold(value) {
|
||||
Svc.Prefs.set("syncThreshold", value);
|
||||
},
|
||||
|
||||
get globalScore() {
|
||||
return Svc.Prefs.get("globalScore", 0);
|
||||
},
|
||||
set globalScore(value) {
|
||||
Svc.Prefs.set("globalScore", value);
|
||||
},
|
||||
|
||||
get numClients() {
|
||||
return Svc.Prefs.get("numClients", 0);
|
||||
},
|
||||
set numClients(value) {
|
||||
Svc.Prefs.set("numClients", value);
|
||||
},
|
||||
|
||||
init: function init() {
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")];
|
||||
this.setDefaults();
|
||||
Svc.Obs.add("weave:engine:score:updated", this);
|
||||
Svc.Obs.add("network:offline-status-changed", 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.Idle.addIdleObserver(this, Svc.Prefs.get("scheduler.idleTime"));
|
||||
}
|
||||
},
|
||||
|
||||
observe: function observe(subject, topic, data) {
|
||||
this._log.trace("Handling " + topic);
|
||||
switch(topic) {
|
||||
case "weave:engine:score:updated":
|
||||
if (Status.login == LOGIN_SUCCEEDED) {
|
||||
Utils.namedTimer(this.calculateScore, SCORE_UPDATE_DELAY, this,
|
||||
"_scoreTimer");
|
||||
}
|
||||
break;
|
||||
case "network:offline-status-changed":
|
||||
// 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;
|
||||
this._syncErrors = 0;
|
||||
if (Status.sync == NO_SYNC_NODE_FOUND) {
|
||||
this._log.trace("Scheduling a sync at interval NO_SYNC_NODE_FOUND.");
|
||||
sync_interval = NO_SYNC_NODE_INTERVAL;
|
||||
}
|
||||
this.scheduleNextSync(sync_interval);
|
||||
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.indexOf(Status.login) == -1) {
|
||||
// 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;
|
||||
}
|
||||
break;
|
||||
case "weave:service:setup-complete":
|
||||
Services.prefs.savePrefFile(null);
|
||||
Svc.Idle.addIdleObserver(this, Svc.Prefs.get("scheduler.idleTime"));
|
||||
Svc.Obs.add("wake_notification", this);
|
||||
break;
|
||||
case "weave:service:start-over":
|
||||
this.setDefaults();
|
||||
try {
|
||||
Svc.Idle.removeIdleObserver(this, Svc.Prefs.get("scheduler.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;
|
||||
Utils.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);
|
||||
}
|
||||
}, IDLE_OBSERVER_BACK_DELAY, this, "idleDebouncerTimer");
|
||||
break;
|
||||
case "wake_notification":
|
||||
this._log.debug("Woke from sleep.");
|
||||
Utils.nextTick(() => {
|
||||
// Trigger a sync if we have multiple clients. We give it 5 seconds
|
||||
// incase the network is still in the process of coming back up.
|
||||
if (this.numClients > 1) {
|
||||
this._log.debug("More than 1 client. Will sync in 5s.");
|
||||
this.scheduleNextSync(5000);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
adjustSyncInterval: function adjustSyncInterval() {
|
||||
if (Status.eol) {
|
||||
this._log.debug("Server status is EOL; using eolInterval.");
|
||||
this.syncInterval = this.eolInterval;
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
calculateScore: function calculateScore() {
|
||||
let engines = [this.service.clientsEngine].concat(this.service.engineManager.getEnabled());
|
||||
for (let i = 0;i < engines.length;i++) {
|
||||
this._log.trace(engines[i].name + ": score: " + engines[i].score);
|
||||
this.globalScore += engines[i].score;
|
||||
engines[i]._tracker.resetScore();
|
||||
}
|
||||
|
||||
this._log.trace("Global score updated: " + this.globalScore);
|
||||
this.checkSyncStatus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Process the locally stored clients list to figure out what mode to be in
|
||||
*/
|
||||
updateClientMode: function updateClientMode() {
|
||||
// Nothing to do if it's the same amount
|
||||
let numClients = this.service.clientsEngine.stats.numClients;
|
||||
if (this.numClients == numClients)
|
||||
return;
|
||||
|
||||
this._log.debug("Client count: " + this.numClients + " -> " + numClients);
|
||||
this.numClients = 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
this.scheduleNextSync(wait);
|
||||
},
|
||||
|
||||
/**
|
||||
* Call sync() if Master Password is not locked.
|
||||
*
|
||||
* Otherwise, reschedule a sync for later.
|
||||
*/
|
||||
syncIfMPUnlocked: function syncIfMPUnlocked() {
|
||||
// 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;
|
||||
}
|
||||
|
||||
Utils.nextTick(this.service.sync, this.service);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set a timer for the next sync
|
||||
*/
|
||||
scheduleNextSync: function scheduleNextSync(interval) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (this.nextSync != 0) {
|
||||
// There's already a sync scheduled. Don't reschedule if there's already
|
||||
// a timer scheduled for sooner than requested.
|
||||
let currentInterval = this.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.");
|
||||
this.syncIfMPUnlocked();
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.debug("Next sync in " + interval + " ms.");
|
||||
Utils.namedTimer(this.syncIfMPUnlocked, 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);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
Utils.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());
|
||||
}
|
||||
|
||||
// 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();
|
||||
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();
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
this.ErrorHandler = function ErrorHandler(service) {
|
||||
this.service = service;
|
||||
this.init();
|
||||
}
|
||||
ErrorHandler.prototype = {
|
||||
MINIMUM_ALERT_INTERVAL_MSEC: 604800000, // One week.
|
||||
|
||||
/**
|
||||
* Flag that turns on error reporting for all errors, incl. network errors.
|
||||
*/
|
||||
dontIgnoreErrors: false,
|
||||
|
||||
/**
|
||||
* Flag that indicates if we have already reported a prolonged failure.
|
||||
* Once set, we don't report it again, meaning this error is only reported
|
||||
* one per run.
|
||||
*/
|
||||
didReportProlongedError: false,
|
||||
|
||||
init: function 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);
|
||||
|
||||
this.initLogs();
|
||||
},
|
||||
|
||||
initLogs: function initLogs() {
|
||||
this._log = Log.repository.getLogger("Sync.ErrorHandler");
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")];
|
||||
|
||||
let root = Log.repository.getLogger("Sync");
|
||||
root.level = Log.Level[Svc.Prefs.get("log.rootLogger")];
|
||||
|
||||
let logs = ["Sync", "FirefoxAccounts", "Hawk", "Common.TokenServerClient",
|
||||
"Sync.SyncMigration", "browserwindow.syncui",
|
||||
"Services.Common.RESTRequest", "Services.Common.RESTRequest",
|
||||
"BookmarkSyncUtils"
|
||||
];
|
||||
|
||||
this._logManager = new LogManager(Svc.Prefs, logs, "sync");
|
||||
},
|
||||
|
||||
observe: function 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);
|
||||
Services.telemetry.getKeyedHistogramById("WEAVE_ENGINE_SYNC_ERRORS")
|
||||
.add(engine_name);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "weave:service:login:error":
|
||||
this._log.error("Sync encountered a login error");
|
||||
this.resetFileLog();
|
||||
|
||||
if (this.shouldReportError()) {
|
||||
this.notifyOnNextTick("weave:ui:login:error");
|
||||
} else {
|
||||
this.notifyOnNextTick("weave:ui:clear-error");
|
||||
}
|
||||
|
||||
this.dontIgnoreErrors = false;
|
||||
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();
|
||||
|
||||
if (this.shouldReportError()) {
|
||||
this.notifyOnNextTick("weave:ui:sync:error");
|
||||
} else {
|
||||
this.notifyOnNextTick("weave:ui:sync:finish");
|
||||
}
|
||||
|
||||
this.dontIgnoreErrors = false;
|
||||
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();
|
||||
|
||||
if (this.shouldReportError()) {
|
||||
this.dontIgnoreErrors = false;
|
||||
this.notifyOnNextTick("weave:ui:sync:error");
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
this.resetFileLog();
|
||||
}
|
||||
this.dontIgnoreErrors = false;
|
||||
this.notifyOnNextTick("weave:ui:sync:finish");
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
notifyOnNextTick: function notifyOnNextTick(topic) {
|
||||
Utils.nextTick(function() {
|
||||
this._log.trace("Notifying " + topic +
|
||||
". Status.login is " + Status.login +
|
||||
". Status.sync is " + Status.sync);
|
||||
Svc.Obs.notify(topic);
|
||||
}, this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Trigger a sync and don't muffle any errors, particularly network errors.
|
||||
*/
|
||||
syncAndReportErrors: function syncAndReportErrors() {
|
||||
this._log.debug("Beginning user-triggered sync.");
|
||||
|
||||
this.dontIgnoreErrors = true;
|
||||
Utils.nextTick(this.service.sync, this.service);
|
||||
},
|
||||
|
||||
_dumpAddons: function _dumpAddons() {
|
||||
// Just dump the items that sync may be concerned with. Specifically,
|
||||
// active extensions that are not hidden.
|
||||
let addonPromise = new Promise(resolve => {
|
||||
try {
|
||||
AddonManager.getAddonsByTypes(["extension"], resolve);
|
||||
} catch (e) {
|
||||
this._log.warn("Failed to dump addons", e)
|
||||
resolve([])
|
||||
}
|
||||
});
|
||||
|
||||
return addonPromise.then(addons => {
|
||||
let relevantAddons = addons.filter(x => x.isActive && !x.hidden);
|
||||
this._log.debug("Addons installed", relevantAddons.length);
|
||||
for (let addon of relevantAddons) {
|
||||
this._log.debug(" - ${name}, version ${version}, id ${id}", addon);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate a log file for the sync that just completed
|
||||
* and refresh the input & output streams.
|
||||
*/
|
||||
resetFileLog: function resetFileLog() {
|
||||
let onComplete = logType => {
|
||||
Svc.Obs.notify("weave:service:reset-file-log");
|
||||
this._log.trace("Notified: " + Date.now());
|
||||
if (logType == this._logManager.ERROR_LOG_WRITTEN) {
|
||||
Cu.reportError("Sync encountered an error - see about:sync-log for the log file.");
|
||||
}
|
||||
};
|
||||
|
||||
// If we're writing an error log, dump extensions that may be causing problems.
|
||||
let beforeResetLog;
|
||||
if (this._logManager.sawError) {
|
||||
beforeResetLog = this._dumpAddons();
|
||||
} else {
|
||||
beforeResetLog = Promise.resolve();
|
||||
}
|
||||
// Note we do not return the promise here - the caller doesn't need to wait
|
||||
// for this to complete.
|
||||
beforeResetLog
|
||||
.then(() => this._logManager.resetFileLog())
|
||||
.then(onComplete, onComplete);
|
||||
},
|
||||
|
||||
/**
|
||||
* Translates server error codes to meaningful strings.
|
||||
*
|
||||
* @param code
|
||||
* server error code as an integer
|
||||
*/
|
||||
errorStr: function errorStr(code) {
|
||||
switch (code.toString()) {
|
||||
case "1":
|
||||
return "illegal-method";
|
||||
case "2":
|
||||
return "invalid-captcha";
|
||||
case "3":
|
||||
return "invalid-username";
|
||||
case "4":
|
||||
return "cannot-overwrite-resource";
|
||||
case "5":
|
||||
return "userid-mismatch";
|
||||
case "6":
|
||||
return "json-parse-failure";
|
||||
case "7":
|
||||
return "invalid-password";
|
||||
case "8":
|
||||
return "invalid-record";
|
||||
case "9":
|
||||
return "weak-password";
|
||||
default:
|
||||
return "generic-server-error";
|
||||
}
|
||||
},
|
||||
|
||||
// A function to indicate if Sync errors should be "reported" - which in this
|
||||
// context really means "should be notify observers of an error" - but note
|
||||
// that since bug 1180587, no one is going to surface an error to the user.
|
||||
shouldReportError: function shouldReportError() {
|
||||
if (Status.login == MASTER_PASSWORD_LOCKED) {
|
||||
this._log.trace("shouldReportError: false (master password locked).");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.dontIgnoreErrors) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (Status.login == LOGIN_FAILED_LOGIN_REJECTED) {
|
||||
// An explicit LOGIN_REJECTED state is always reported (bug 1081158)
|
||||
this._log.trace("shouldReportError: true (login was rejected)");
|
||||
return true;
|
||||
}
|
||||
|
||||
let lastSync = Svc.Prefs.get("lastSync");
|
||||
if (lastSync && ((Date.now() - Date.parse(lastSync)) >
|
||||
Svc.Prefs.get("errorhandler.networkFailureReportTimeout") * 1000)) {
|
||||
Status.sync = PROLONGED_SYNC_FAILURE;
|
||||
if (this.didReportProlongedError) {
|
||||
this._log.trace("shouldReportError: false (prolonged sync failure, but" +
|
||||
" we've already reported it).");
|
||||
return false;
|
||||
}
|
||||
this._log.trace("shouldReportError: true (first prolonged sync failure).");
|
||||
this.didReportProlongedError = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// We got a 401 mid-sync. Wait for the next sync before actually handling
|
||||
// an error. This assumes that we'll get a 401 again on a login fetch in
|
||||
// order to report the error.
|
||||
if (!this.service.clusterURL) {
|
||||
this._log.trace("shouldReportError: false (no cluster URL; " +
|
||||
"possible node reassignment).");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
let result = ([Status.login, Status.sync].indexOf(SERVER_MAINTENANCE) == -1 &&
|
||||
[Status.login, Status.sync].indexOf(LOGIN_FAILED_NETWORK_ERROR) == -1);
|
||||
this._log.trace("shouldReportError: ${result} due to login=${login}, sync=${sync}",
|
||||
{result, login: Status.login, sync: Status.sync});
|
||||
return result;
|
||||
},
|
||||
|
||||
get currentAlertMode() {
|
||||
return Svc.Prefs.get("errorhandler.alert.mode");
|
||||
},
|
||||
|
||||
set currentAlertMode(str) {
|
||||
return Svc.Prefs.set("errorhandler.alert.mode", str);
|
||||
},
|
||||
|
||||
get earliestNextAlert() {
|
||||
return Svc.Prefs.get("errorhandler.alert.earliestNext", 0) * 1000;
|
||||
},
|
||||
|
||||
set earliestNextAlert(msec) {
|
||||
return Svc.Prefs.set("errorhandler.alert.earliestNext", msec / 1000);
|
||||
},
|
||||
|
||||
clearServerAlerts: function () {
|
||||
// If we have any outstanding alerts, apparently they're no longer relevant.
|
||||
Svc.Prefs.resetBranch("errorhandler.alert");
|
||||
},
|
||||
|
||||
/**
|
||||
* X-Weave-Alert headers can include a JSON object:
|
||||
*
|
||||
* {
|
||||
* "code": // One of "hard-eol", "soft-eol".
|
||||
* "url": // For "Learn more" link.
|
||||
* "message": // Logged in Sync logs.
|
||||
* }
|
||||
*/
|
||||
handleServerAlert: function (xwa) {
|
||||
if (!xwa.code) {
|
||||
this._log.warn("Got structured X-Weave-Alert, but no alert code.");
|
||||
return;
|
||||
}
|
||||
|
||||
switch (xwa.code) {
|
||||
// Gently and occasionally notify the user that this service will be
|
||||
// shutting down.
|
||||
case "soft-eol":
|
||||
// Fall through.
|
||||
|
||||
// Tell the user that this service has shut down, and drop our syncing
|
||||
// frequency dramatically.
|
||||
case "hard-eol":
|
||||
// Note that both of these alerts should be subservient to future "sign
|
||||
// in with your Firefox Account" storage alerts.
|
||||
if ((this.currentAlertMode != xwa.code) ||
|
||||
(this.earliestNextAlert < Date.now())) {
|
||||
Utils.nextTick(function() {
|
||||
Svc.Obs.notify("weave:eol", xwa);
|
||||
}, this);
|
||||
this._log.error("X-Weave-Alert: " + xwa.code + ": " + xwa.message);
|
||||
this.earliestNextAlert = Date.now() + this.MINIMUM_ALERT_INTERVAL_MSEC;
|
||||
this.currentAlertMode = xwa.code;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this._log.debug("Got unexpected X-Weave-Alert code: " + xwa.code);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle HTTP response results or exceptions and set the appropriate
|
||||
* Status.* bits.
|
||||
*
|
||||
* This method also looks for "side-channel" warnings.
|
||||
*/
|
||||
checkServerError: function (resp) {
|
||||
switch (resp.status) {
|
||||
case 200:
|
||||
case 404:
|
||||
case 513:
|
||||
let xwa = resp.headers['x-weave-alert'];
|
||||
|
||||
// Only process machine-readable alerts.
|
||||
if (!xwa || !xwa.startsWith("{")) {
|
||||
this.clearServerAlerts();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
xwa = JSON.parse(xwa);
|
||||
} catch (ex) {
|
||||
this._log.warn("Malformed X-Weave-Alert from server: " + xwa);
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleServerAlert(xwa);
|
||||
break;
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
1039
services/sync/modules/record.js
Normal file
1039
services/sync/modules/record.js
Normal file
File diff suppressed because it is too large
Load diff
669
services/sync/modules/resource.js
Normal file
669
services/sync/modules/resource.js
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
/* 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.EXPORTED_SYMBOLS = [
|
||||
"AsyncResource",
|
||||
"Resource"
|
||||
];
|
||||
|
||||
var Cc = Components.classes;
|
||||
var Ci = Components.interfaces;
|
||||
var Cr = Components.results;
|
||||
var Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/NetUtil.jsm");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/observers.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
const DEFAULT_LOAD_FLAGS =
|
||||
// Always validate the cache:
|
||||
Ci.nsIRequest.LOAD_BYPASS_CACHE |
|
||||
Ci.nsIRequest.INHIBIT_CACHING |
|
||||
// Don't send user cookies over the wire (Bug 644734).
|
||||
Ci.nsIRequest.LOAD_ANONYMOUS;
|
||||
|
||||
/*
|
||||
* AsyncResource represents a remote network resource, identified by a URI.
|
||||
* Create an instance like so:
|
||||
*
|
||||
* let resource = new AsyncResource("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)
|
||||
*
|
||||
* 'callback' is a function with the following signature:
|
||||
*
|
||||
* function callback(error, result) {...}
|
||||
*
|
||||
* 'error' will be null on successful requests. Likewise, result will not be
|
||||
* passed (=undefined) when an error occurs. Note that this is independent of
|
||||
* the status of the HTTP response.
|
||||
*/
|
||||
this.AsyncResource = function AsyncResource(uri) {
|
||||
this._log = Log.repository.getLogger(this._logName);
|
||||
this._log.level =
|
||||
Log.Level[Svc.Prefs.get("log.logger.network.resources")];
|
||||
this.uri = uri;
|
||||
this._headers = {};
|
||||
this._onComplete = Utils.bind2(this, this._onComplete);
|
||||
}
|
||||
AsyncResource.prototype = {
|
||||
_logName: "Sync.AsyncResource",
|
||||
|
||||
// ** {{{ AsyncResource.serverTime }}} **
|
||||
//
|
||||
// Caches the latest server timestamp (X-Weave-Timestamp header).
|
||||
serverTime: null,
|
||||
|
||||
/**
|
||||
* Callback to be invoked at request time to add authentication details.
|
||||
*
|
||||
* 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,
|
||||
|
||||
// ** {{{ AsyncResource.headers }}} **
|
||||
//
|
||||
// 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(value) {
|
||||
this._headers = value;
|
||||
},
|
||||
setHeader: function Res_setHeader(header, value) {
|
||||
this._headers[header.toLowerCase()] = value;
|
||||
},
|
||||
get headerNames() {
|
||||
return Object.keys(this.headers);
|
||||
},
|
||||
|
||||
// ** {{{ AsyncResource.uri }}} **
|
||||
//
|
||||
// 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;
|
||||
},
|
||||
|
||||
// ** {{{ AsyncResource.spec }}} **
|
||||
//
|
||||
// Get the string representation of the URI.
|
||||
get spec() {
|
||||
if (this._uri)
|
||||
return this._uri.spec;
|
||||
return null;
|
||||
},
|
||||
|
||||
// ** {{{ AsyncResource.data }}} **
|
||||
//
|
||||
// Get and set the data encapulated in the resource.
|
||||
_data: null,
|
||||
get data() {
|
||||
return this._data;
|
||||
},
|
||||
set data(value) {
|
||||
this._data = value;
|
||||
},
|
||||
|
||||
// ** {{{ AsyncResource._createRequest }}} **
|
||||
//
|
||||
// This method returns a new IO Channel for requests to be made
|
||||
// through. It is never called directly, only {{{_doRequest}}} uses it
|
||||
// to obtain a request channel.
|
||||
//
|
||||
_createRequest: function Res__createRequest(method) {
|
||||
let channel = NetUtil.newChannel({uri: this.spec, loadUsingSystemPrincipal: true})
|
||||
.QueryInterface(Ci.nsIRequest)
|
||||
.QueryInterface(Ci.nsIHttpChannel);
|
||||
|
||||
channel.loadFlags |= DEFAULT_LOAD_FLAGS;
|
||||
|
||||
// Setup a callback to handle channel notifications.
|
||||
let listener = new ChannelNotificationListener(this.headerNames);
|
||||
channel.notificationCallbacks = listener;
|
||||
|
||||
// Compose a UA string fragment from the various available identifiers.
|
||||
if (Svc.Prefs.get("sendVersionInfo", true)) {
|
||||
channel.setRequestHeader("user-agent", Utils.userAgent, false);
|
||||
}
|
||||
|
||||
let headers = this.headers;
|
||||
|
||||
if (this.authenticator) {
|
||||
let result = this.authenticator(this, method);
|
||||
if (result && result.headers) {
|
||||
for (let [k, v] of Object.entries(result.headers)) {
|
||||
headers[k.toLowerCase()] = v;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this._log.debug("No authenticator found.");
|
||||
}
|
||||
|
||||
for (let [key, value] of Object.entries(headers)) {
|
||||
if (key == 'authorization')
|
||||
this._log.trace("HTTP Header " + key + ": ***** (suppressed)");
|
||||
else
|
||||
this._log.trace("HTTP Header " + key + ": " + headers[key]);
|
||||
channel.setRequestHeader(key, headers[key], false);
|
||||
}
|
||||
return channel;
|
||||
},
|
||||
|
||||
_onProgress: function Res__onProgress(channel) {},
|
||||
|
||||
_doRequest: function _doRequest(action, data, callback) {
|
||||
this._log.trace("In _doRequest.");
|
||||
this._callback = callback;
|
||||
let channel = this._createRequest(action);
|
||||
|
||||
if ("undefined" != typeof(data))
|
||||
this._data = data;
|
||||
|
||||
// PUT and POST are treated differently because they have payload data.
|
||||
if ("PUT" == action || "POST" == action) {
|
||||
// Convert non-string bodies into JSON
|
||||
if (this._data.constructor.toString() != String)
|
||||
this._data = JSON.stringify(this._data);
|
||||
|
||||
this._log.debug(action + " Length: " + this._data.length);
|
||||
this._log.trace(action + " Body: " + this._data);
|
||||
|
||||
let type = ('content-type' in this._headers) ?
|
||||
this._headers['content-type'] : 'text/plain';
|
||||
|
||||
let stream = Cc["@mozilla.org/io/string-input-stream;1"].
|
||||
createInstance(Ci.nsIStringInputStream);
|
||||
stream.setData(this._data, this._data.length);
|
||||
|
||||
channel.QueryInterface(Ci.nsIUploadChannel);
|
||||
channel.setUploadStream(stream, type, this._data.length);
|
||||
}
|
||||
|
||||
// Setup a channel listener so that the actual network operation
|
||||
// is performed asynchronously.
|
||||
let listener = new ChannelListener(this._onComplete, this._onProgress,
|
||||
this._log, this.ABORT_TIMEOUT);
|
||||
channel.requestMethod = action;
|
||||
try {
|
||||
channel.asyncOpen2(listener);
|
||||
} catch (ex) {
|
||||
// asyncOpen2 can throw in a bunch of cases -- e.g., a forbidden port.
|
||||
this._log.warn("Caught an error in asyncOpen2", ex);
|
||||
CommonUtils.nextTick(callback.bind(this, ex));
|
||||
}
|
||||
},
|
||||
|
||||
_onComplete: function _onComplete(error, data, channel) {
|
||||
this._log.trace("In _onComplete. Error is " + error + ".");
|
||||
|
||||
if (error) {
|
||||
this._callback(error);
|
||||
return;
|
||||
}
|
||||
|
||||
this._data = data;
|
||||
let action = channel.requestMethod;
|
||||
|
||||
this._log.trace("Channel: " + channel);
|
||||
this._log.trace("Action: " + action);
|
||||
|
||||
// Process status and success first. This way a problem with headers
|
||||
// doesn't fail to include accurate status information.
|
||||
let status = 0;
|
||||
let success = false;
|
||||
|
||||
try {
|
||||
status = channel.responseStatus;
|
||||
success = channel.requestSucceeded; // HTTP status.
|
||||
|
||||
this._log.trace("Status: " + status);
|
||||
this._log.trace("Success: " + success);
|
||||
|
||||
// Log the status of the request.
|
||||
let mesg = [action, success ? "success" : "fail", status,
|
||||
channel.URI.spec].join(" ");
|
||||
this._log.debug("mesg: " + mesg);
|
||||
|
||||
if (mesg.length > 200)
|
||||
mesg = mesg.substr(0, 200) + "…";
|
||||
this._log.debug(mesg);
|
||||
|
||||
// Additionally give the full response body when Trace logging.
|
||||
if (this._log.level <= Log.Level.Trace)
|
||||
this._log.trace(action + " body: " + data);
|
||||
|
||||
} catch(ex) {
|
||||
// Got a response, but an exception occurred during processing.
|
||||
// This shouldn't occur.
|
||||
this._log.warn("Caught unexpected exception in _oncomplete", ex);
|
||||
}
|
||||
|
||||
// Process headers. They can be empty, or the call can otherwise fail, so
|
||||
// put this in its own try block.
|
||||
let headers = {};
|
||||
try {
|
||||
this._log.trace("Processing response headers.");
|
||||
|
||||
// Read out the response headers if available.
|
||||
channel.visitResponseHeaders({
|
||||
visitHeader: function visitHeader(header, value) {
|
||||
headers[header.toLowerCase()] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// This is a server-side safety valve to allow slowing down
|
||||
// clients without hurting performance.
|
||||
if (headers["x-weave-backoff"]) {
|
||||
let backoff = headers["x-weave-backoff"];
|
||||
this._log.debug("Got X-Weave-Backoff: " + backoff);
|
||||
Observers.notify("weave:service:backoff:interval",
|
||||
parseInt(backoff, 10));
|
||||
}
|
||||
|
||||
if (success && headers["x-weave-quota-remaining"]) {
|
||||
Observers.notify("weave:service:quota:remaining",
|
||||
parseInt(headers["x-weave-quota-remaining"], 10));
|
||||
}
|
||||
|
||||
let contentLength = headers["content-length"];
|
||||
if (success && contentLength && data &&
|
||||
contentLength != data.length) {
|
||||
this._log.warn("The response body's length of: " + data.length +
|
||||
" doesn't match the header's content-length of: " +
|
||||
contentLength + ".");
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log.debug("Caught exception visiting headers in _onComplete", ex);
|
||||
}
|
||||
|
||||
let ret = new String(data);
|
||||
ret.url = channel.URI.spec;
|
||||
ret.status = status;
|
||||
ret.success = success;
|
||||
ret.headers = headers;
|
||||
|
||||
if (!success) {
|
||||
this._log.warn(`${action} request to ${ret.url} failed with status ${status}`);
|
||||
}
|
||||
// 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", function() {
|
||||
try {
|
||||
return JSON.parse(ret);
|
||||
} 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: \"" +
|
||||
JSON.stringify((ret + "").slice(0, 100)) +
|
||||
"\".");
|
||||
throw ex;
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
this._callback(null, ret);
|
||||
},
|
||||
|
||||
get: function get(callback) {
|
||||
this._doRequest("GET", undefined, callback);
|
||||
},
|
||||
|
||||
put: function put(data, callback) {
|
||||
if (typeof data == "function")
|
||||
[data, callback] = [undefined, data];
|
||||
this._doRequest("PUT", data, callback);
|
||||
},
|
||||
|
||||
post: function post(data, callback) {
|
||||
if (typeof data == "function")
|
||||
[data, callback] = [undefined, data];
|
||||
this._doRequest("POST", data, callback);
|
||||
},
|
||||
|
||||
delete: function delete_(callback) {
|
||||
this._doRequest("DELETE", undefined, callback);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/*
|
||||
* Represent a remote network resource, identified by a URI, with a
|
||||
* synchronous API.
|
||||
*
|
||||
* 'Resource' is not recommended for new code. Use the asynchronous API of
|
||||
* 'AsyncResource' instead.
|
||||
*/
|
||||
this.Resource = function Resource(uri) {
|
||||
AsyncResource.call(this, uri);
|
||||
}
|
||||
Resource.prototype = {
|
||||
|
||||
__proto__: AsyncResource.prototype,
|
||||
|
||||
_logName: "Sync.Resource",
|
||||
|
||||
// ** {{{ Resource._request }}} **
|
||||
//
|
||||
// Perform a particular HTTP request on the resource. This method
|
||||
// is never called directly, but is used by the high-level
|
||||
// {{{get}}}, {{{put}}}, {{{post}}} and {{delete}} methods.
|
||||
_request: function Res__request(action, data) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
function callback(error, ret) {
|
||||
if (error)
|
||||
cb.throw(error);
|
||||
else
|
||||
cb(ret);
|
||||
}
|
||||
|
||||
// The channel listener might get a failure code
|
||||
try {
|
||||
this._doRequest(action, data, callback);
|
||||
return Async.waitForSyncCallback(cb);
|
||||
} catch (ex) {
|
||||
if (Async.isShutdownException(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
this._log.warn("${action} request to ${url} failed: ${ex}",
|
||||
{ action, url: this.uri.spec, ex });
|
||||
// Combine the channel stack with this request stack. Need to create
|
||||
// a new error object for that.
|
||||
let error = Error(ex.message);
|
||||
error.result = ex.result;
|
||||
let chanStack = [];
|
||||
if (ex.stack)
|
||||
chanStack = ex.stack.trim().split(/\n/).slice(1);
|
||||
let requestStack = error.stack.split(/\n/).slice(1);
|
||||
|
||||
// Strip out the args for the last 2 frames because they're usually HUGE!
|
||||
for (let i = 0; i <= 1; i++)
|
||||
requestStack[i] = requestStack[i].replace(/\(".*"\)@/, "(...)@");
|
||||
|
||||
error.stack = chanStack.concat(requestStack).join("\n");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ** {{{ Resource.get }}} **
|
||||
//
|
||||
// Perform an asynchronous HTTP GET for this resource.
|
||||
get: function Res_get() {
|
||||
return this._request("GET");
|
||||
},
|
||||
|
||||
// ** {{{ Resource.put }}} **
|
||||
//
|
||||
// Perform a HTTP PUT for this resource.
|
||||
put: function Res_put(data) {
|
||||
return this._request("PUT", data);
|
||||
},
|
||||
|
||||
// ** {{{ Resource.post }}} **
|
||||
//
|
||||
// Perform a HTTP POST for this resource.
|
||||
post: function Res_post(data) {
|
||||
return this._request("POST", data);
|
||||
},
|
||||
|
||||
// ** {{{ Resource.delete }}} **
|
||||
//
|
||||
// Perform a HTTP DELETE for this resource.
|
||||
delete: function Res_delete() {
|
||||
return this._request("DELETE");
|
||||
}
|
||||
};
|
||||
|
||||
// = ChannelListener =
|
||||
//
|
||||
// This object implements the {{{nsIStreamListener}}} interface
|
||||
// and is called as the network operation proceeds.
|
||||
function ChannelListener(onComplete, onProgress, logger, timeout) {
|
||||
this._onComplete = onComplete;
|
||||
this._onProgress = onProgress;
|
||||
this._log = logger;
|
||||
this._timeout = timeout;
|
||||
this.delayAbort();
|
||||
}
|
||||
ChannelListener.prototype = {
|
||||
|
||||
onStartRequest: function Channel_onStartRequest(channel) {
|
||||
this._log.trace("onStartRequest called for channel " + channel + ".");
|
||||
|
||||
try {
|
||||
channel.QueryInterface(Ci.nsIHttpChannel);
|
||||
} catch (ex) {
|
||||
this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
|
||||
channel.cancel(Cr.NS_BINDING_ABORTED);
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the latest server timestamp when possible.
|
||||
try {
|
||||
AsyncResource.serverTime = channel.getResponseHeader("X-Weave-Timestamp") - 0;
|
||||
}
|
||||
catch(ex) {}
|
||||
|
||||
this._log.trace("onStartRequest: " + channel.requestMethod + " " +
|
||||
channel.URI.spec);
|
||||
this._data = '';
|
||||
this.delayAbort();
|
||||
},
|
||||
|
||||
onStopRequest: function Channel_onStopRequest(channel, context, status) {
|
||||
// Clear the abort timer now that the channel is done.
|
||||
this.abortTimer.clear();
|
||||
|
||||
if (!this._onComplete) {
|
||||
this._log.error("Unexpected error: _onComplete not defined in onStopRequest.");
|
||||
this._onProgress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
channel.QueryInterface(Ci.nsIHttpChannel);
|
||||
} catch (ex) {
|
||||
this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
|
||||
|
||||
this._onComplete(ex, this._data, channel);
|
||||
this._onComplete = this._onProgress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let statusSuccess = Components.isSuccessCode(status);
|
||||
let uri = channel && channel.URI && channel.URI.spec || "<unknown>";
|
||||
this._log.trace("Channel for " + channel.requestMethod + " " + uri + ": " +
|
||||
"isSuccessCode(" + status + ")? " + statusSuccess);
|
||||
|
||||
if (this._data == '') {
|
||||
this._data = null;
|
||||
}
|
||||
|
||||
// Pass back the failure code and stop execution. Use Components.Exception()
|
||||
// instead of Error() so the exception is QI-able and can be passed across
|
||||
// XPCOM borders while preserving the status code.
|
||||
if (!statusSuccess) {
|
||||
let message = Components.Exception("", status).name;
|
||||
let error = Components.Exception(message, status);
|
||||
|
||||
this._onComplete(error, undefined, channel);
|
||||
this._onComplete = this._onProgress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._log.trace("Channel: flags = " + channel.loadFlags +
|
||||
", URI = " + uri +
|
||||
", HTTP success? " + channel.requestSucceeded);
|
||||
this._onComplete(null, this._data, channel);
|
||||
this._onComplete = this._onProgress = null;
|
||||
},
|
||||
|
||||
onDataAvailable: function Channel_onDataAvail(req, cb, stream, off, count) {
|
||||
let siStream;
|
||||
try {
|
||||
siStream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(Ci.nsIScriptableInputStream);
|
||||
siStream.init(stream);
|
||||
} catch (ex) {
|
||||
this._log.warn("Exception creating nsIScriptableInputStream", ex);
|
||||
this._log.debug("Parameters: " + req.URI.spec + ", " + stream + ", " + off + ", " + count);
|
||||
// Cannot proceed, so rethrow and allow the channel to cancel itself.
|
||||
throw ex;
|
||||
}
|
||||
|
||||
try {
|
||||
this._data += siStream.read(count);
|
||||
} catch (ex) {
|
||||
this._log.warn("Exception thrown reading " + count + " bytes from " + siStream + ".");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
try {
|
||||
this._onProgress();
|
||||
} catch (ex) {
|
||||
if (Async.isShutdownException(ex)) {
|
||||
throw ex;
|
||||
}
|
||||
this._log.warn("Got exception calling onProgress handler during fetch of "
|
||||
+ req.URI.spec, ex);
|
||||
this._log.trace("Rethrowing; expect a failure code from the HTTP channel.");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
this.delayAbort();
|
||||
},
|
||||
|
||||
/**
|
||||
* Create or push back the abort timer that kills this request.
|
||||
*/
|
||||
delayAbort: function delayAbort() {
|
||||
try {
|
||||
CommonUtils.namedTimer(this.abortRequest, this._timeout, this, "abortTimer");
|
||||
} catch (ex) {
|
||||
this._log.warn("Got exception extending abort timer", ex);
|
||||
}
|
||||
},
|
||||
|
||||
abortRequest: function abortRequest() {
|
||||
// Ignore any callbacks if we happen to get any now
|
||||
this.onStopRequest = function() {};
|
||||
let error = Components.Exception("Aborting due to channel inactivity.",
|
||||
Cr.NS_ERROR_NET_TIMEOUT);
|
||||
if (!this._onComplete) {
|
||||
this._log.error("Unexpected error: _onComplete not defined in " +
|
||||
"abortRequest.");
|
||||
return;
|
||||
}
|
||||
this._onComplete(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This class handles channel notification events.
|
||||
*
|
||||
* An instance of this class is bound to each created channel.
|
||||
*
|
||||
* Optionally pass an array of header names. Each header named
|
||||
* in this array will be copied between the channels in the
|
||||
* event of a redirect.
|
||||
*/
|
||||
function ChannelNotificationListener(headersToCopy) {
|
||||
this._headersToCopy = headersToCopy;
|
||||
|
||||
this._log = Log.repository.getLogger(this._logName);
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.network.resources")];
|
||||
}
|
||||
ChannelNotificationListener.prototype = {
|
||||
_logName: "Sync.Resource",
|
||||
|
||||
getInterface: function(aIID) {
|
||||
return this.QueryInterface(aIID);
|
||||
},
|
||||
|
||||
QueryInterface: function(aIID) {
|
||||
if (aIID.equals(Ci.nsIBadCertListener2) ||
|
||||
aIID.equals(Ci.nsIInterfaceRequestor) ||
|
||||
aIID.equals(Ci.nsISupports) ||
|
||||
aIID.equals(Ci.nsIChannelEventSink))
|
||||
return this;
|
||||
|
||||
throw Cr.NS_ERROR_NO_INTERFACE;
|
||||
},
|
||||
|
||||
notifyCertProblem: function certProblem(socketInfo, sslStatus, targetHost) {
|
||||
let log = Log.repository.getLogger("Sync.CertListener");
|
||||
log.warn("Invalid HTTPS certificate encountered!");
|
||||
|
||||
// This suppresses the UI warning only. The request is still cancelled.
|
||||
return true;
|
||||
},
|
||||
|
||||
asyncOnChannelRedirect:
|
||||
function asyncOnChannelRedirect(oldChannel, newChannel, flags, callback) {
|
||||
|
||||
let oldSpec = (oldChannel && oldChannel.URI) ? oldChannel.URI.spec : "<undefined>";
|
||||
let newSpec = (newChannel && newChannel.URI) ? newChannel.URI.spec : "<undefined>";
|
||||
this._log.debug("Channel redirect: " + oldSpec + ", " + newSpec + ", " + flags);
|
||||
|
||||
this._log.debug("Ensuring load flags are set.");
|
||||
newChannel.loadFlags |= DEFAULT_LOAD_FLAGS;
|
||||
|
||||
// For internal redirects, copy the headers that our caller set.
|
||||
try {
|
||||
if ((flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL) &&
|
||||
newChannel.URI.equals(oldChannel.URI)) {
|
||||
this._log.debug("Copying headers for safe internal redirect.");
|
||||
|
||||
// QI the channel so we can set headers on it.
|
||||
try {
|
||||
newChannel.QueryInterface(Ci.nsIHttpChannel);
|
||||
} catch (ex) {
|
||||
this._log.error("Unexpected error: channel is not a nsIHttpChannel!");
|
||||
throw ex;
|
||||
}
|
||||
|
||||
for (let header of this._headersToCopy) {
|
||||
let value = oldChannel.getRequestHeader(header);
|
||||
if (value) {
|
||||
let printed = (header == "authorization") ? "****" : value;
|
||||
this._log.debug("Header: " + header + " = " + printed);
|
||||
newChannel.setRequestHeader(header, value, false);
|
||||
} else {
|
||||
this._log.warn("No value for header " + header);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (ex) {
|
||||
this._log.error("Error copying headers", ex);
|
||||
}
|
||||
|
||||
// We let all redirects proceed.
|
||||
try {
|
||||
callback.onRedirectVerifyCallback(Cr.NS_OK);
|
||||
} catch (ex) {
|
||||
this._log.error("onRedirectVerifyCallback threw!", ex);
|
||||
}
|
||||
}
|
||||
};
|
||||
90
services/sync/modules/rest.js
Normal file
90
services/sync/modules/rest.js
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/rest.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["SyncStorageRequest"];
|
||||
|
||||
const STORAGE_REQUEST_TIMEOUT = 5 * 60; // 5 minutes
|
||||
|
||||
/**
|
||||
* RESTRequest variant for use against a Sync storage server.
|
||||
*/
|
||||
this.SyncStorageRequest = function SyncStorageRequest(uri) {
|
||||
RESTRequest.call(this, uri);
|
||||
|
||||
this.authenticator = null;
|
||||
}
|
||||
SyncStorageRequest.prototype = {
|
||||
|
||||
__proto__: RESTRequest.prototype,
|
||||
|
||||
_logName: "Sync.StorageRequest",
|
||||
|
||||
/**
|
||||
* Wait 5 minutes before killing a request.
|
||||
*/
|
||||
timeout: STORAGE_REQUEST_TIMEOUT,
|
||||
|
||||
dispatch: function dispatch(method, data, onComplete, onProgress) {
|
||||
// Compose a UA string fragment from the various available identifiers.
|
||||
if (Svc.Prefs.get("sendVersionInfo", true)) {
|
||||
this.setHeader("user-agent", Utils.userAgent);
|
||||
}
|
||||
|
||||
if (this.authenticator) {
|
||||
this.authenticator(this);
|
||||
} else {
|
||||
this._log.debug("No authenticator found.");
|
||||
}
|
||||
|
||||
return RESTRequest.prototype.dispatch.apply(this, arguments);
|
||||
},
|
||||
|
||||
onStartRequest: function onStartRequest(channel) {
|
||||
RESTRequest.prototype.onStartRequest.call(this, channel);
|
||||
if (this.status == this.ABORTED) {
|
||||
return;
|
||||
}
|
||||
|
||||
let headers = this.response.headers;
|
||||
// Save the latest server timestamp when possible.
|
||||
if (headers["x-weave-timestamp"]) {
|
||||
SyncStorageRequest.serverTime = parseFloat(headers["x-weave-timestamp"]);
|
||||
}
|
||||
|
||||
// This is a server-side safety valve to allow slowing down
|
||||
// clients without hurting performance.
|
||||
if (headers["x-weave-backoff"]) {
|
||||
Svc.Obs.notify("weave:service:backoff:interval",
|
||||
parseInt(headers["x-weave-backoff"], 10));
|
||||
}
|
||||
|
||||
if (this.response.success && headers["x-weave-quota-remaining"]) {
|
||||
Svc.Obs.notify("weave:service:quota:remaining",
|
||||
parseInt(headers["x-weave-quota-remaining"], 10));
|
||||
}
|
||||
},
|
||||
|
||||
onStopRequest: function onStopRequest(channel, context, statusCode) {
|
||||
if (this.status != this.ABORTED) {
|
||||
let resp = this.response;
|
||||
let contentLength = resp.headers ? resp.headers["content-length"] : "";
|
||||
|
||||
if (resp.success && contentLength &&
|
||||
contentLength != resp.body.length) {
|
||||
this._log.warn("The response body's length of: " + resp.body.length +
|
||||
" doesn't match the header's content-length of: " +
|
||||
contentLength + ".");
|
||||
}
|
||||
}
|
||||
|
||||
RESTRequest.prototype.onStopRequest.apply(this, arguments);
|
||||
}
|
||||
};
|
||||
1756
services/sync/modules/service.js
Normal file
1756
services/sync/modules/service.js
Normal file
File diff suppressed because it is too large
Load diff
113
services/sync/modules/stages/cluster.js
Normal file
113
services/sync/modules/stages/cluster.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ["ClusterManager"];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/policies.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
/**
|
||||
* Contains code for managing the Sync cluster we are in.
|
||||
*/
|
||||
this.ClusterManager = function ClusterManager(service) {
|
||||
this._log = Log.repository.getLogger("Sync.Service");
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.service.main")];
|
||||
|
||||
this.service = service;
|
||||
}
|
||||
ClusterManager.prototype = {
|
||||
get identity() {
|
||||
return this.service.identity;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain the cluster for the current user.
|
||||
*
|
||||
* Returns the string URL of the cluster or null on error.
|
||||
*/
|
||||
_findCluster: function _findCluster() {
|
||||
this._log.debug("Finding cluster for user " + this.identity.username);
|
||||
|
||||
// This should ideally use UserAPI10Client but the legacy hackiness is
|
||||
// strong with this code.
|
||||
let fail;
|
||||
let url = this.service.userAPIURI + this.identity.username + "/node/weave";
|
||||
let res = this.service.resource(url);
|
||||
try {
|
||||
let node = res.get();
|
||||
switch (node.status) {
|
||||
case 400:
|
||||
this.service.status.login = LOGIN_FAILED_LOGIN_REJECTED;
|
||||
fail = "Find cluster denied: " + this.service.errorHandler.errorStr(node);
|
||||
break;
|
||||
case 404:
|
||||
this._log.debug("Using serverURL as data cluster (multi-cluster support disabled)");
|
||||
return this.service.serverURL;
|
||||
case 0:
|
||||
case 200:
|
||||
if (node == "null") {
|
||||
node = null;
|
||||
}
|
||||
this._log.trace("_findCluster successfully returning " + node);
|
||||
return node;
|
||||
default:
|
||||
this.service.errorHandler.checkServerError(node);
|
||||
fail = "Unexpected response code: " + node.status;
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
this._log.debug("Network error on findCluster");
|
||||
this.service.status.login = LOGIN_FAILED_NETWORK_ERROR;
|
||||
this.service.errorHandler.checkServerError(e);
|
||||
fail = e;
|
||||
}
|
||||
throw fail;
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine the cluster for the current user and update state.
|
||||
*/
|
||||
setCluster: function setCluster() {
|
||||
// Make sure we didn't get some unexpected response for the cluster.
|
||||
let cluster = 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 == this.service.clusterURL) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this._log.debug("Setting cluster to " + cluster);
|
||||
this.service.clusterURL = cluster;
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
getUserBaseURL: function getUserBaseURL() {
|
||||
// Legacy Sync and FxA Sync construct the userBaseURL differently. Legacy
|
||||
// Sync appends path components onto an empty path, and in FxA Sync, the
|
||||
// token server constructs this for us in an opaque manner. Since the
|
||||
// cluster manager already sets the clusterURL on Service and also has
|
||||
// access to the current identity, we added this functionality here.
|
||||
|
||||
// If the clusterURL hasn't been set, the userBaseURL shouldn't be set
|
||||
// either. Some tests expect "undefined" to be returned here.
|
||||
if (!this.service.clusterURL) {
|
||||
return undefined;
|
||||
}
|
||||
let storageAPI = this.service.clusterURL + SYNC_API_VERSION + "/";
|
||||
return storageAPI + this.identity.username + "/";
|
||||
}
|
||||
};
|
||||
Object.freeze(ClusterManager.prototype);
|
||||
76
services/sync/modules/stages/declined.js
Normal file
76
services/sync/modules/stages/declined.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["DeclinedEngines"];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-common/observers.js");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
|
||||
|
||||
|
||||
this.DeclinedEngines = function (service) {
|
||||
this._log = Log.repository.getLogger("Sync.Declined");
|
||||
this._log.level = Log.Level[new Preferences(PREFS_BRANCH).get("log.logger.declined")];
|
||||
|
||||
this.service = service;
|
||||
}
|
||||
this.DeclinedEngines.prototype = {
|
||||
updateDeclined: function (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: enabled,
|
||||
known: known,
|
||||
undecided: undecided,
|
||||
};
|
||||
CommonUtils.nextTick(() => {
|
||||
Observers.notify("weave:engines:notdeclined", subject);
|
||||
});
|
||||
}
|
||||
|
||||
return declinedChanged;
|
||||
},
|
||||
};
|
||||
449
services/sync/modules/stages/enginesync.js
Normal file
449
services/sync/modules/stages/enginesync.js
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
/* 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.
|
||||
*/
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["EngineSynchronizer"];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
Cu.import("resource://services-sync/policies.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/observers.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Task.jsm");
|
||||
|
||||
/**
|
||||
* Perform synchronization of engines.
|
||||
*
|
||||
* This was originally split out of service.js. The API needs lots of love.
|
||||
*/
|
||||
this.EngineSynchronizer = function EngineSynchronizer(service) {
|
||||
this._log = Log.repository.getLogger("Sync.Synchronizer");
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.synchronizer")];
|
||||
|
||||
this.service = service;
|
||||
|
||||
this.onComplete = null;
|
||||
}
|
||||
|
||||
EngineSynchronizer.prototype = {
|
||||
sync: function sync(engineNamesToSync) {
|
||||
if (!this.onComplete) {
|
||||
throw new Error("onComplete handler not installed.");
|
||||
}
|
||||
|
||||
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;
|
||||
this.onComplete(new Error("Can't sync: " + reason));
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have a node, get one. If that fails, retry in 10 minutes.
|
||||
if (!this.service.clusterURL && !this.service._clusterManager.setCluster()) {
|
||||
this.service.status.sync = NO_SYNC_NODE_FOUND;
|
||||
this._log.info("No cluster URL found. Cannot sync.");
|
||||
this.onComplete(null);
|
||||
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 = 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 (!(this.service._remoteSetup(info))) {
|
||||
this.onComplete(new Error("Aborting sync, remote setup failed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure we have an up-to-date list of clients before sending commands
|
||||
this._log.debug("Refreshing client list.");
|
||||
if (!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.");
|
||||
this.onComplete(null);
|
||||
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":
|
||||
this.service.resetClient(engineManager.enabledEngineNames);
|
||||
break;
|
||||
case "wipeClient":
|
||||
this.service.wipeClient(engineManager.enabledEngineNames);
|
||||
break;
|
||||
case "wipeRemote":
|
||||
this.service.wipeRemote(engineManager.enabledEngineNames);
|
||||
break;
|
||||
default:
|
||||
allowEnginesHint = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.service.clientsEngine.localCommands) {
|
||||
try {
|
||||
if (!(this.service.clientsEngine.processIncomingCommands())) {
|
||||
this.service.status.sync = ABORT_SYNC_COMMAND;
|
||||
this.onComplete(new Error("Processed command aborted sync."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Repeat remoteSetup in-case the commands forced us to reset
|
||||
if (!(this.service._remoteSetup(info))) {
|
||||
this.onComplete(new Error("Remote setup failed after processing commands."));
|
||||
return;
|
||||
}
|
||||
}
|
||||
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.
|
||||
this._syncEngine(this.service.clientsEngine);
|
||||
}
|
||||
}
|
||||
|
||||
// Update engines because it might change what we sync.
|
||||
try {
|
||||
this._updateEnabledEngines();
|
||||
} catch (ex) {
|
||||
this._log.debug("Updating enabled engines failed", ex);
|
||||
this.service.errorHandler.checkServerError(ex);
|
||||
this.onComplete(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 there's any problems with syncing the engine, report the failure
|
||||
if (!(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.");
|
||||
this.onComplete(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload meta/global if any engines changed anything.
|
||||
let meta = this.service.recordManager.get(this.service.metaURL);
|
||||
if (meta.isNew || meta.changed) {
|
||||
this._log.info("meta/global changed locally: reuploading.");
|
||||
try {
|
||||
this.service.uploadMetaGlobal(meta);
|
||||
delete meta.isNew;
|
||||
delete meta.changed;
|
||||
} catch (error) {
|
||||
this._log.error("Unable to upload meta/global. Leaving marked as new.");
|
||||
}
|
||||
}
|
||||
|
||||
Async.promiseSpinningly(this._tryValidateEngines(enginesToValidate));
|
||||
|
||||
// If there were no sync engine failures
|
||||
if (this.service.status.service != SYNC_FAILED_PARTIAL) {
|
||||
Svc.Prefs.set("lastSync", new Date().toString());
|
||||
this.service.status.sync = SYNC_SUCCEEDED;
|
||||
}
|
||||
} 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.");
|
||||
}
|
||||
|
||||
this.onComplete(null);
|
||||
},
|
||||
|
||||
_tryValidateEngines: Task.async(function* (recentlySyncedEngines) {
|
||||
if (!Services.telemetry.canRecordBase || !Svc.Prefs.get("validation.enabled", false)) {
|
||||
this._log.info("Skipping validation: validation or telemetry reporting is disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
let lastValidation = Svc.Prefs.get("validation.lastTime", 0);
|
||||
let validationInterval = Svc.Prefs.get("validation.interval");
|
||||
let nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (nowSeconds - lastValidation < validationInterval) {
|
||||
this._log.info("Skipping validation: too recent since last validation attempt");
|
||||
return;
|
||||
}
|
||||
// Update the time now, even if we may return false still. We don't want to
|
||||
// check the rest of these more frequently than once a day.
|
||||
Svc.Prefs.set("validation.lastTime", nowSeconds);
|
||||
|
||||
// Validation only occurs a certain percentage of the time.
|
||||
let validationProbability = Svc.Prefs.get("validation.percentageChance", 0) / 100.0;
|
||||
if (validationProbability < Math.random()) {
|
||||
this._log.info("Skipping validation: Probability threshold not met");
|
||||
return;
|
||||
}
|
||||
let maxRecords = Svc.Prefs.get("validation.maxRecords");
|
||||
if (!maxRecords) {
|
||||
// Don't bother asking the server for the counts if we know validation
|
||||
// won't happen anyway.
|
||||
return;
|
||||
}
|
||||
|
||||
// maxRecords of -1 means "any number", so we can skip asking the server.
|
||||
// Used for tests.
|
||||
let info;
|
||||
if (maxRecords < 0) {
|
||||
info = {};
|
||||
for (let e of recentlySyncedEngines) {
|
||||
info[e.name] = 1; // needs to be < maxRecords
|
||||
}
|
||||
maxRecords = 2;
|
||||
} else {
|
||||
|
||||
let collectionCountsURL = this.service.userBaseURL + "info/collection_counts";
|
||||
try {
|
||||
let infoResp = this.service._fetchInfo(collectionCountsURL);
|
||||
if (!infoResp.success) {
|
||||
this._log.error("Can't run validation: request to info/collection_counts responded with "
|
||||
+ resp.status);
|
||||
return;
|
||||
}
|
||||
info = infoResp.obj; // might throw because obj is a getter which parses json.
|
||||
} catch (e) {
|
||||
// Not running validation is totally fine, so we just write an error log and return.
|
||||
this._log.error("Can't run validation: Caught error when fetching counts", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!info) {
|
||||
return;
|
||||
}
|
||||
|
||||
let engineLookup = new Map(recentlySyncedEngines.map(e => [e.name, e]));
|
||||
let toRun = [];
|
||||
for (let [engineName, recordCount] of Object.entries(info)) {
|
||||
let engine = engineLookup.get(engineName);
|
||||
if (recordCount > maxRecords || !engine) {
|
||||
this._log.debug(`Skipping validation for ${engineName} because it's not an engine or ` +
|
||||
`the number of records (${recordCount}) is greater than the maximum allowed (${maxRecords}).`);
|
||||
continue;
|
||||
}
|
||||
let validator = engine.getValidator();
|
||||
if (!validator) {
|
||||
continue;
|
||||
}
|
||||
// Put this in an array so that we know how many we're going to do, so we
|
||||
// don't tell users we're going to run some validators when we aren't.
|
||||
toRun.push({ engine, validator });
|
||||
}
|
||||
|
||||
if (!toRun.length) {
|
||||
return;
|
||||
}
|
||||
Services.console.logStringMessage(
|
||||
"Sync is about to run a consistency check. This may be slow, and " +
|
||||
"can be controlled using the pref \"services.sync.validation.enabled\".\n" +
|
||||
"If you encounter any problems because of this, please file a bug.");
|
||||
for (let { validator, engine } of toRun) {
|
||||
try {
|
||||
let result = yield validator.validate(engine);
|
||||
Observers.notify("weave:engine:validate:finish", result, engine.name);
|
||||
} catch (e) {
|
||||
this._log.error(`Failed to run validation on ${engine.name}!`, e);
|
||||
Observers.notify("weave:engine:validate:error", e, engine.name)
|
||||
// Keep validating -- there's no reason to think that a failure for one
|
||||
// validator would mean the others will fail.
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Returns true if sync should proceed.
|
||||
// false / no return value means sync should be aborted.
|
||||
_syncEngine: function _syncEngine(engine) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
_updateEnabledFromMeta: function (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.");
|
||||
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!
|
||||
engine.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
engineManager.decline(toDecline);
|
||||
engineManager.undecline(toUndecline);
|
||||
|
||||
Svc.Prefs.resetBranch("engineStatusChanged.");
|
||||
this.service._ignorePrefObserver = false;
|
||||
},
|
||||
|
||||
_updateEnabledEngines: function () {
|
||||
let meta = this.service.recordManager.get(this.service.metaURL);
|
||||
let numClients = this.service.scheduler.numClients;
|
||||
let engineManager = this.service.engineManager;
|
||||
|
||||
this._updateEnabledFromMeta(meta, numClients, engineManager);
|
||||
},
|
||||
};
|
||||
Object.freeze(EngineSynchronizer.prototype);
|
||||
145
services/sync/modules/status.js
Normal file
145
services/sync/modules/status.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ["Status"];
|
||||
|
||||
var Cc = Components.classes;
|
||||
var Ci = Components.interfaces;
|
||||
var Cr = Components.results;
|
||||
var Cu = Components.utils;
|
||||
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-sync/identity.js");
|
||||
Cu.import("resource://services-sync/browserid_identity.js");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
|
||||
this.Status = {
|
||||
_log: Log.repository.getLogger("Sync.Status"),
|
||||
__authManager: null,
|
||||
ready: false,
|
||||
|
||||
get _authManager() {
|
||||
if (this.__authManager) {
|
||||
return this.__authManager;
|
||||
}
|
||||
let service = Components.classes["@mozilla.org/weave/service;1"]
|
||||
.getService(Components.interfaces.nsISupports)
|
||||
.wrappedJSObject;
|
||||
let idClass = service.fxAccountsEnabled ? BrowserIDManager : IdentityManager;
|
||||
this.__authManager = new idClass();
|
||||
this.__authManager.initialize();
|
||||
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_PASSWORD ||
|
||||
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 eol() {
|
||||
let modePref = PREFS_BRANCH + "errorhandler.alert.mode";
|
||||
try {
|
||||
return Services.prefs.getCharPref(modePref) == "hard-eol";
|
||||
} catch (ex) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
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() {
|
||||
let result = this._authManager.currentAuthState;
|
||||
if (result == STATUS_OK) {
|
||||
Status.service = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
Status.login = result;
|
||||
return Status.service;
|
||||
},
|
||||
|
||||
resetBackoff: function resetBackoff() {
|
||||
this.enforceBackoff = false;
|
||||
this.backoffInterval = 0;
|
||||
this.minimumNextSync = 0;
|
||||
},
|
||||
|
||||
resetSync: function resetSync() {
|
||||
// Logger setup.
|
||||
let logPref = PREFS_BRANCH + "log.logger.status";
|
||||
let logLevel = "Trace";
|
||||
try {
|
||||
logLevel = Services.prefs.getCharPref(logPref);
|
||||
} catch (ex) {
|
||||
// Use default.
|
||||
}
|
||||
this._log.level = Log.Level[logLevel];
|
||||
|
||||
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();
|
||||
578
services/sync/modules/telemetry.js
Normal file
578
services/sync/modules/telemetry.js
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
/* 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 {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
|
||||
|
||||
this.EXPORTED_SYMBOLS = ["SyncTelemetry"];
|
||||
|
||||
Cu.import("resource://services-sync/browserid_identity.js");
|
||||
Cu.import("resource://services-sync/main.js");
|
||||
Cu.import("resource://services-sync/status.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://services-common/observers.js");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/TelemetryController.jsm");
|
||||
Cu.import("resource://gre/modules/FxAccounts.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
Cu.import("resource://gre/modules/osfile.jsm", this);
|
||||
|
||||
let constants = {};
|
||||
Cu.import("resource://services-sync/constants.js", constants);
|
||||
|
||||
var fxAccountsCommon = {};
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js", fxAccountsCommon);
|
||||
|
||||
XPCOMUtils.defineLazyServiceGetter(this, "Telemetry",
|
||||
"@mozilla.org/base/telemetry;1",
|
||||
"nsITelemetry");
|
||||
|
||||
const log = Log.repository.getLogger("Sync.Telemetry");
|
||||
|
||||
const TOPICS = [
|
||||
"profile-before-change",
|
||||
"weave:service:sync:start",
|
||||
"weave:service:sync:finish",
|
||||
"weave:service:sync:error",
|
||||
|
||||
"weave:engine:sync:start",
|
||||
"weave:engine:sync:finish",
|
||||
"weave:engine:sync:error",
|
||||
"weave:engine:sync:applied",
|
||||
"weave:engine:sync:uploaded",
|
||||
"weave:engine:validate:finish",
|
||||
"weave:engine:validate:error",
|
||||
];
|
||||
|
||||
const PING_FORMAT_VERSION = 1;
|
||||
|
||||
// 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"]);
|
||||
|
||||
// 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 transformError(error, engineName) {
|
||||
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 };
|
||||
}
|
||||
// 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]");
|
||||
return { name: "unexpectederror", error };
|
||||
}
|
||||
|
||||
if (error.failureCode) {
|
||||
return { name: "othererror", error: error.failureCode };
|
||||
}
|
||||
|
||||
if (error instanceof AuthenticationError) {
|
||||
return { name: "autherror", from: error.source };
|
||||
}
|
||||
|
||||
if (error instanceof Ci.mozIStorageError) {
|
||||
return { name: "sqlerror", code: error.result };
|
||||
}
|
||||
|
||||
let httpCode = error.status ||
|
||||
(error.response && error.response.status) ||
|
||||
error.code;
|
||||
|
||||
if (httpCode) {
|
||||
return { name: "httperror", code: httpCode };
|
||||
}
|
||||
|
||||
if (error.result) {
|
||||
return { name: "nserror", code: error.result };
|
||||
}
|
||||
|
||||
return {
|
||||
name: "unexpectederror",
|
||||
// as above, remove the profile dir value.
|
||||
error: String(error).replace(reProfileDir, "[profileDir]")
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
let result = Object.assign({}, this);
|
||||
delete result.startTime;
|
||||
return result;
|
||||
}
|
||||
|
||||
finished(error) {
|
||||
let took = timeDeltaFrom(this.startTime);
|
||||
if (took > 0) {
|
||||
this.took = took;
|
||||
}
|
||||
if (error) {
|
||||
this.failureReason = transformError(error, this.name);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recordValidation(validationResult) {
|
||||
if (this.validation) {
|
||||
log.error(`Multiple validations occurred for engine ${this.name}!`);
|
||||
return;
|
||||
}
|
||||
let { problems, version, duration, recordCount } = validationResult;
|
||||
let validation = {
|
||||
version: version || 0,
|
||||
checked: recordCount || 0,
|
||||
};
|
||||
if (duration > 0) {
|
||||
validation.took = Math.round(duration);
|
||||
}
|
||||
let summarized = problems.getSummary(true).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: 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TelemetryRecord {
|
||||
constructor(allowedEngines) {
|
||||
this.allowedEngines = allowedEngines;
|
||||
// Our failure reason. This property only exists in the generated ping if an
|
||||
// error actually occurred.
|
||||
this.failureReason = undefined;
|
||||
this.uid = "";
|
||||
this.when = Date.now();
|
||||
this.startTime = tryGetMonotonicTimestamp();
|
||||
this.took = 0; // will be set later.
|
||||
|
||||
// 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,
|
||||
uid: this.uid,
|
||||
took: this.took,
|
||||
failureReason: this.failureReason,
|
||||
status: this.status,
|
||||
deviceID: this.deviceID,
|
||||
devices: this.devices,
|
||||
};
|
||||
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 = transformError(error);
|
||||
}
|
||||
|
||||
// We don't bother including the "devices" field if we can't come up with a
|
||||
// UID or device ID for *this* device -- If that's the case, any data we'd
|
||||
// put there would be likely to be full of garbage anyway.
|
||||
let includeDeviceInfo = false;
|
||||
try {
|
||||
this.uid = Weave.Service.identity.hashedUID();
|
||||
let deviceID = Weave.Service.identity.deviceID();
|
||||
if (deviceID) {
|
||||
// Combine the raw device id with the metrics uid to create a stable
|
||||
// unique identifier that can't be mapped back to the user's FxA
|
||||
// identity without knowing the metrics HMAC key.
|
||||
this.deviceID = Utils.sha256(deviceID + this.uid);
|
||||
includeDeviceInfo = true;
|
||||
}
|
||||
} catch (e) {
|
||||
this.uid = "0".repeat(32);
|
||||
this.deviceID = undefined;
|
||||
}
|
||||
|
||||
if (includeDeviceInfo) {
|
||||
let remoteDevices = Weave.Service.clientsEngine.remoteClients;
|
||||
this.devices = remoteDevices.map(device => {
|
||||
return {
|
||||
os: device.os,
|
||||
version: device.version,
|
||||
id: Utils.sha256(device.id + this.uid)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
class SyncTelemetryImpl {
|
||||
constructor(allowedEngines) {
|
||||
log.level = Log.Level[Svc.Prefs.get("log.logger.telemetry", "Trace")];
|
||||
// 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.maxPayloadCount = Svc.Prefs.get("telemetry.maxPayloadCount");
|
||||
this.submissionInterval = Svc.Prefs.get("telemetry.submissionInterval") * 1000;
|
||||
this.lastSubmissionTime = Telemetry.msSinceProcessStart();
|
||||
}
|
||||
|
||||
getPingJSON(reason) {
|
||||
return {
|
||||
why: reason,
|
||||
discarded: this.discarded || undefined,
|
||||
version: PING_FORMAT_VERSION,
|
||||
syncs: this.payloads.slice(),
|
||||
};
|
||||
}
|
||||
|
||||
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.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) {
|
||||
// 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.
|
||||
if (record.syncs.length) {
|
||||
log.trace(`submitting ${record.syncs.length} sync record(s) to telemetry`);
|
||||
TelemetryController.submitExternalPing("sync", record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onSyncStarted() {
|
||||
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 TelemetryRecord(this.allowedEngines);
|
||||
}
|
||||
|
||||
_checkCurrent(topic) {
|
||||
if (!this.current) {
|
||||
log.warn(`Observed notification ${topic} but no current sync is being recorded.`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
onSyncFinished(error) {
|
||||
if (!this.current) {
|
||||
log.warn("onSyncFinished but we aren't recording");
|
||||
return;
|
||||
}
|
||||
this.current.finished(error);
|
||||
if (this.payloads.length < this.maxPayloadCount) {
|
||||
this.payloads.push(this.current.toJSON());
|
||||
} else {
|
||||
++this.discarded;
|
||||
}
|
||||
this.current = null;
|
||||
if ((Telemetry.msSinceProcessStart() - this.lastSubmissionTime) > this.submissionInterval) {
|
||||
this.finish("schedule");
|
||||
this.lastSubmissionTime = Telemetry.msSinceProcessStart();
|
||||
}
|
||||
}
|
||||
|
||||
observe(subject, topic, data) {
|
||||
log.trace(`observed ${topic} ${data}`);
|
||||
|
||||
switch (topic) {
|
||||
case "profile-before-change":
|
||||
this.shutdown();
|
||||
break;
|
||||
|
||||
/* sync itself state changes */
|
||||
case "weave:service:sync:start":
|
||||
this.onSyncStarted();
|
||||
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: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;
|
||||
|
||||
default:
|
||||
log.warn(`unexpected observer topic ${topic}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.SyncTelemetry = new SyncTelemetryImpl(ENGINES);
|
||||
224
services/sync/modules/userapi.js
Normal file
224
services/sync/modules/userapi.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/* 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";
|
||||
|
||||
this.EXPORTED_SYMBOLS = [
|
||||
"UserAPI10Client",
|
||||
];
|
||||
|
||||
var {utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/rest.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-sync/identity.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
/**
|
||||
* A generic client for the user API 1.0 service.
|
||||
*
|
||||
* http://docs.services.mozilla.com/reg/apis.html
|
||||
*
|
||||
* Instances are constructed with the base URI of the service.
|
||||
*/
|
||||
this.UserAPI10Client = function UserAPI10Client(baseURI) {
|
||||
this._log = Log.repository.getLogger("Sync.UserAPI");
|
||||
this._log.level = Log.Level[Svc.Prefs.get("log.logger.userapi")];
|
||||
|
||||
this.baseURI = baseURI;
|
||||
}
|
||||
UserAPI10Client.prototype = {
|
||||
USER_CREATE_ERROR_CODES: {
|
||||
2: "Incorrect or missing captcha.",
|
||||
4: "User exists.",
|
||||
6: "JSON parse failure.",
|
||||
7: "Missing password field.",
|
||||
9: "Requested password not strong enough.",
|
||||
12: "No email address on file.",
|
||||
},
|
||||
|
||||
/**
|
||||
* Determine whether a specified username exists.
|
||||
*
|
||||
* Callback receives the following arguments:
|
||||
*
|
||||
* (Error) Describes error that occurred or null if request was
|
||||
* successful.
|
||||
* (boolean) True if user exists. False if not. null if there was an error.
|
||||
*/
|
||||
usernameExists: function usernameExists(username, cb) {
|
||||
if (typeof(cb) != "function") {
|
||||
throw new Error("cb must be a function.");
|
||||
}
|
||||
|
||||
let url = this.baseURI + username;
|
||||
let request = new RESTRequest(url);
|
||||
request.get(this._onUsername.bind(this, cb, request));
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtain the Weave (Sync) node for a specified user.
|
||||
*
|
||||
* The callback receives the following arguments:
|
||||
*
|
||||
* (Error) Describes error that occurred or null if request was successful.
|
||||
* (string) Username request is for.
|
||||
* (string) URL of user's node. If null and there is no error, no node could
|
||||
* be assigned at the time of the request.
|
||||
*/
|
||||
getWeaveNode: function getWeaveNode(username, password, cb) {
|
||||
if (typeof(cb) != "function") {
|
||||
throw new Error("cb must be a function.");
|
||||
}
|
||||
|
||||
let request = this._getRequest(username, "/node/weave", password);
|
||||
request.get(this._onWeaveNode.bind(this, cb, request));
|
||||
},
|
||||
|
||||
/**
|
||||
* Change a password for the specified user.
|
||||
*
|
||||
* @param username
|
||||
* (string) The username whose password to change.
|
||||
* @param oldPassword
|
||||
* (string) The old, current password.
|
||||
* @param newPassword
|
||||
* (string) The new password to switch to.
|
||||
*/
|
||||
changePassword: function changePassword(username, oldPassword, newPassword, cb) {
|
||||
let request = this._getRequest(username, "/password", oldPassword);
|
||||
request.onComplete = this._onChangePassword.bind(this, cb, request);
|
||||
request.post(CommonUtils.encodeUTF8(newPassword));
|
||||
},
|
||||
|
||||
createAccount: function createAccount(email, password, captchaChallenge,
|
||||
captchaResponse, cb) {
|
||||
let username = IdentityManager.prototype.usernameFromAccount(email);
|
||||
let body = JSON.stringify({
|
||||
"email": email,
|
||||
"password": Utils.encodeUTF8(password),
|
||||
"captcha-challenge": captchaChallenge,
|
||||
"captcha-response": captchaResponse
|
||||
});
|
||||
|
||||
let url = this.baseURI + username;
|
||||
let request = new RESTRequest(url);
|
||||
|
||||
if (this.adminSecret) {
|
||||
request.setHeader("X-Weave-Secret", this.adminSecret);
|
||||
}
|
||||
|
||||
request.onComplete = this._onCreateAccount.bind(this, cb, request);
|
||||
request.put(body);
|
||||
},
|
||||
|
||||
_getRequest: function _getRequest(username, path, password=null) {
|
||||
let url = this.baseURI + username + path;
|
||||
let request = new RESTRequest(url);
|
||||
|
||||
if (password) {
|
||||
let up = username + ":" + password;
|
||||
request.setHeader("authorization", "Basic " + btoa(up));
|
||||
}
|
||||
|
||||
return request;
|
||||
},
|
||||
|
||||
_onUsername: function _onUsername(cb, request, error) {
|
||||
if (error) {
|
||||
cb(error, null);
|
||||
return;
|
||||
}
|
||||
|
||||
let body = request.response.body;
|
||||
if (body == "0") {
|
||||
cb(null, false);
|
||||
return;
|
||||
} else if (body == "1") {
|
||||
cb(null, true);
|
||||
return;
|
||||
} else {
|
||||
cb(new Error("Unknown response from server: " + body), null);
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
_onWeaveNode: function _onWeaveNode(cb, request, error) {
|
||||
if (error) {
|
||||
cb.network = true;
|
||||
cb(error, null);
|
||||
return;
|
||||
}
|
||||
|
||||
let response = request.response;
|
||||
|
||||
if (response.status == 200) {
|
||||
let body = response.body;
|
||||
if (body == "null") {
|
||||
cb(null, null);
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, body);
|
||||
return;
|
||||
}
|
||||
|
||||
error = new Error("Sync node retrieval failed.");
|
||||
switch (response.status) {
|
||||
case 400:
|
||||
error.denied = true;
|
||||
break;
|
||||
case 404:
|
||||
error.notFound = true;
|
||||
break;
|
||||
default:
|
||||
error.message = "Unexpected response code: " + response.status;
|
||||
}
|
||||
|
||||
cb(error, null);
|
||||
return;
|
||||
},
|
||||
|
||||
_onChangePassword: function _onChangePassword(cb, request, error) {
|
||||
this._log.info("Password change response received: " +
|
||||
request.response.status);
|
||||
if (error) {
|
||||
cb(error);
|
||||
return;
|
||||
}
|
||||
|
||||
let response = request.response;
|
||||
if (response.status != 200) {
|
||||
cb(new Error("Password changed failed: " + response.body));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null);
|
||||
},
|
||||
|
||||
_onCreateAccount: function _onCreateAccount(cb, request, error) {
|
||||
let response = request.response;
|
||||
|
||||
this._log.info("Create account response: " + response.status + " " +
|
||||
response.body);
|
||||
|
||||
if (error) {
|
||||
cb(new Error("HTTP transport error."), null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status == 200) {
|
||||
cb(null, response.body);
|
||||
return;
|
||||
}
|
||||
|
||||
error = new Error("Could not create user.");
|
||||
error.body = response.body;
|
||||
|
||||
cb(error, null);
|
||||
return;
|
||||
},
|
||||
};
|
||||
Object.freeze(UserAPI10Client.prototype);
|
||||
797
services/sync/modules/util.js
Normal file
797
services/sync/modules/util.js
Normal file
|
|
@ -0,0 +1,797 @@
|
|||
/* 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.EXPORTED_SYMBOLS = ["XPCOMUtils", "Services", "Utils", "Async", "Svc", "Str"];
|
||||
|
||||
var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://services-common/observers.js");
|
||||
Cu.import("resource://services-common/stringbundle.js");
|
||||
Cu.import("resource://services-common/utils.js");
|
||||
Cu.import("resource://services-common/async.js", this);
|
||||
Cu.import("resource://services-crypto/utils.js");
|
||||
Cu.import("resource://services-sync/constants.js");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm", this);
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm", this);
|
||||
Cu.import("resource://gre/modules/osfile.jsm", this);
|
||||
Cu.import("resource://gre/modules/Task.jsm", this);
|
||||
|
||||
// FxAccountsCommon.js doesn't use a "namespace", so create one here.
|
||||
XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function() {
|
||||
let FxAccountsCommon = {};
|
||||
Cu.import("resource://gre/modules/FxAccountsCommon.js", FxAccountsCommon);
|
||||
return FxAccountsCommon;
|
||||
});
|
||||
|
||||
/*
|
||||
* Utility functions
|
||||
*/
|
||||
|
||||
this.Utils = {
|
||||
// Alias in functions from CommonUtils. These previously were defined here.
|
||||
// In the ideal world, references to these would be removed.
|
||||
nextTick: CommonUtils.nextTick,
|
||||
namedTimer: CommonUtils.namedTimer,
|
||||
makeURI: CommonUtils.makeURI,
|
||||
encodeUTF8: CommonUtils.encodeUTF8,
|
||||
decodeUTF8: CommonUtils.decodeUTF8,
|
||||
safeAtoB: CommonUtils.safeAtoB,
|
||||
byteArrayToString: CommonUtils.byteArrayToString,
|
||||
bytesAsHex: CommonUtils.bytesAsHex,
|
||||
hexToBytes: CommonUtils.hexToBytes,
|
||||
encodeBase32: CommonUtils.encodeBase32,
|
||||
decodeBase32: CommonUtils.decodeBase32,
|
||||
|
||||
// Aliases from CryptoUtils.
|
||||
generateRandomBytes: CryptoUtils.generateRandomBytes,
|
||||
computeHTTPMACSHA1: CryptoUtils.computeHTTPMACSHA1,
|
||||
digestUTF8: CryptoUtils.digestUTF8,
|
||||
digestBytes: CryptoUtils.digestBytes,
|
||||
sha1: CryptoUtils.sha1,
|
||||
sha1Base32: CryptoUtils.sha1Base32,
|
||||
sha256: CryptoUtils.sha256,
|
||||
makeHMACKey: CryptoUtils.makeHMACKey,
|
||||
makeHMACHasher: CryptoUtils.makeHMACHasher,
|
||||
hkdfExpand: CryptoUtils.hkdfExpand,
|
||||
pbkdf2Generate: CryptoUtils.pbkdf2Generate,
|
||||
deriveKeyFromPassphrase: CryptoUtils.deriveKeyFromPassphrase,
|
||||
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);
|
||||
this._userAgent =
|
||||
Services.appinfo.name + "/" + Services.appinfo.version + // Product.
|
||||
" (" + hph.oscpu + ")" + // (oscpu)
|
||||
" FxSync/" + WEAVE_VERSION + "." + // Sync.
|
||||
Services.appinfo.appBuildID + "."; // Build.
|
||||
}
|
||||
return this._userAgent + Svc.Prefs.get("client.type", "desktop");
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap a 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: function Utils_catch(func, exceptionCallback) {
|
||||
let thisArg = this;
|
||||
return function WrappedCatch() {
|
||||
try {
|
||||
return func.call(thisArg);
|
||||
}
|
||||
catch(ex) {
|
||||
thisArg._log.debug("Exception calling " + (func.name || "anonymous function"), ex);
|
||||
if (exceptionCallback) {
|
||||
return exceptionCallback.call(thisArg, ex);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap a function to call lock before calling the function then unlock.
|
||||
*
|
||||
* @usage MyObj._lock = Utils.lock;
|
||||
* MyObj.foo = function() { this._lock(func)(); }
|
||||
*/
|
||||
lock: function lock(label, func) {
|
||||
let thisArg = this;
|
||||
return function WrappedLock() {
|
||||
if (!thisArg.lock()) {
|
||||
throw "Could not acquire lock. Label: \"" + label + "\".";
|
||||
}
|
||||
|
||||
try {
|
||||
return func.call(thisArg);
|
||||
}
|
||||
finally {
|
||||
thisArg.unlock();
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
isLockException: function isLockException(ex) {
|
||||
return ex && ex.indexOf && ex.indexOf("Could not acquire lock.") == 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Wrap 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", function () {
|
||||
* //...
|
||||
* }(),
|
||||
* };
|
||||
*/
|
||||
notify: function Utils_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 function WrappedNotify() {
|
||||
try {
|
||||
notify("start", null);
|
||||
let ret = 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.generateRandomBytes(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;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
lazyStrings: function Weave_lazyStrings(name) {
|
||||
let bundle = "chrome://weave/locale/services/" + name + ".properties";
|
||||
return () => new StringBundle(bundle);
|
||||
},
|
||||
|
||||
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 "Record SHA256 HMAC mismatch: should be " + shouldBe + ", is " + is;
|
||||
},
|
||||
|
||||
isHMACMismatch: function isHMACMismatch(ex) {
|
||||
const hmacFail = "Record SHA256 HMAC mismatch: ";
|
||||
return ex && ex.indexOf && (ex.indexOf(hmacFail) == 0);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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(
|
||||
Utils.encodeBase32(keyData))
|
||||
.slice(0, SYNC_KEY_ENCODED_LENGTH);
|
||||
},
|
||||
|
||||
decodeKeyBase32: function decodeKeyBase32(encoded) {
|
||||
return Utils.decodeBase32(
|
||||
Utils.base32FromFriendly(
|
||||
Utils.normalizePassphrase(encoded)))
|
||||
.slice(0, SYNC_KEY_DECODED_LENGTH);
|
||||
},
|
||||
|
||||
base64Key: function base64Key(keyData) {
|
||||
return btoa(keyData);
|
||||
},
|
||||
|
||||
/**
|
||||
* N.B., salt should be base64 encoded, even though we have to decode
|
||||
* it later!
|
||||
*/
|
||||
derivePresentableKeyFromPassphrase : function derivePresentableKeyFromPassphrase(passphrase, salt, keyLength, forceJS) {
|
||||
let k = CryptoUtils.deriveKeyFromPassphrase(passphrase, salt, keyLength,
|
||||
forceJS);
|
||||
return Utils.encodeKeyBase32(k);
|
||||
},
|
||||
|
||||
/**
|
||||
* N.B., salt should be base64 encoded, even though we have to decode
|
||||
* it later!
|
||||
*/
|
||||
deriveEncodedKeyFromPassphrase : function deriveEncodedKeyFromPassphrase(passphrase, salt, keyLength, forceJS) {
|
||||
let k = CryptoUtils.deriveKeyFromPassphrase(passphrase, salt, keyLength,
|
||||
forceJS);
|
||||
return Utils.base64Key(k);
|
||||
},
|
||||
|
||||
/**
|
||||
* Take a base64-encoded 128-bit AES key, returning it as five groups of five
|
||||
* uppercase alphanumeric characters, separated by hyphens.
|
||||
* A.K.A. base64-to-base32 encoding.
|
||||
*/
|
||||
presentEncodedKeyAsSyncKey : function presentEncodedKeyAsSyncKey(encodedKey) {
|
||||
return Utils.encodeKeyBase32(atob(encodedKey));
|
||||
},
|
||||
|
||||
/**
|
||||
* 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 and "this" for callback.
|
||||
* @param callback
|
||||
* Function to process json object as its first argument. If the file
|
||||
* could not be loaded, the first argument will be undefined.
|
||||
*/
|
||||
jsonLoad: Task.async(function*(filePath, that, callback) {
|
||||
let path = OS.Path.join(OS.Constants.Path.profileDir, "weave", filePath + ".json");
|
||||
|
||||
if (that._log) {
|
||||
that._log.trace("Loading json from disk: " + filePath);
|
||||
}
|
||||
|
||||
let json;
|
||||
|
||||
try {
|
||||
json = yield CommonUtils.readJSON(path);
|
||||
} catch (e) {
|
||||
if (e instanceof OS.File.Error && e.becauseNoSuchFile) {
|
||||
// Ignore non-existent files, but explicitly return null.
|
||||
json = null;
|
||||
} else {
|
||||
if (that._log) {
|
||||
that._log.debug("Failed to load json", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback.call(that, json);
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 and "this" for callback
|
||||
* @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.
|
||||
* @param callback
|
||||
* Function called when the write has been performed. Optional.
|
||||
* The first argument will be a Components.results error
|
||||
* constant on error or null if no error was encountered (and
|
||||
* the file saved successfully).
|
||||
*/
|
||||
jsonSave: Task.async(function*(filePath, that, obj, callback) {
|
||||
let path = OS.Path.join(OS.Constants.Path.profileDir, "weave",
|
||||
...(filePath + ".json").split("/"));
|
||||
let dir = OS.Path.dirname(path);
|
||||
let error = null;
|
||||
|
||||
try {
|
||||
yield 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;
|
||||
|
||||
yield CommonUtils.writeJSON(json, path);
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
|
||||
if (typeof callback == "function") {
|
||||
callback.call(that, error);
|
||||
}
|
||||
}),
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
},
|
||||
|
||||
getErrorString: function Utils_getErrorString(error, args) {
|
||||
try {
|
||||
return Str.errors.get(error, args || null);
|
||||
} catch (e) {}
|
||||
|
||||
// basically returns "Unknown Error"
|
||||
return Str.errors.get("error.reason.unknown");
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate 26 characters.
|
||||
*/
|
||||
generatePassphrase: function generatePassphrase() {
|
||||
// Note that this is a different base32 alphabet to the one we use for
|
||||
// other tasks. It's lowercase, uses different letters, and needs to be
|
||||
// decoded with decodeKeyBase32, not just decodeBase32.
|
||||
return Utils.encodeKeyBase32(CryptoUtils.generateRandomBytes(16));
|
||||
},
|
||||
|
||||
/**
|
||||
* The following are the methods supported for UI use:
|
||||
*
|
||||
* * isPassphrase:
|
||||
* determines whether a string is either a normalized or presentable
|
||||
* passphrase.
|
||||
* * hyphenatePassphrase:
|
||||
* present a normalized passphrase for display. This might actually
|
||||
* perform work beyond just hyphenation; sorry.
|
||||
* * hyphenatePartialPassphrase:
|
||||
* present a fragment of a normalized passphrase for display.
|
||||
* * normalizePassphrase:
|
||||
* take a presentable passphrase and reduce it to a normalized
|
||||
* representation for storage. normalizePassphrase can safely be called
|
||||
* on normalized input.
|
||||
* * normalizeAccount:
|
||||
* take user input for account/username, cleaning up appropriately.
|
||||
*/
|
||||
|
||||
isPassphrase: function(s) {
|
||||
if (s) {
|
||||
return /^[abcdefghijkmnpqrstuvwxyz23456789]{26}$/.test(Utils.normalizePassphrase(s));
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hyphenate a passphrase (26 characters) into groups.
|
||||
* abbbbccccddddeeeeffffggggh
|
||||
* =>
|
||||
* a-bbbbc-cccdd-ddeee-effff-ggggh
|
||||
*/
|
||||
hyphenatePassphrase: function hyphenatePassphrase(passphrase) {
|
||||
// For now, these are the same.
|
||||
return Utils.hyphenatePartialPassphrase(passphrase, true);
|
||||
},
|
||||
|
||||
hyphenatePartialPassphrase: function hyphenatePartialPassphrase(passphrase, omitTrailingDash) {
|
||||
if (!passphrase)
|
||||
return null;
|
||||
|
||||
// Get the raw data input. Just base32.
|
||||
let data = passphrase.toLowerCase().replace(/[^abcdefghijkmnpqrstuvwxyz23456789]/g, "");
|
||||
|
||||
// This is the neatest way to do this.
|
||||
if ((data.length == 1) && !omitTrailingDash)
|
||||
return data + "-";
|
||||
|
||||
// Hyphenate it.
|
||||
let y = data.substr(0,1);
|
||||
let z = data.substr(1).replace(/(.{1,5})/g, "-$1");
|
||||
|
||||
// Correct length? We're done.
|
||||
if ((z.length == 30) || omitTrailingDash)
|
||||
return y + z;
|
||||
|
||||
// Add a trailing dash if appropriate.
|
||||
return (y + z.replace(/([^-]{5})$/, "$1-")).substr(0, SYNC_KEY_HYPHENATED_LENGTH);
|
||||
},
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
normalizeAccount: function normalizeAccount(acc) {
|
||||
return acc.trim();
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
return minuend.filter(i => subtrahend.indexOf(i) == -1);
|
||||
},
|
||||
|
||||
/**
|
||||
* 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));
|
||||
},
|
||||
|
||||
bind2: function Async_bind2(object, method) {
|
||||
return function innerBind() { return method.apply(object, arguments); };
|
||||
},
|
||||
|
||||
/**
|
||||
* Is there a master password configured, regardless of current lock state?
|
||||
*/
|
||||
mpEnabled: function mpEnabled() {
|
||||
let modules = Cc["@mozilla.org/security/pkcs11moduledb;1"]
|
||||
.getService(Ci.nsIPKCS11ModuleDB);
|
||||
let sdrSlot = modules.findSlotByName("");
|
||||
let status = sdrSlot.status;
|
||||
let slots = Ci.nsIPKCS11Slot;
|
||||
|
||||
return status != slots.SLOT_UNINITIALIZED && status != slots.SLOT_READY;
|
||||
},
|
||||
|
||||
/**
|
||||
* Is there a master password configured and currently locked?
|
||||
*/
|
||||
mpLocked: function mpLocked() {
|
||||
let modules = Cc["@mozilla.org/security/pkcs11moduledb;1"]
|
||||
.getService(Ci.nsIPKCS11ModuleDB);
|
||||
let sdrSlot = modules.findSlotByName("");
|
||||
let status = sdrSlot.status;
|
||||
let slots = Ci.nsIPKCS11Slot;
|
||||
|
||||
if (status == slots.SLOT_READY || status == slots.SLOT_LOGGED_IN
|
||||
|| status == slots.SLOT_UNINITIALIZED)
|
||||
return false;
|
||||
|
||||
if (status == slots.SLOT_NOT_LOGGED_IN)
|
||||
return true;
|
||||
|
||||
// something wacky happened, pretend MP is locked
|
||||
return true;
|
||||
},
|
||||
|
||||
// If Master Password is enabled and locked, present a dialog to unlock it.
|
||||
// Return whether the system is unlocked.
|
||||
ensureMPUnlocked: function ensureMPUnlocked() {
|
||||
if (!Utils.mpLocked()) {
|
||||
return true;
|
||||
}
|
||||
let sdr = Cc["@mozilla.org/security/sdr;1"]
|
||||
.getService(Ci.nsISecretDecoderRing);
|
||||
try {
|
||||
sdr.encryptString("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: function() {
|
||||
let result = new Set(this.getSyncCredentialsHostsLegacy());
|
||||
for (let host of this.getSyncCredentialsHostsFxA()) {
|
||||
result.add(host);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
|
||||
/*
|
||||
* Get the "legacy" identity hosts.
|
||||
*/
|
||||
getSyncCredentialsHostsLegacy: function() {
|
||||
// the legacy sync host
|
||||
return new Set([PWDMGR_HOST]);
|
||||
},
|
||||
|
||||
/*
|
||||
* Get the FxA identity hosts.
|
||||
*/
|
||||
getSyncCredentialsHostsFxA: function() {
|
||||
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;
|
||||
},
|
||||
|
||||
getDefaultDeviceName() {
|
||||
// Generate a client name if we don't have a useful one yet
|
||||
let env = Cc["@mozilla.org/process/environment;1"]
|
||||
.getService(Ci.nsIEnvironment);
|
||||
let user = env.get("USER") || env.get("USERNAME") ||
|
||||
Svc.Prefs.get("account") || Svc.Prefs.get("username");
|
||||
// A little hack for people using the the moz-build environment on Windows
|
||||
// which sets USER to the literal "%USERNAME%" (yes, really)
|
||||
if (user == "%USERNAME%" && env.get("USERNAME")) {
|
||||
user = env.get("USERNAME");
|
||||
}
|
||||
|
||||
let brand = new StringBundle("chrome://branding/locale/brand.properties");
|
||||
let brandName = brand.get("brandShortName");
|
||||
|
||||
let appName;
|
||||
try {
|
||||
let syncStrings = new StringBundle("chrome://browser/locale/sync.properties");
|
||||
appName = syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]);
|
||||
} catch (ex) {}
|
||||
appName = appName || brandName;
|
||||
|
||||
let system =
|
||||
// 'device' is defined on unix systems
|
||||
Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("device") ||
|
||||
// hostname of the system, usually assigned by the user or admin
|
||||
Cc["@mozilla.org/system-info;1"].getService(Ci.nsIPropertyBag2).get("host") ||
|
||||
// fall back on ua info string
|
||||
Cc["@mozilla.org/network/protocol;1?name=http"].getService(Ci.nsIHttpProtocolHandler).oscpu;
|
||||
|
||||
return Str.sync.get("client.name2", [user, appName, system]);
|
||||
},
|
||||
|
||||
getDeviceName() {
|
||||
const deviceName = Svc.Prefs.get("client.name", "");
|
||||
|
||||
if (deviceName === "") {
|
||||
return this.getDefaultDeviceName();
|
||||
}
|
||||
|
||||
return deviceName;
|
||||
},
|
||||
|
||||
getDeviceType() {
|
||||
return Svc.Prefs.get("client.type", DEVICE_TYPE_DESKTOP);
|
||||
},
|
||||
|
||||
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}`;
|
||||
}
|
||||
};
|
||||
|
||||
XPCOMUtils.defineLazyGetter(Utils, "_utf8Converter", function() {
|
||||
let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]
|
||||
.createInstance(Ci.nsIScriptableUnicodeConverter);
|
||||
converter.charset = "UTF-8";
|
||||
return converter;
|
||||
});
|
||||
|
||||
/*
|
||||
* Commonly-used services
|
||||
*/
|
||||
this.Svc = {};
|
||||
Svc.Prefs = new Preferences(PREFS_BRANCH);
|
||||
Svc.DefaultPrefs = new Preferences({branch: PREFS_BRANCH, defaultBranch: true});
|
||||
Svc.Obs = Observers;
|
||||
|
||||
var _sessionCID = Services.appinfo.ID == SEAMONKEY_ID ?
|
||||
"@mozilla.org/suite/sessionstore;1" :
|
||||
"@mozilla.org/browser/sessionstore;1";
|
||||
|
||||
[
|
||||
["Idle", "@mozilla.org/widget/idleservice;1", "nsIIdleService"],
|
||||
["Session", _sessionCID, "nsISessionStore"]
|
||||
].forEach(function([name, contract, iface]) {
|
||||
XPCOMUtils.defineLazyServiceGetter(Svc, name, contract, iface);
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyModuleGetter(Svc, "FormHistory", "resource://gre/modules/FormHistory.jsm");
|
||||
|
||||
Svc.__defineGetter__("Crypto", function() {
|
||||
let cryptoSvc;
|
||||
let ns = {};
|
||||
Cu.import("resource://services-crypto/WeaveCrypto.js", ns);
|
||||
cryptoSvc = new ns.WeaveCrypto();
|
||||
delete Svc.Crypto;
|
||||
return Svc.Crypto = cryptoSvc;
|
||||
});
|
||||
|
||||
this.Str = {};
|
||||
["errors", "sync"].forEach(function(lazy) {
|
||||
XPCOMUtils.defineLazyGetter(Str, lazy, Utils.lazyStrings(lazy));
|
||||
});
|
||||
|
||||
Svc.Obs.add("xpcom-shutdown", function () {
|
||||
for (let name in Svc)
|
||||
delete Svc[name];
|
||||
});
|
||||
78
services/sync/moz.build
Normal file
78
services/sync/moz.build
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
|
||||
# vim: set filetype=python:
|
||||
# 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/.
|
||||
|
||||
with Files('**'):
|
||||
BUG_COMPONENT = ('Mozilla Services', 'Firefox Sync: Backend')
|
||||
|
||||
DIRS += ['locales']
|
||||
|
||||
XPCSHELL_TESTS_MANIFESTS += ['tests/unit/xpcshell.ini']
|
||||
|
||||
EXTRA_COMPONENTS += [
|
||||
'SyncComponents.manifest',
|
||||
'Weave.js',
|
||||
]
|
||||
|
||||
EXTRA_JS_MODULES['services-sync'] += [
|
||||
'modules/addonsreconciler.js',
|
||||
'modules/addonutils.js',
|
||||
'modules/bookmark_validator.js',
|
||||
'modules/browserid_identity.js',
|
||||
'modules/collection_validator.js',
|
||||
'modules/engines.js',
|
||||
'modules/FxaMigrator.jsm',
|
||||
'modules/identity.js',
|
||||
'modules/jpakeclient.js',
|
||||
'modules/keys.js',
|
||||
'modules/main.js',
|
||||
'modules/policies.js',
|
||||
'modules/record.js',
|
||||
'modules/resource.js',
|
||||
'modules/rest.js',
|
||||
'modules/service.js',
|
||||
'modules/status.js',
|
||||
'modules/SyncedTabs.jsm',
|
||||
'modules/telemetry.js',
|
||||
'modules/userapi.js',
|
||||
'modules/util.js',
|
||||
]
|
||||
|
||||
EXTRA_PP_JS_MODULES['services-sync'] += [
|
||||
'modules/constants.js',
|
||||
]
|
||||
|
||||
# Definitions used by constants.js
|
||||
DEFINES['weave_version'] = '1.54.0'
|
||||
DEFINES['weave_id'] = '{340c2bbc-ce74-4362-90b5-7c26312808ef}'
|
||||
|
||||
EXTRA_JS_MODULES['services-sync'].engines += [
|
||||
'modules/engines/addons.js',
|
||||
'modules/engines/bookmarks.js',
|
||||
'modules/engines/clients.js',
|
||||
'modules/engines/extension-storage.js',
|
||||
'modules/engines/forms.js',
|
||||
'modules/engines/history.js',
|
||||
'modules/engines/passwords.js',
|
||||
'modules/engines/prefs.js',
|
||||
'modules/engines/tabs.js',
|
||||
]
|
||||
|
||||
EXTRA_JS_MODULES['services-sync'].stages += [
|
||||
'modules/stages/cluster.js',
|
||||
'modules/stages/declined.js',
|
||||
'modules/stages/enginesync.js',
|
||||
]
|
||||
|
||||
TESTING_JS_MODULES.services.sync += [
|
||||
'modules-testing/fakeservices.js',
|
||||
'modules-testing/fxa_utils.js',
|
||||
'modules-testing/rotaryengine.js',
|
||||
'modules-testing/utils.js',
|
||||
]
|
||||
|
||||
JS_PREFERENCE_FILES += [
|
||||
'services-sync.js',
|
||||
]
|
||||
95
services/sync/services-sync.js
Normal file
95
services/sync/services-sync.js
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
/* 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/. */
|
||||
|
||||
pref("services.sync.serverURL", "https://auth.services.mozilla.com/");
|
||||
pref("services.sync.userURL", "user/");
|
||||
pref("services.sync.miscURL", "misc/");
|
||||
pref("services.sync.termsURL", "https://services.mozilla.com/tos/");
|
||||
pref("services.sync.privacyURL", "https://services.mozilla.com/privacy-policy/");
|
||||
pref("services.sync.statusURL", "https://services.mozilla.com/status/");
|
||||
pref("services.sync.syncKeyHelpURL", "https://services.mozilla.com/help/synckey");
|
||||
|
||||
pref("services.sync.lastversion", "firstrun");
|
||||
pref("services.sync.sendVersionInfo", true);
|
||||
|
||||
pref("services.sync.scheduler.eolInterval", 604800); // 1 week
|
||||
pref("services.sync.scheduler.idleInterval", 3600); // 1 hour
|
||||
pref("services.sync.scheduler.activeInterval", 600); // 10 minutes
|
||||
pref("services.sync.scheduler.immediateInterval", 90); // 1.5 minutes
|
||||
pref("services.sync.scheduler.idleTime", 300); // 5 minutes
|
||||
|
||||
pref("services.sync.scheduler.fxa.singleDeviceInterval", 3600); // 1 hour
|
||||
pref("services.sync.scheduler.sync11.singleDeviceInterval", 86400); // 1 day
|
||||
|
||||
pref("services.sync.errorhandler.networkFailureReportTimeout", 1209600); // 2 weeks
|
||||
|
||||
pref("services.sync.engine.addons", true);
|
||||
pref("services.sync.engine.bookmarks", true);
|
||||
pref("services.sync.engine.history", true);
|
||||
pref("services.sync.engine.passwords", true);
|
||||
pref("services.sync.engine.prefs", true);
|
||||
pref("services.sync.engine.tabs", true);
|
||||
pref("services.sync.engine.tabs.filteredUrls", "^(about:.*|chrome://weave/.*|wyciwyg:.*|file:.*|blob:.*)$");
|
||||
|
||||
pref("services.sync.jpake.serverURL", "https://setup.services.mozilla.com/");
|
||||
pref("services.sync.jpake.pollInterval", 1000);
|
||||
pref("services.sync.jpake.firstMsgMaxTries", 300); // 5 minutes
|
||||
pref("services.sync.jpake.lastMsgMaxTries", 300); // 5 minutes
|
||||
pref("services.sync.jpake.maxTries", 10);
|
||||
|
||||
// If true, add-on sync ignores changes to the user-enabled flag. This
|
||||
// allows people to have the same set of add-ons installed across all
|
||||
// profiles while maintaining different enabled states.
|
||||
pref("services.sync.addons.ignoreUserEnabledChanges", false);
|
||||
|
||||
// Comma-delimited list of hostnames to trust for add-on install.
|
||||
pref("services.sync.addons.trustedSourceHostnames", "addons.mozilla.org");
|
||||
|
||||
pref("services.sync.log.appender.console", "Fatal");
|
||||
pref("services.sync.log.appender.dump", "Error");
|
||||
pref("services.sync.log.appender.file.level", "Trace");
|
||||
pref("services.sync.log.appender.file.logOnError", true);
|
||||
pref("services.sync.log.appender.file.logOnSuccess", false);
|
||||
pref("services.sync.log.appender.file.maxErrorAge", 864000); // 10 days
|
||||
pref("services.sync.log.rootLogger", "Debug");
|
||||
pref("services.sync.log.logger.addonutils", "Debug");
|
||||
pref("services.sync.log.logger.declined", "Debug");
|
||||
pref("services.sync.log.logger.service.main", "Debug");
|
||||
pref("services.sync.log.logger.status", "Debug");
|
||||
pref("services.sync.log.logger.authenticator", "Debug");
|
||||
pref("services.sync.log.logger.network.resources", "Debug");
|
||||
pref("services.sync.log.logger.service.jpakeclient", "Debug");
|
||||
pref("services.sync.log.logger.engine.bookmarks", "Debug");
|
||||
pref("services.sync.log.logger.engine.clients", "Debug");
|
||||
pref("services.sync.log.logger.engine.forms", "Debug");
|
||||
pref("services.sync.log.logger.engine.history", "Debug");
|
||||
pref("services.sync.log.logger.engine.passwords", "Debug");
|
||||
pref("services.sync.log.logger.engine.prefs", "Debug");
|
||||
pref("services.sync.log.logger.engine.tabs", "Debug");
|
||||
pref("services.sync.log.logger.engine.addons", "Debug");
|
||||
pref("services.sync.log.logger.engine.extension-storage", "Debug");
|
||||
pref("services.sync.log.logger.engine.apps", "Debug");
|
||||
pref("services.sync.log.logger.identity", "Debug");
|
||||
pref("services.sync.log.logger.userapi", "Debug");
|
||||
pref("services.sync.log.cryptoDebug", false);
|
||||
|
||||
pref("services.sync.fxa.termsURL", "https://accounts.firefox.com/legal/terms");
|
||||
pref("services.sync.fxa.privacyURL", "https://accounts.firefox.com/legal/privacy");
|
||||
|
||||
pref("services.sync.telemetry.submissionInterval", 43200); // 12 hours in seconds
|
||||
pref("services.sync.telemetry.maxPayloadCount", 500);
|
||||
|
||||
// Note that services.sync.validation.enabled is located in browser/app/profile/firefox.js
|
||||
|
||||
// We consider validation this frequently. After considering validation, even
|
||||
// if we don't end up validating, we won't try again unless this much time has passed.
|
||||
pref("services.sync.validation.interval", 86400); // 24 hours in seconds
|
||||
|
||||
// We only run validation `services.sync.validation.percentageChance` percent of
|
||||
// the time, even if it's been the right amount of time since the last validation,
|
||||
// and you meet the maxRecord checks.
|
||||
pref("services.sync.validation.percentageChance", 10);
|
||||
|
||||
// We won't validate an engine if it has more than this many records on the server.
|
||||
pref("services.sync.validation.maxRecords", 100);
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Restartless Test XPI</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>restartless-xpi@tests.mozilla.org</guid>
|
||||
<slug>restartless-xpi</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485">http://127.0.0.1:4567/addons/restartless.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5612">
|
||||
<name>Unsigned Test XPI</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>unsigned-xpi@tests.mozilla.org</guid>
|
||||
<slug>unsigned-xpi</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="452">http://127.0.0.1:4567/addons/unsigned.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
BIN
services/sync/tests/tps/addons/restartless.xpi
Normal file
BIN
services/sync/tests/tps/addons/restartless.xpi
Normal file
Binary file not shown.
BIN
services/sync/tests/tps/addons/unsigned.xpi
Normal file
BIN
services/sync/tests/tps/addons/unsigned.xpi
Normal file
Binary file not shown.
33
services/sync/tests/tps/all_tests.json
Normal file
33
services/sync/tests/tps/all_tests.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{ "tests": [
|
||||
"test_bookmark_conflict.js",
|
||||
"test_sync.js",
|
||||
"test_prefs.js",
|
||||
"test_tabs.js",
|
||||
"test_passwords.js",
|
||||
"test_history.js",
|
||||
"test_formdata.js",
|
||||
"test_bug530717.js",
|
||||
"test_bug531489.js",
|
||||
"test_bug538298.js",
|
||||
"test_bug556509.js",
|
||||
"test_bug562515.js",
|
||||
"test_bug563989.js",
|
||||
"test_bug535326.js",
|
||||
"test_bug501528.js",
|
||||
"test_bug575423.js",
|
||||
"test_bug546807.js",
|
||||
"test_history_collision.js",
|
||||
"test_privbrw_passwords.js",
|
||||
"test_privbrw_tabs.js",
|
||||
"test_bookmarks_in_same_named_folder.js",
|
||||
"test_client_wipe.js",
|
||||
"test_special_tabs.js",
|
||||
"test_addon_sanity.js",
|
||||
"test_addon_restartless_xpi.js",
|
||||
"test_addon_nonrestartless_xpi.js",
|
||||
"test_addon_reconciling.js",
|
||||
"test_addon_wipe.js"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
30
services/sync/tests/tps/mozmill_sanity.js
Normal file
30
services/sync/tests/tps/mozmill_sanity.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* 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/. */
|
||||
|
||||
Components.utils.import('resource://tps/tps.jsm');
|
||||
|
||||
var setupModule = function(module) {
|
||||
module.controller = mozmill.getBrowserController();
|
||||
assert.ok(true, "SetupModule passes");
|
||||
}
|
||||
|
||||
var setupTest = function(module) {
|
||||
assert.ok(true, "SetupTest passes");
|
||||
}
|
||||
|
||||
var testTestStep = function() {
|
||||
assert.ok(true, "test Passes");
|
||||
controller.open("http://www.mozilla.org");
|
||||
|
||||
TPS.Login();
|
||||
TPS.Sync(ACTIONS.ACTION_SYNC_WIPE_CLIENT);
|
||||
}
|
||||
|
||||
var teardownTest = function () {
|
||||
assert.ok(true, "teardownTest passes");
|
||||
}
|
||||
|
||||
var teardownModule = function() {
|
||||
assert.ok(true, "teardownModule passes");
|
||||
}
|
||||
15
services/sync/tests/tps/mozmill_sanity2.js
Normal file
15
services/sync/tests/tps/mozmill_sanity2.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
/* 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 setupModule = function(module) {
|
||||
module.controller = mozmill.getBrowserController();
|
||||
};
|
||||
|
||||
var testGetNode = function() {
|
||||
controller.open("about:support");
|
||||
controller.waitForPageLoad();
|
||||
|
||||
var appbox = findElement.ID(controller.tabs.activeTab, "application-box");
|
||||
assert.waitFor(() => appbox.getNode().textContent == 'Firefox', 'correct app name');
|
||||
};
|
||||
105
services/sync/tests/tps/test_addon_nonrestartless_xpi.js
Normal file
105
services/sync/tests/tps/test_addon_nonrestartless_xpi.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
|
||||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// This test verifies that install of extensions that require restart
|
||||
// syncs between profiles.
|
||||
EnableEngines(["addons"]);
|
||||
|
||||
var phases = {
|
||||
"phase01": "profile1",
|
||||
"phase02": "profile1",
|
||||
"phase03": "profile2",
|
||||
"phase04": "profile2",
|
||||
"phase05": "profile1",
|
||||
"phase06": "profile1",
|
||||
"phase07": "profile2",
|
||||
"phase08": "profile2",
|
||||
"phase09": "profile1",
|
||||
"phase10": "profile1",
|
||||
"phase11": "profile2",
|
||||
"phase12": "profile2",
|
||||
"phase13": "profile1",
|
||||
"phase14": "profile1",
|
||||
"phase15": "profile2",
|
||||
"phase16": "profile2"
|
||||
};
|
||||
|
||||
const id = "unsigned-xpi@tests.mozilla.org";
|
||||
|
||||
Phase("phase01", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Addons.install, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase02", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase03", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase04", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Now we disable the add-on
|
||||
Phase("phase05", [
|
||||
[EnsureTracking],
|
||||
[Addons.setEnabled, [id], STATE_DISABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase06", [
|
||||
[Addons.verify, [id], STATE_DISABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase07", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase08", [
|
||||
[Addons.verify, [id], STATE_DISABLED],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Now we re-enable it again.
|
||||
Phase("phase09", [
|
||||
[EnsureTracking],
|
||||
[Addons.setEnabled, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase10", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase11", [
|
||||
[Addons.verify, [id], STATE_DISABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase12", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// And we uninstall it
|
||||
|
||||
Phase("phase13", [
|
||||
[EnsureTracking],
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Addons.uninstall, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase14", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase15", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase16", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
54
services/sync/tests/tps/test_addon_reconciling.js
Normal file
54
services/sync/tests/tps/test_addon_reconciling.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// This test verifies that record reconciling works as expected. It makes
|
||||
// similar changes to add-ons in separate profiles and does a sync to verify
|
||||
// the proper action is taken.
|
||||
EnableEngines(["addons"]);
|
||||
|
||||
var phases = {
|
||||
"phase01": "profile1",
|
||||
"phase02": "profile2",
|
||||
"phase03": "profile1",
|
||||
"phase04": "profile2",
|
||||
"phase05": "profile1",
|
||||
"phase06": "profile2"
|
||||
};
|
||||
|
||||
const id = "restartless-xpi@tests.mozilla.org";
|
||||
|
||||
// Install the add-on in 2 profiles.
|
||||
Phase("phase01", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Addons.install, [id]],
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase02", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync],
|
||||
[Addons.verify, [id], STATE_ENABLED]
|
||||
]);
|
||||
|
||||
// Now we disable in one and uninstall in the other.
|
||||
Phase("phase03", [
|
||||
[Sync], // Get GUID updates, potentially.
|
||||
[Addons.setEnabled, [id], STATE_DISABLED],
|
||||
// We've changed the state, but don't want this profile to sync until phase5,
|
||||
// so if we ran a validation now we'd be expecting to find errors.
|
||||
[Addons.skipValidation]
|
||||
]);
|
||||
Phase("phase04", [
|
||||
[EnsureTracking],
|
||||
[Addons.uninstall, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// When we sync, the uninstall should take precedence because it was newer.
|
||||
Phase("phase05", [
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase06", [
|
||||
[Sync],
|
||||
[Addons.verifyNot, [id]]
|
||||
]);
|
||||
70
services/sync/tests/tps/test_addon_restartless_xpi.js
Normal file
70
services/sync/tests/tps/test_addon_restartless_xpi.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// This test verifies that install of restartless extensions syncs to
|
||||
// other profiles.
|
||||
EnableEngines(["addons"]);
|
||||
|
||||
var phases = {
|
||||
"phase01": "profile1",
|
||||
"phase02": "profile2",
|
||||
"phase03": "profile1",
|
||||
"phase04": "profile2",
|
||||
"phase05": "profile1",
|
||||
"phase06": "profile2",
|
||||
"phase07": "profile1",
|
||||
"phase08": "profile2"
|
||||
};
|
||||
|
||||
const id = "restartless-xpi@tests.mozilla.org";
|
||||
|
||||
// Verify install is synced
|
||||
Phase("phase01", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Addons.install, [id]],
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase02", [
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync],
|
||||
[Addons.verify, [id], STATE_ENABLED]
|
||||
]);
|
||||
|
||||
// Now disable and see that is is synced.
|
||||
Phase("phase03", [
|
||||
[EnsureTracking],
|
||||
[Addons.setEnabled, [id], STATE_DISABLED],
|
||||
[Addons.verify, [id], STATE_DISABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase04", [
|
||||
[Sync],
|
||||
[Addons.verify, [id], STATE_DISABLED]
|
||||
]);
|
||||
|
||||
// Enable and see it is synced.
|
||||
Phase("phase05", [
|
||||
[EnsureTracking],
|
||||
[Addons.setEnabled, [id], STATE_ENABLED],
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase06", [
|
||||
[Sync],
|
||||
[Addons.verify, [id], STATE_ENABLED]
|
||||
]);
|
||||
|
||||
// Uninstall and see it is synced.
|
||||
Phase("phase07", [
|
||||
[EnsureTracking],
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Addons.uninstall, [id]],
|
||||
[Addons.verifyNot, [id]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase08", [
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync],
|
||||
[Addons.verifyNot, [id]]
|
||||
]);
|
||||
30
services/sync/tests/tps/test_addon_sanity.js
Normal file
30
services/sync/tests/tps/test_addon_sanity.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
EnableEngines(["addons"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile1" };
|
||||
|
||||
const id = "unsigned-xpi@tests.mozilla.org";
|
||||
|
||||
Phase("phase1", [
|
||||
[Addons.install, [id]],
|
||||
// Non-restartless add-on shouldn't be found after install.
|
||||
[Addons.verifyNot, [id]],
|
||||
|
||||
// But it should be marked for Sync.
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase("phase2", [
|
||||
// Add-on should be present after restart
|
||||
[Addons.verify, [id], STATE_ENABLED],
|
||||
[Sync] // Sync to ensure everything is initialized enough for the addon validator to run
|
||||
]);
|
||||
35
services/sync/tests/tps/test_addon_wipe.js
Normal file
35
services/sync/tests/tps/test_addon_wipe.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// This test ensures that a client wipe followed by an "initial" sync will
|
||||
// restore add-ons. This test should expose flaws in the reconciling logic,
|
||||
// specifically around AddonsReconciler. This test is in response to bug
|
||||
// 792990.
|
||||
|
||||
EnableEngines(["addons"]);
|
||||
|
||||
var phases = {
|
||||
"phase01": "profile1",
|
||||
"phase02": "profile1",
|
||||
"phase03": "profile1"
|
||||
};
|
||||
|
||||
const id1 = "restartless-xpi@tests.mozilla.org";
|
||||
const id2 = "unsigned-xpi@tests.mozilla.org";
|
||||
|
||||
Phase("phase01", [
|
||||
[Addons.install, [id1]],
|
||||
[Addons.install, [id2]],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase02", [
|
||||
[Addons.verify, [id1], STATE_ENABLED],
|
||||
[Addons.verify, [id2], STATE_ENABLED],
|
||||
[Sync, SYNC_WIPE_CLIENT],
|
||||
[Sync]
|
||||
]);
|
||||
Phase("phase03", [
|
||||
[Addons.verify, [id1], STATE_ENABLED],
|
||||
[Addons.verify, [id2], STATE_ENABLED],
|
||||
[Sync] // Sync to ensure that the addon validator can run without error
|
||||
]);
|
||||
143
services/sync/tests/tps/test_bookmark_conflict.js
Normal file
143
services/sync/tests/tps/test_bookmark_conflict.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarksInitial = {
|
||||
"menu": [
|
||||
{ folder: "foldera" },
|
||||
{ folder: "folderb" },
|
||||
{ folder: "folderc" },
|
||||
{ folder: "folderd" },
|
||||
],
|
||||
|
||||
"menu/foldera": [{ uri: "http://www.cnn.com", title: "CNN" }],
|
||||
"menu/folderb": [{ uri: "http://www.apple.com", title: "Apple", tags: [] }],
|
||||
"menu/folderc": [{ uri: "http://www.yahoo.com", title: "Yahoo" }],
|
||||
|
||||
"menu/folderd": []
|
||||
};
|
||||
|
||||
// a list of bookmarks to delete during a 'delete' action on P2
|
||||
var bookmarksToDelete = {
|
||||
"menu": [
|
||||
{ folder: "foldera" },
|
||||
{ folder: "folderb" },
|
||||
],
|
||||
"menu/folderc": [{ uri: "http://www.yahoo.com", title: "Yahoo" }],
|
||||
};
|
||||
|
||||
|
||||
// the modifications to make on P1, after P2 has synced, but before P1 has gotten
|
||||
// P2's changes
|
||||
var bookmarkMods = {
|
||||
"menu": [
|
||||
{ folder: "foldera" },
|
||||
{ folder: "folderb" },
|
||||
{ folder: "folderc" },
|
||||
{ folder: "folderd" },
|
||||
],
|
||||
|
||||
// we move this child out of its folder (p1), after deleting the folder (p2)
|
||||
// and expect the child to come back to p2 after sync.
|
||||
"menu/foldera": [{
|
||||
uri: "http://www.cnn.com",
|
||||
title: "CNN",
|
||||
changes: { location: "menu/folderd" }
|
||||
}],
|
||||
|
||||
// we rename this child (p1) after deleting the folder (p2), and expect the child
|
||||
// to be moved into great grandparent (menu)
|
||||
"menu/folderb": [{
|
||||
uri: "http://www.apple.com",
|
||||
title: "Apple",
|
||||
tags: [],
|
||||
changes: { title: "Mac" }
|
||||
}],
|
||||
|
||||
|
||||
// we move this child (p1) after deleting the child (p2) and expect it to survive
|
||||
"menu/folderc": [{
|
||||
uri: "http://www.yahoo.com",
|
||||
title: "Yahoo",
|
||||
changes: { location: "menu/folderd" }
|
||||
}],
|
||||
|
||||
"menu/folderd": []
|
||||
};
|
||||
|
||||
// a list of bookmarks to delete during a 'delete' action
|
||||
var bookmarksToDelete = {
|
||||
"menu": [
|
||||
{ folder: "foldera" },
|
||||
{ folder: "folderb" },
|
||||
],
|
||||
"menu/folderc": [
|
||||
{ uri: "http://www.yahoo.com", title: "Yahoo" },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
|
||||
// expected bookmark state after conflict resolution
|
||||
var bookmarksExpected = {
|
||||
"menu": [
|
||||
{ folder: "folderc" },
|
||||
{ folder: "folderd" },
|
||||
{ uri: "http://www.apple.com", title: "Mac", },
|
||||
],
|
||||
|
||||
"menu/folderc": [],
|
||||
|
||||
"menu/folderd": [
|
||||
{ uri: "http://www.cnn.com", title: "CNN" },
|
||||
{ uri: "http://www.yahoo.com", title: "Yahoo" }
|
||||
]
|
||||
};
|
||||
|
||||
// Add bookmarks to profile1 and sync.
|
||||
Phase("phase1", [
|
||||
[Bookmarks.add, bookmarksInitial],
|
||||
[Bookmarks.verify, bookmarksInitial],
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarksInitial],
|
||||
]);
|
||||
|
||||
// Sync to profile2 and verify that the bookmarks are present. Delete
|
||||
// bookmarks/folders, verify that it's not present, and sync
|
||||
Phase("phase2", [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarksInitial],
|
||||
[Bookmarks.delete, bookmarksToDelete],
|
||||
[Bookmarks.verifyNot, bookmarksToDelete],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Using profile1, modify the bookmarks, and sync *after* the modification,
|
||||
// and then sync again to propagate the reconciliation changes.
|
||||
Phase("phase3", [
|
||||
[Bookmarks.verify, bookmarksInitial],
|
||||
[Bookmarks.modify, bookmarkMods],
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarksExpected],
|
||||
[Bookmarks.verifyNot, bookmarksToDelete],
|
||||
]);
|
||||
|
||||
// Back in profile2, do a sync and verify that we're in the expected state
|
||||
Phase("phase4", [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarksExpected],
|
||||
[Bookmarks.verifyNot, bookmarksToDelete],
|
||||
]);
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// bug 558077
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
var bookmarks_initial_1 = {
|
||||
"menu": [
|
||||
{ folder: "aaa",
|
||||
description: "foo"
|
||||
},
|
||||
{ uri: "http://www.mozilla.com"
|
||||
}
|
||||
],
|
||||
"menu/aaa": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
},
|
||||
{ uri: "http://www.google.com",
|
||||
title: "testing Google"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var bookmarks_initial_2 = {
|
||||
"menu": [
|
||||
{ folder: "aaa",
|
||||
description: "bar"
|
||||
},
|
||||
{ uri: "http://www.mozilla.com"
|
||||
}
|
||||
],
|
||||
"menu/aaa": [
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla"
|
||||
},
|
||||
{ uri: "http://www.apple.com",
|
||||
tags: [ "apple" ]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial_1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial_1],
|
||||
[Bookmarks.add, bookmarks_initial_2],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
// XXX [Bookmarks.verify, bookmarks_initial_1],
|
||||
[Bookmarks.verify, bookmarks_initial_2]
|
||||
]);
|
||||
79
services/sync/tests/tps/test_bug501528.js
Normal file
79
services/sync/tests/tps/test_bug501528.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["passwords"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Password lists
|
||||
*/
|
||||
|
||||
var passwords_initial = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "secret",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
password: "SeCrEt$$$"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
var passwords_after_first_update = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "SeCrEt$$$",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Passwords.add, passwords_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Passwords.add, passwords_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[Passwords.modify, passwords_initial],
|
||||
[Passwords.verify, passwords_after_first_update],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_after_first_update],
|
||||
]);
|
||||
|
||||
69
services/sync/tests/tps/test_bug530717.js
Normal file
69
services/sync/tests/tps/test_bug530717.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["prefs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
/*
|
||||
* Preference lists
|
||||
*/
|
||||
|
||||
var prefs1 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.getfirefox.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 20
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: true
|
||||
}
|
||||
];
|
||||
|
||||
var prefs2 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.mozilla.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 18
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add prefs to profile1 and sync.
|
||||
Phase('phase1', [
|
||||
[Prefs.modify, prefs1],
|
||||
[Prefs.verify, prefs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Sync profile2 and verify same prefs are present.
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Prefs.verify, prefs1]
|
||||
]);
|
||||
|
||||
// Using profile1, change some prefs, then do another sync with wipe-client.
|
||||
// Verify that the cloud's prefs are restored, and the recent local changes
|
||||
// discarded.
|
||||
Phase('phase3', [
|
||||
[Prefs.modify, prefs2],
|
||||
[Prefs.verify, prefs2],
|
||||
[Sync, SYNC_WIPE_CLIENT],
|
||||
[Prefs.verify, prefs1]
|
||||
]);
|
||||
|
||||
62
services/sync/tests/tps/test_bug531489.js
Normal file
62
services/sync/tests/tps/test_bug531489.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
/*
|
||||
* Bookmark asset lists: these define bookmarks that are used during the test
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
"menu": [
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
}
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
}
|
||||
],
|
||||
"toolbar": [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add three bookmarks with the same url to different locations and sync.
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Sync to profile2 and verify that all three bookmarks are present
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// Sync again to profile1 and verify that all three bookmarks are still
|
||||
// present.
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
130
services/sync/tests/tps/test_bug535326.js
Normal file
130
services/sync/tests/tps/test_bug535326.js
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["tabs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2"};
|
||||
|
||||
var tabs1 = [
|
||||
{ uri: "data:text/html,<html><head><title>Howdy</title></head><body>Howdy</body></html>",
|
||||
title: "Howdy",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>America</title></head><body>America</body></html>",
|
||||
title: "America",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
|
||||
title: "Apple",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>This</title></head><body>This</body></html>",
|
||||
title: "This",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Bug</title></head><body>Bug</body></html>",
|
||||
title: "Bug",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>IRC</title></head><body>IRC</body></html>",
|
||||
title: "IRC",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Tinderbox</title></head><body>Tinderbox</body></html>",
|
||||
title: "Tinderbox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Fox</title></head><body>Fox</body></html>",
|
||||
title: "Fox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Hello</title></head><body>Hello</body></html>",
|
||||
title: "Hello",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Eagle</title></head><body>Eagle</body></html>",
|
||||
title: "Eagle",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Train</title></head><body>Train</body></html>",
|
||||
title: "Train",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Macbook</title></head><body>Macbook</body></html>",
|
||||
title: "Macbook",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Clock</title></head><body>Clock</body></html>",
|
||||
title: "Clock",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Google</title></head><body>Google</body></html>",
|
||||
title: "Google",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Human</title></head><body>Human</body></html>",
|
||||
title: "Human",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Jetpack</title></head><body>Jetpack</body></html>",
|
||||
title: "Jetpack",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Selenium</title></head><body>Selenium</body></html>",
|
||||
title: "Selenium",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
|
||||
title: "Mozilla",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
|
||||
title: "Firefox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Weave</title></head><body>Weave</body></html>",
|
||||
title: "Weave",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Android</title></head><body>Android</body></html>",
|
||||
title: "Android",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Bye</title></head><body>Bye</body></html>",
|
||||
title: "Bye",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Hi</title></head><body>Hi</body></html>",
|
||||
title: "Hi",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Final</title></head><body>Final</body></html>",
|
||||
title: "Final",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Fennec</title></head><body>Fennec</body></html>",
|
||||
title: "Fennec",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Mobile</title></head><body>Mobile</body></html>",
|
||||
title: "Mobile",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
Phase('phase1', [
|
||||
[Tabs.add, tabs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Tabs.verify, tabs1]
|
||||
]);
|
||||
|
||||
93
services/sync/tests/tps/test_bug538298.js
Normal file
93
services/sync/tests/tps/test_bug538298.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Bookmark asset lists: these define bookmarks that are used during the test
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
"toolbar": [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
},
|
||||
{ uri: "http://www.cnn.com",
|
||||
title: "CNN",
|
||||
changes: {
|
||||
position: "Google"
|
||||
}
|
||||
},
|
||||
{ uri: "http://www.mozilla.com",
|
||||
title: "Mozilla"
|
||||
},
|
||||
{ uri: "http://www.firefox.com",
|
||||
title: "Firefox",
|
||||
changes: {
|
||||
position: "Mozilla"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var bookmarks_after_move = {
|
||||
"toolbar": [
|
||||
{ uri: "http://www.cnn.com",
|
||||
title: "CNN"
|
||||
},
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
},
|
||||
{ uri: "http://www.firefox.com",
|
||||
title: "Firefox"
|
||||
},
|
||||
{ uri: "http://www.mozilla.com",
|
||||
title: "Mozilla"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add four bookmarks to the toolbar and sync.
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Sync to profile2 and verify that all four bookmarks are present.
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// Change the order of the toolbar bookmarks, and sync.
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Bookmarks.modify, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_after_move],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
// Go back to profile2, sync, and verify that the bookmarks are reordered
|
||||
// as expected.
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_after_move]
|
||||
]);
|
||||
|
||||
54
services/sync/tests/tps/test_bug546807.js
Normal file
54
services/sync/tests/tps/test_bug546807.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
EnableEngines(["tabs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2"};
|
||||
|
||||
/*
|
||||
* Tabs data
|
||||
*/
|
||||
|
||||
var tabs1 = [
|
||||
{ uri: "about:config",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:credits",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
|
||||
title: "Apple",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs_absent = [
|
||||
{ uri: "about:config",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:credits",
|
||||
profile: "profile1"
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Tabs.add, tabs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Tabs.verifyNot, tabs_absent]
|
||||
]);
|
||||
|
||||
45
services/sync/tests/tps/test_bug556509.js
Normal file
45
services/sync/tests/tps/test_bug556509.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2"};
|
||||
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
"menu": [
|
||||
{ folder: "testfolder",
|
||||
description: "it's just me, a test folder"
|
||||
}
|
||||
],
|
||||
"menu/testfolder": [
|
||||
{ uri: "http://www.mozilla.com",
|
||||
title: "Mozilla"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add a bookmark folder which has a description, and sync.
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Sync to profile2 and verify that the bookmark folder is created, along
|
||||
// with its description.
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
105
services/sync/tests/tps/test_bug562515.js
Normal file
105
services/sync/tests/tps/test_bug562515.js
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Bookmark lists
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
loadInSidebar: true,
|
||||
tags: [ "google", "computers", "internet", "www"]
|
||||
},
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla",
|
||||
keyword: "bz"
|
||||
},
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.mozilla.com" },
|
||||
{ separator: true },
|
||||
{ folder: "folderb" }
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
},
|
||||
{ uri: "http://www.cnn.com",
|
||||
description: "This is a description of the site a at www.cnn.com"
|
||||
},
|
||||
{ livemark: "Livemark1",
|
||||
feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
|
||||
siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
|
||||
}
|
||||
],
|
||||
"menu/folderb": [
|
||||
{ uri: "http://www.apple.com",
|
||||
tags: [ "apple", "mac" ]
|
||||
}
|
||||
],
|
||||
"toolbar": [
|
||||
{ uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
|
||||
title: "Visited Today"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// a list of bookmarks to delete during a 'delete' action
|
||||
var bookmarks_to_delete = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
loadInSidebar: true,
|
||||
tags: [ "google", "computers", "internet", "www"]
|
||||
}
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// add bookmarks to profile1 and sync
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// sync to profile2 and verify that the bookmarks are present
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// delete some bookmarks from profile1, then sync with "wipe-client"
|
||||
// set; finally, verify that the deleted bookmarks were restored.
|
||||
Phase('phase3', [
|
||||
[Bookmarks.delete, bookmarks_to_delete],
|
||||
[Bookmarks.verifyNot, bookmarks_to_delete],
|
||||
[Sync, SYNC_WIPE_CLIENT],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// sync profile2 again, verify no bookmarks have been deleted
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
107
services/sync/tests/tps/test_bug563989.js
Normal file
107
services/sync/tests/tps/test_bug563989.js
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["bookmarks"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Bookmark asset lists: these define bookmarks that are used during the test
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
loadInSidebar: true,
|
||||
tags: [ "google", "computers", "internet", "www" ]
|
||||
},
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla",
|
||||
keyword: "bz"
|
||||
},
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.mozilla.com" },
|
||||
{ separator: true },
|
||||
{ folder: "folderb" }
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
},
|
||||
{ uri: "http://www.cnn.com",
|
||||
description: "This is a description of the site a at www.cnn.com"
|
||||
},
|
||||
{ livemark: "Livemark1",
|
||||
feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
|
||||
siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
|
||||
}
|
||||
],
|
||||
"menu/folderb": [
|
||||
{ uri: "http://www.apple.com",
|
||||
tags: [ "apple", "mac" ]
|
||||
}
|
||||
],
|
||||
"toolbar": [
|
||||
{ uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
|
||||
title: "Visited Today"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// a list of bookmarks to delete during a 'delete' action
|
||||
var bookmarks_to_delete = {
|
||||
"menu/folderb": [
|
||||
{ uri: "http://www.apple.com",
|
||||
tags: [ "apple", "mac" ]
|
||||
}
|
||||
],
|
||||
"toolbar": [
|
||||
{ uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
|
||||
title: "Visited Today"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add bookmarks to profile1 and sync.
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
// Sync to profile2 and verify that the bookmarks are present. Delete
|
||||
// some bookmarks, and verify that they're not present, but don't sync again.
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Bookmarks.delete, bookmarks_to_delete],
|
||||
[Bookmarks.verifyNot, bookmarks_to_delete],
|
||||
[Bookmarks.skipValidation]
|
||||
]);
|
||||
|
||||
// Using profile1, sync again with wipe-server set to true. Verify our
|
||||
// initial bookmarks are still all present.
|
||||
Phase('phase3', [
|
||||
[Sync, SYNC_WIPE_REMOTE],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// Back in profile2, do a sync and verify that the bookmarks we had
|
||||
// deleted earlier are now restored.
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
85
services/sync/tests/tps/test_bug575423.js
Normal file
85
services/sync/tests/tps/test_bug575423.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
EnableEngines(["history"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2"};
|
||||
|
||||
/*
|
||||
* History data
|
||||
*/
|
||||
|
||||
// the history data to add to the browser
|
||||
var history1 = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -1
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Another history data to add to the browser
|
||||
var history2 = [
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.google.com/language_tools?hl=en",
|
||||
title: "Language Tools",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -40
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
Phase('phase1', [
|
||||
[History.add, history1],
|
||||
[Sync],
|
||||
[History.add, history2],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[History.verify, history2]
|
||||
]);
|
||||
|
||||
164
services/sync/tests/tps/test_client_wipe.js
Normal file
164
services/sync/tests/tps/test_client_wipe.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
/*
|
||||
* Bookmark lists
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to add to the browser
|
||||
var bookmarks_initial = {
|
||||
toolbar: [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
},
|
||||
{ uri: "http://www.cnn.com",
|
||||
title: "CNN",
|
||||
changes: {
|
||||
position: "Google"
|
||||
}
|
||||
},
|
||||
{ uri: "http://www.mozilla.com",
|
||||
title: "Mozilla"
|
||||
},
|
||||
{ uri: "http://www.firefox.com",
|
||||
title: "Firefox",
|
||||
changes: {
|
||||
position: "Mozilla"
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
var bookmarks_after_move = {
|
||||
toolbar: [
|
||||
{ uri: "http://www.cnn.com",
|
||||
title: "CNN"
|
||||
},
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
},
|
||||
{ uri: "http://www.firefox.com",
|
||||
title: "Firefox"
|
||||
},
|
||||
{ uri: "http://www.mozilla.com",
|
||||
title: "Mozilla"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* Password data
|
||||
*/
|
||||
|
||||
// Initial password data
|
||||
var passwords_initial = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "secret",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
password: "SeCrEt$$$"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// Password after first modify action has been performed
|
||||
var passwords_after_change = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "SeCrEt$$$",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
username: "james"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Prefs to use in the test
|
||||
*/
|
||||
var prefs1 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.getfirefox.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 20
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: true
|
||||
}
|
||||
];
|
||||
|
||||
var prefs2 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.mozilla.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 18
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
// Add prefs,passwords and bookmarks to profile1 and sync.
|
||||
Phase('phase1', [
|
||||
[Passwords.add, passwords_initial],
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Prefs.modify, prefs1],
|
||||
[Prefs.verify, prefs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
// Sync profile2 and verify same prefs,passwords and bookmarks are present.
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Prefs.verify, prefs1],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
// Using profile1, change some prefs,bookmarks and pwds, then do another sync with wipe-client.
|
||||
// Verify that the cloud's settings are restored, and the recent local changes
|
||||
// discarded.
|
||||
Phase('phase3', [
|
||||
[Prefs.modify, prefs2],
|
||||
[Passwords.modify, passwords_initial],
|
||||
[Bookmarks.modify, bookmarks_initial],
|
||||
[Prefs.verify, prefs2],
|
||||
[Passwords.verify, passwords_after_change],
|
||||
[Bookmarks.verify, bookmarks_after_move],
|
||||
[Sync, SYNC_WIPE_CLIENT],
|
||||
[Prefs.verify, prefs1],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[Bookmarks.verify, bookmarks_initial]
|
||||
]);
|
||||
|
||||
97
services/sync/tests/tps/test_formdata.js
Normal file
97
services/sync/tests/tps/test_formdata.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["forms"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Form data asset lists: these define form values that are used in the tests.
|
||||
*/
|
||||
|
||||
var formdata1 = [
|
||||
{ fieldname: "testing",
|
||||
value: "success",
|
||||
date: -1
|
||||
},
|
||||
{ fieldname: "testing",
|
||||
value: "failure",
|
||||
date: -2
|
||||
},
|
||||
{ fieldname: "username",
|
||||
value: "joe"
|
||||
}
|
||||
];
|
||||
|
||||
// This is currently pointless - it *looks* like it is trying to check that
|
||||
// one of the entries in formdata1 has been removed, but (a) the delete code
|
||||
// isn't active (see comments below), and (b) the way the verification works
|
||||
// means it would never do the right thing - it only checks all the entries
|
||||
// here exist, but not that they are the only entries in the DB.
|
||||
var formdata2 = [
|
||||
{ fieldname: "testing",
|
||||
value: "success",
|
||||
date: -1
|
||||
},
|
||||
{ fieldname: "username",
|
||||
value: "joe"
|
||||
}
|
||||
];
|
||||
|
||||
var formdata_delete = [
|
||||
{ fieldname: "testing",
|
||||
value: "failure"
|
||||
}
|
||||
];
|
||||
|
||||
var formdata_new = [
|
||||
{ fieldname: "new-field",
|
||||
value: "new-value"
|
||||
}
|
||||
]
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Formdata.add, formdata1],
|
||||
[Formdata.verify, formdata1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Formdata.verify, formdata1],
|
||||
]);
|
||||
|
||||
/*
|
||||
* Note: Weave does not support syncing deleted form data, so those
|
||||
* tests are disabled below. See bug 568363.
|
||||
*/
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Formdata.delete, formdata_delete],
|
||||
//[Formdata.verifyNot, formdata_delete],
|
||||
[Formdata.verify, formdata2],
|
||||
// add new data after the first Sync, ensuring the tracker works.
|
||||
[Formdata.add, formdata_new],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Formdata.verify, formdata2],
|
||||
[Formdata.verify, formdata_new],
|
||||
//[Formdata.verifyNot, formdata_delete]
|
||||
]);
|
||||
|
||||
|
||||
167
services/sync/tests/tps/test_history.js
Normal file
167
services/sync/tests/tps/test_history.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["history"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2" };
|
||||
|
||||
/*
|
||||
* History asset lists: these define history entries that are used during
|
||||
* the test
|
||||
*/
|
||||
|
||||
// the initial list of history items to add to the browser
|
||||
var history1 = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -1
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.google.com/language_tools?hl=en",
|
||||
title: "Language Tools",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -40
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 1,
|
||||
date: -20
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// a list of items to delete from the history
|
||||
var history_to_delete = [
|
||||
{ uri: "http://www.cnn.com/" },
|
||||
{ begin: -24,
|
||||
end: -1
|
||||
},
|
||||
{ host: "www.google.com" }
|
||||
];
|
||||
|
||||
// a list which reflects items that should be in the history after
|
||||
// the above items are deleted
|
||||
var history2 = [
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// a list which includes history entries that should not be present
|
||||
// after deletion of the history_to_delete entries
|
||||
var history_not = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -1
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.google.com/language_tools?hl=en",
|
||||
title: "Language Tools",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -40
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 1,
|
||||
date: -20
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
* Note: there is no test phase in which deleted history entries are
|
||||
* synced to other clients. This functionality is not supported by
|
||||
* Sync, see bug 446517.
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[History.add, history1],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[History.verify, history1],
|
||||
[History.delete, history_to_delete],
|
||||
[History.verify, history2],
|
||||
[History.verifyNot, history_not],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
125
services/sync/tests/tps/test_history_collision.js
Normal file
125
services/sync/tests/tps/test_history_collision.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["history"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* History lists
|
||||
*/
|
||||
|
||||
// the initial list of history to add to the browser
|
||||
var history1 = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// the history to delete
|
||||
var history_to_delete = [
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN"
|
||||
},
|
||||
{ begin: -36,
|
||||
end: -1
|
||||
}
|
||||
];
|
||||
|
||||
var history_not = [
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
var history_after_delete = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[History.add, history1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[History.add, history1],
|
||||
[Sync, SYNC_WIPE_REMOTE]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[History.verify, history1],
|
||||
[History.delete, history_to_delete],
|
||||
[History.verify, history_after_delete],
|
||||
[History.verifyNot, history_not],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[History.verify, history_after_delete],
|
||||
[History.verifyNot, history_not]
|
||||
]);
|
||||
|
||||
24
services/sync/tests/tps/test_mozmill_sanity.js
Normal file
24
services/sync/tests/tps/test_mozmill_sanity.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2" };
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[RunMozmillTest, 'mozmill_sanity.js'],
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[RunMozmillTest, 'mozmill_sanity2.js'],
|
||||
]);
|
||||
113
services/sync/tests/tps/test_passwords.js
Normal file
113
services/sync/tests/tps/test_passwords.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["passwords"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Password asset lists: these define password entries that are used during
|
||||
* the test
|
||||
*/
|
||||
|
||||
// initial password list to be loaded into the browser
|
||||
var passwords_initial = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "SeCrEt123",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
password: "zippity-do-dah"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// expected state of passwords after the changes in the above list are applied
|
||||
var passwords_after_first_update = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "zippity-do-dah",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
var passwords_to_delete = [
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
var passwords_absent = [
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// expected state of passwords after the delete operation
|
||||
var passwords_after_second_update = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "zippity-do-dah",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Passwords.add, passwords_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[Passwords.modify, passwords_initial],
|
||||
[Passwords.verify, passwords_after_first_update],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_after_first_update],
|
||||
[Passwords.delete, passwords_to_delete],
|
||||
[Passwords.verify, passwords_after_second_update],
|
||||
[Passwords.verifyNot, passwords_absent],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_after_second_update],
|
||||
[Passwords.verifyNot, passwords_absent]
|
||||
]);
|
||||
57
services/sync/tests/tps/test_prefs.js
Normal file
57
services/sync/tests/tps/test_prefs.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["prefs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
var prefs1 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.getfirefox.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 20
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: true
|
||||
}
|
||||
];
|
||||
|
||||
var prefs2 = [
|
||||
{ name: "browser.startup.homepage",
|
||||
value: "http://www.mozilla.com"
|
||||
},
|
||||
{ name: "browser.urlbar.maxRichResults",
|
||||
value: 18
|
||||
},
|
||||
{ name: "privacy.clearOnShutdown.siteSettings",
|
||||
value: false
|
||||
}
|
||||
];
|
||||
|
||||
Phase('phase1', [
|
||||
[Prefs.modify, prefs1],
|
||||
[Prefs.verify, prefs1],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Prefs.verify, prefs1],
|
||||
[Prefs.modify, prefs2],
|
||||
[Prefs.verify, prefs2],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Prefs.verify, prefs2]
|
||||
]);
|
||||
|
||||
104
services/sync/tests/tps/test_privbrw_passwords.js
Normal file
104
services/sync/tests/tps/test_privbrw_passwords.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["passwords"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Password data
|
||||
*/
|
||||
|
||||
// Initial password data
|
||||
var passwords_initial = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "secret",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
password: "SeCrEt$$$"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// Password after first modify action has been performed
|
||||
var passwords_after_first_change = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "SeCrEt$$$",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
username: "james"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// Password after second modify action has been performed
|
||||
var passwords_after_second_change = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "james",
|
||||
password: "SeCrEt$$$",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "jack",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Passwords.add, passwords_initial],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[Passwords.modify, passwords_initial],
|
||||
[Passwords.verify, passwords_after_first_change],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Windows.add, { private: true }],
|
||||
[Passwords.verify, passwords_after_first_change],
|
||||
[Passwords.modify, passwords_after_first_change],
|
||||
[Passwords.verify, passwords_after_second_change],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Passwords.verify, passwords_after_second_change]
|
||||
]);
|
||||
|
||||
87
services/sync/tests/tps/test_privbrw_tabs.js
Normal file
87
services/sync/tests/tps/test_privbrw_tabs.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["tabs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Tabs data
|
||||
*/
|
||||
|
||||
var tabs1 = [
|
||||
{ uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
|
||||
title: "Firefox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Weave</title></head><body>Weave</body></html>",
|
||||
title: "Weave",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Apple</title></head><body>Apple</body></html>",
|
||||
title: "Apple",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>IRC</title></head><body>IRC</body></html>",
|
||||
title: "IRC",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs2 = [
|
||||
{ uri: "data:text/html,<html><head><title>Tinderbox</title></head><body>Tinderbox</body></html>",
|
||||
title: "Tinderbox",
|
||||
profile: "profile2"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Fox</title></head><body>Fox</body></html>",
|
||||
title: "Fox",
|
||||
profile: "profile2"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs3 = [
|
||||
{ uri: "data:text/html,<html><head><title>Jetpack</title></head><body>Jetpack</body></html>",
|
||||
title: "Jetpack",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Selenium</title></head><body>Selenium</body></html>",
|
||||
title: "Selenium",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Tabs.add, tabs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Tabs.verify, tabs1],
|
||||
[Tabs.add, tabs2],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Windows.add, { private: true }],
|
||||
[Tabs.add, tabs3],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Tabs.verifyNot, tabs3]
|
||||
]);
|
||||
78
services/sync/tests/tps/test_special_tabs.js
Normal file
78
services/sync/tests/tps/test_special_tabs.js
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
// Bug 532173 - Dont sync tabs like about:* , weave firstrun etc
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["tabs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2" };
|
||||
|
||||
var tabs1 = [
|
||||
{ uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
|
||||
title: "Firefox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:plugins",
|
||||
title: "About",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:credits",
|
||||
title: "Credits",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
|
||||
title: "Mozilla",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/en-US/firefox/sync/firstrun.html",
|
||||
title: "Firstrun",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs2 = [
|
||||
{ uri: "data:text/html,<html><head><title>Firefox</title></head><body>Firefox</body></html>",
|
||||
title: "Firefox",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Mozilla</title></head><body>Mozilla</body></html>",
|
||||
title: "Mozilla",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs3 = [
|
||||
{ uri: "http://www.mozilla.com/en-US/firefox/sync/firstrun.html",
|
||||
title: "Firstrun",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:plugins",
|
||||
title: "About",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "about:credits",
|
||||
title: "Credits",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
Phase('phase1', [
|
||||
[Tabs.add, tabs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Tabs.verify, tabs2],
|
||||
[Tabs.verifyNot, tabs3]
|
||||
]);
|
||||
|
||||
424
services/sync/tests/tps/test_sync.js
Normal file
424
services/sync/tests/tps/test_sync.js
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1",
|
||||
"phase4": "profile2" };
|
||||
|
||||
/*
|
||||
* Bookmark asset lists: these define bookmarks that are used during the test
|
||||
*/
|
||||
|
||||
// the initial list of bookmarks to be added to the browser
|
||||
var bookmarks_initial = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
loadInSidebar: true,
|
||||
tags: ["google", "computers", "internet", "www"],
|
||||
changes: {
|
||||
title: "Google",
|
||||
loadInSidebar: false,
|
||||
tags: ["google", "computers", "misc"],
|
||||
}
|
||||
},
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla",
|
||||
keyword: "bz",
|
||||
changes: {
|
||||
keyword: "bugzilla"
|
||||
}
|
||||
},
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.mozilla.com" },
|
||||
{ separator: true },
|
||||
{ folder: "folderb" },
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo",
|
||||
changes: {
|
||||
location: "menu/folderb"
|
||||
}
|
||||
},
|
||||
{ uri: "http://www.cnn.com",
|
||||
description: "This is a description of the site a at www.cnn.com",
|
||||
changes: {
|
||||
uri: "http://money.cnn.com",
|
||||
description: "new description",
|
||||
}
|
||||
},
|
||||
{ livemark: "Livemark1",
|
||||
feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
|
||||
siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html",
|
||||
changes: {
|
||||
livemark: "LivemarkOne"
|
||||
}
|
||||
},
|
||||
],
|
||||
"menu/folderb": [
|
||||
{ uri: "http://www.apple.com",
|
||||
tags: ["apple", "mac"],
|
||||
changes: {
|
||||
uri: "http://www.apple.com/iphone/",
|
||||
title: "iPhone",
|
||||
location: "menu",
|
||||
position: "Google",
|
||||
tags: []
|
||||
}
|
||||
}
|
||||
],
|
||||
toolbar: [
|
||||
{ uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
|
||||
title: "Visited Today"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// the state of bookmarks after the first 'modify' action has been performed
|
||||
// on them
|
||||
var bookmarks_after_first_modify = {
|
||||
"menu": [
|
||||
{ uri: "http://www.apple.com/iphone/",
|
||||
title: "iPhone",
|
||||
before: "Google",
|
||||
tags: []
|
||||
},
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google",
|
||||
loadInSidebar: false,
|
||||
tags: [ "google", "computers", "misc"]
|
||||
},
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla",
|
||||
keyword: "bugzilla"
|
||||
},
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.mozilla.com" },
|
||||
{ separator: true },
|
||||
{ folder: "folderb",
|
||||
changes: {
|
||||
location: "menu/foldera",
|
||||
folder: "Folder B",
|
||||
description: "folder description"
|
||||
}
|
||||
}
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://money.cnn.com",
|
||||
title: "http://www.cnn.com",
|
||||
description: "new description"
|
||||
},
|
||||
{ livemark: "LivemarkOne",
|
||||
feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
|
||||
siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
|
||||
}
|
||||
],
|
||||
"menu/folderb": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
}
|
||||
],
|
||||
"toolbar": [
|
||||
{ uri: "place:queryType=0&sort=8&maxResults=10&beginTimeRef=1&beginTime=0",
|
||||
title: "Visited Today"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// a list of bookmarks to delete during a 'delete' action
|
||||
var bookmarks_to_delete = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google",
|
||||
loadInSidebar: false,
|
||||
tags: [ "google", "computers", "misc" ]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// the state of bookmarks after the second 'modify' action has been performed
|
||||
// on them
|
||||
var bookmarks_after_second_modify = {
|
||||
"menu": [
|
||||
{ uri: "http://www.apple.com/iphone/",
|
||||
title: "iPhone"
|
||||
},
|
||||
{ uri: "http://bugzilla.mozilla.org/show_bug.cgi?id=%s",
|
||||
title: "Bugzilla",
|
||||
keyword: "bugzilla"
|
||||
},
|
||||
{ folder: "foldera" },
|
||||
{ uri: "http://www.mozilla.com" },
|
||||
{ separator: true },
|
||||
],
|
||||
"menu/foldera": [
|
||||
{ uri: "http://money.cnn.com",
|
||||
title: "http://www.cnn.com",
|
||||
description: "new description"
|
||||
},
|
||||
{ livemark: "LivemarkOne",
|
||||
feedUri: "http://rss.wunderground.com/blog/JeffMasters/rss.xml",
|
||||
siteUri: "http://www.wunderground.com/blog/JeffMasters/show.html"
|
||||
},
|
||||
{ folder: "Folder B",
|
||||
description: "folder description"
|
||||
}
|
||||
],
|
||||
"menu/foldera/Folder B": [
|
||||
{ uri: "http://www.yahoo.com",
|
||||
title: "testing Yahoo"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// a list of bookmarks which should not be present after the last
|
||||
// 'delete' and 'modify' actions
|
||||
var bookmarks_absent = {
|
||||
"menu": [
|
||||
{ uri: "http://www.google.com",
|
||||
title: "Google"
|
||||
},
|
||||
{ folder: "folderb" },
|
||||
{ folder: "Folder B" }
|
||||
]
|
||||
};
|
||||
|
||||
/*
|
||||
* History asset lists: these define history entries that are used during
|
||||
* the test
|
||||
*/
|
||||
|
||||
// the initial list of history items to add to the browser
|
||||
var history_initial = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1, date: 0 },
|
||||
{ type: 2, date: -1 }
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1, date: -1 },
|
||||
{ type: 2, date: -36 }
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.google.com/language_tools?hl=en",
|
||||
title: "Language Tools",
|
||||
visits: [
|
||||
{ type: 1, date: 0 },
|
||||
{ type: 2, date: -40 }
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1, date: 0 },
|
||||
{ type: 1, date: -1 },
|
||||
{ type: 1, date: -20 },
|
||||
{ type: 2, date: -36 }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// a list of history entries to delete during a 'delete' action
|
||||
var history_to_delete = [
|
||||
{ uri: "http://www.cnn.com/" },
|
||||
{ begin: -24,
|
||||
end: -1 },
|
||||
{ host: "www.google.com" }
|
||||
];
|
||||
|
||||
// the expected history entries after the first 'delete' action
|
||||
var history_after_delete = [
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// history entries expected to not exist after a 'delete' action
|
||||
var history_absent = [
|
||||
{ uri: "http://www.google.com/",
|
||||
title: "Google",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -1
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.cnn.com/",
|
||||
title: "CNN",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 2,
|
||||
date: -36
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.google.com/language_tools?hl=en",
|
||||
title: "Language Tools",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: 0
|
||||
},
|
||||
{ type: 2,
|
||||
date: -40
|
||||
}
|
||||
]
|
||||
},
|
||||
{ uri: "http://www.mozilla.com/",
|
||||
title: "Mozilla",
|
||||
visits: [
|
||||
{ type: 1,
|
||||
date: -1
|
||||
},
|
||||
{ type: 1,
|
||||
date: -20
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Password asset lists: these define password entries that are used during
|
||||
* the test
|
||||
*/
|
||||
|
||||
// the initial list of passwords to add to the browser
|
||||
var passwords_initial = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "SeCrEt123",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword",
|
||||
changes: {
|
||||
password: "zippity-do-dah"
|
||||
}
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// the expected state of passwords after the first 'modify' action
|
||||
var passwords_after_first_modify = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "zippity-do-dah",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
},
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// a list of passwords to delete during a 'delete' action
|
||||
var passwords_to_delete = [
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// a list of passwords expected to be absent after 'delete' and 'modify'
|
||||
// actions
|
||||
var passwords_absent = [
|
||||
{ hostname: "http://www.example.com",
|
||||
realm: "login",
|
||||
username: "joe",
|
||||
password: "secretlogin"
|
||||
}
|
||||
];
|
||||
|
||||
// the expected state of passwords after the seconds 'modify' action
|
||||
var passwords_after_second_modify = [
|
||||
{ hostname: "http://www.example.com",
|
||||
submitURL: "http://login.example.com",
|
||||
username: "joe",
|
||||
password: "zippity-do-dah",
|
||||
usernameField: "uname",
|
||||
passwordField: "pword"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Bookmarks.add, bookmarks_initial],
|
||||
[Passwords.add, passwords_initial],
|
||||
[History.add, history_initial],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_initial],
|
||||
[Passwords.verify, passwords_initial],
|
||||
[History.verify, history_initial],
|
||||
[Bookmarks.modify, bookmarks_initial],
|
||||
[Passwords.modify, passwords_initial],
|
||||
[History.delete, history_to_delete],
|
||||
[Bookmarks.verify, bookmarks_after_first_modify],
|
||||
[Passwords.verify, passwords_after_first_modify],
|
||||
[History.verify, history_after_delete],
|
||||
[History.verifyNot, history_absent],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_after_first_modify],
|
||||
[Passwords.verify, passwords_after_first_modify],
|
||||
[History.verify, history_after_delete],
|
||||
[Bookmarks.modify, bookmarks_after_first_modify],
|
||||
[Passwords.modify, passwords_after_first_modify],
|
||||
[Bookmarks.delete, bookmarks_to_delete],
|
||||
[Passwords.delete, passwords_to_delete],
|
||||
[Bookmarks.verify, bookmarks_after_second_modify],
|
||||
[Passwords.verify, passwords_after_second_modify],
|
||||
[Bookmarks.verifyNot, bookmarks_absent],
|
||||
[Passwords.verifyNot, passwords_absent],
|
||||
[Sync],
|
||||
]);
|
||||
|
||||
Phase('phase4', [
|
||||
[Sync],
|
||||
[Bookmarks.verify, bookmarks_after_second_modify],
|
||||
[Passwords.verify, passwords_after_second_modify],
|
||||
[Bookmarks.verifyNot, bookmarks_absent],
|
||||
[Passwords.verifyNot, passwords_absent],
|
||||
[History.verifyNot, history_absent],
|
||||
]);
|
||||
|
||||
|
||||
59
services/sync/tests/tps/test_tabs.js
Normal file
59
services/sync/tests/tps/test_tabs.js
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
/*
|
||||
* The list of phases mapped to their corresponding profiles. The object
|
||||
* here must be in strict JSON format, as it will get parsed by the Python
|
||||
* testrunner (no single quotes, extra comma's, etc).
|
||||
*/
|
||||
EnableEngines(["tabs"]);
|
||||
|
||||
var phases = { "phase1": "profile1",
|
||||
"phase2": "profile2",
|
||||
"phase3": "profile1"};
|
||||
|
||||
/*
|
||||
* Tab lists.
|
||||
*/
|
||||
|
||||
var tabs1 = [
|
||||
{ uri: "http://mozqa.com/data/firefox/layout/mozilla.html",
|
||||
title: "Mozilla",
|
||||
profile: "profile1"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Hello</title></head><body>Hello</body></html>",
|
||||
title: "Hello",
|
||||
profile: "profile1"
|
||||
}
|
||||
];
|
||||
|
||||
var tabs2 = [
|
||||
{ uri: "http://mozqa.com/data/firefox/layout/mozilla_community.html",
|
||||
title: "Mozilla Community",
|
||||
profile: "profile2"
|
||||
},
|
||||
{ uri: "data:text/html,<html><head><title>Bye</title></head><body>Bye</body></html>",
|
||||
profile: "profile2"
|
||||
}
|
||||
];
|
||||
|
||||
/*
|
||||
* Test phases
|
||||
*/
|
||||
|
||||
Phase('phase1', [
|
||||
[Tabs.add, tabs1],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase2', [
|
||||
[Sync],
|
||||
[Tabs.verify, tabs1],
|
||||
[Tabs.add, tabs2],
|
||||
[Sync]
|
||||
]);
|
||||
|
||||
Phase('phase3', [
|
||||
[Sync],
|
||||
[Tabs.verify, tabs2]
|
||||
]);
|
||||
27
services/sync/tests/unit/addon1-search.xml
Normal file
27
services/sync/tests/unit/addon1-search.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Non-Restartless Test Extension</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>addon1@tests.mozilla.org</guid>
|
||||
<slug>addon11</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>xpcshell@tests.mozilla.org</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485">http://127.0.0.1:8888/addon1.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
27
services/sync/tests/unit/bootstrap1-search.xml
Normal file
27
services/sync/tests/unit/bootstrap1-search.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Restartless Test Extension</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>bootstrap1@tests.mozilla.org</guid>
|
||||
<slug>bootstrap1</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>xpcshell@tests.mozilla.org</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485">http://127.0.0.1:8888/bootstrap1.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
38
services/sync/tests/unit/fake_login_manager.js
Normal file
38
services/sync/tests/unit/fake_login_manager.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
// ----------------------------------------
|
||||
// Fake Sample Data
|
||||
// ----------------------------------------
|
||||
|
||||
var fakeSampleLogins = [
|
||||
// Fake nsILoginInfo object.
|
||||
{hostname: "www.boogle.com",
|
||||
formSubmitURL: "http://www.boogle.com/search",
|
||||
httpRealm: "",
|
||||
username: "",
|
||||
password: "",
|
||||
usernameField: "test_person",
|
||||
passwordField: "test_password"}
|
||||
];
|
||||
|
||||
// ----------------------------------------
|
||||
// Fake Login Manager
|
||||
// ----------------------------------------
|
||||
|
||||
function FakeLoginManager(fakeLogins) {
|
||||
this.fakeLogins = fakeLogins;
|
||||
|
||||
let self = this;
|
||||
|
||||
// Use a fake nsILoginManager object.
|
||||
delete Services.logins;
|
||||
Services.logins = {
|
||||
removeAllLogins: function() { self.fakeLogins = []; },
|
||||
getAllLogins: function() { return self.fakeLogins; },
|
||||
addLogin: function(login) {
|
||||
getTestLogger().info("nsILoginManager.addLogin() called " +
|
||||
"with hostname '" + login.hostname + "'.");
|
||||
self.fakeLogins.push(login);
|
||||
}
|
||||
};
|
||||
}
|
||||
57
services/sync/tests/unit/head_appinfo.js
Normal file
57
services/sync/tests/unit/head_appinfo.js
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
var {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
var gSyncProfile;
|
||||
|
||||
gSyncProfile = do_get_profile();
|
||||
|
||||
// Init FormHistoryStartup and pretend we opened a profile.
|
||||
var fhs = Cc["@mozilla.org/satchel/form-history-startup;1"]
|
||||
.getService(Ci.nsIObserver);
|
||||
fhs.observe(null, "profile-after-change", null);
|
||||
|
||||
// An app is going to have some prefs set which xpcshell tests don't.
|
||||
Services.prefs.setCharPref("identity.sync.tokenserver.uri", "http://token-server");
|
||||
|
||||
// Set the validation prefs to attempt validation every time to avoid non-determinism.
|
||||
Services.prefs.setIntPref("services.sync.validation.interval", 0);
|
||||
Services.prefs.setIntPref("services.sync.validation.percentageChance", 100);
|
||||
Services.prefs.setIntPref("services.sync.validation.maxRecords", -1);
|
||||
Services.prefs.setBoolPref("services.sync.validation.enabled", true);
|
||||
|
||||
// Make sure to provide the right OS so crypto loads the right binaries
|
||||
function getOS() {
|
||||
switch (mozinfo.os) {
|
||||
case "win":
|
||||
return "WINNT";
|
||||
case "mac":
|
||||
return "Darwin";
|
||||
default:
|
||||
return "Linux";
|
||||
}
|
||||
}
|
||||
|
||||
Cu.import("resource://testing-common/AppInfo.jsm", this);
|
||||
updateAppInfo({
|
||||
name: "XPCShell",
|
||||
ID: "xpcshell@tests.mozilla.org",
|
||||
version: "1",
|
||||
platformVersion: "",
|
||||
OS: getOS(),
|
||||
});
|
||||
|
||||
// Register resource aliases. Normally done in SyncComponents.manifest.
|
||||
function addResourceAlias() {
|
||||
const resProt = Services.io.getProtocolHandler("resource")
|
||||
.QueryInterface(Ci.nsIResProtocolHandler);
|
||||
for (let s of ["common", "sync", "crypto"]) {
|
||||
let uri = Services.io.newURI("resource://gre/modules/services-" + s + "/", null,
|
||||
null);
|
||||
resProt.setSubstitution("services-" + s, uri);
|
||||
}
|
||||
}
|
||||
addResourceAlias();
|
||||
112
services/sync/tests/unit/head_errorhandler_common.js
Normal file
112
services/sync/tests/unit/head_errorhandler_common.js
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
Cu.import("resource://services-sync/engines.js");
|
||||
|
||||
// Common code for test_errorhandler_{1,2}.js -- pulled out to make it less
|
||||
// monolithic and take less time to execute.
|
||||
const EHTestsCommon = {
|
||||
|
||||
service_unavailable(request, response) {
|
||||
let body = "Service Unavailable";
|
||||
response.setStatusLine(request.httpVersion, 503, "Service Unavailable");
|
||||
response.setHeader("Retry-After", "42");
|
||||
response.bodyOutputStream.write(body, body.length);
|
||||
},
|
||||
|
||||
sync_httpd_setup() {
|
||||
let global = new ServerWBO("global", {
|
||||
syncID: Service.syncID,
|
||||
storageVersion: STORAGE_VERSION,
|
||||
engines: {clients: {version: Service.clientsEngine.version,
|
||||
syncID: Service.clientsEngine.syncID},
|
||||
catapult: {version: Service.engineManager.get("catapult").version,
|
||||
syncID: Service.engineManager.get("catapult").syncID}}
|
||||
});
|
||||
let clientsColl = new ServerCollection({}, true);
|
||||
|
||||
// Tracking info/collections.
|
||||
let collectionsHelper = track_collections_helper();
|
||||
let upd = collectionsHelper.with_updated_collection;
|
||||
|
||||
let handler_401 = httpd_handler(401, "Unauthorized");
|
||||
return httpd_setup({
|
||||
// Normal server behaviour.
|
||||
"/1.1/johndoe/storage/meta/global": upd("meta", global.handler()),
|
||||
"/1.1/johndoe/info/collections": collectionsHelper.handler,
|
||||
"/1.1/johndoe/storage/crypto/keys":
|
||||
upd("crypto", (new ServerWBO("keys")).handler()),
|
||||
"/1.1/johndoe/storage/clients": upd("clients", clientsColl.handler()),
|
||||
|
||||
// Credentials are wrong or node reallocated.
|
||||
"/1.1/janedoe/storage/meta/global": handler_401,
|
||||
"/1.1/janedoe/info/collections": handler_401,
|
||||
|
||||
// Maintenance or overloaded (503 + Retry-After) at info/collections.
|
||||
"/maintenance/1.1/broken.info/info/collections": EHTestsCommon.service_unavailable,
|
||||
|
||||
// Maintenance or overloaded (503 + Retry-After) at meta/global.
|
||||
"/maintenance/1.1/broken.meta/storage/meta/global": EHTestsCommon.service_unavailable,
|
||||
"/maintenance/1.1/broken.meta/info/collections": collectionsHelper.handler,
|
||||
|
||||
// Maintenance or overloaded (503 + Retry-After) at crypto/keys.
|
||||
"/maintenance/1.1/broken.keys/storage/meta/global": upd("meta", global.handler()),
|
||||
"/maintenance/1.1/broken.keys/info/collections": collectionsHelper.handler,
|
||||
"/maintenance/1.1/broken.keys/storage/crypto/keys": EHTestsCommon.service_unavailable,
|
||||
|
||||
// Maintenance or overloaded (503 + Retry-After) at wiping collection.
|
||||
"/maintenance/1.1/broken.wipe/info/collections": collectionsHelper.handler,
|
||||
"/maintenance/1.1/broken.wipe/storage/meta/global": upd("meta", global.handler()),
|
||||
"/maintenance/1.1/broken.wipe/storage/crypto/keys":
|
||||
upd("crypto", (new ServerWBO("keys")).handler()),
|
||||
"/maintenance/1.1/broken.wipe/storage": EHTestsCommon.service_unavailable,
|
||||
"/maintenance/1.1/broken.wipe/storage/clients": upd("clients", clientsColl.handler()),
|
||||
"/maintenance/1.1/broken.wipe/storage/catapult": EHTestsCommon.service_unavailable
|
||||
});
|
||||
},
|
||||
|
||||
CatapultEngine: (function() {
|
||||
function CatapultEngine() {
|
||||
SyncEngine.call(this, "Catapult", Service);
|
||||
}
|
||||
CatapultEngine.prototype = {
|
||||
__proto__: SyncEngine.prototype,
|
||||
exception: null, // tests fill this in
|
||||
_sync: function _sync() {
|
||||
if (this.exception) {
|
||||
throw this.exception;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return CatapultEngine;
|
||||
}()),
|
||||
|
||||
|
||||
generateCredentialsChangedFailure() {
|
||||
// Make sync fail due to changed credentials. We simply re-encrypt
|
||||
// the keys with a different Sync Key, without changing the local one.
|
||||
let newSyncKeyBundle = new SyncKeyBundle("johndoe", "23456234562345623456234562");
|
||||
let keys = Service.collectionKeys.asWBO();
|
||||
keys.encrypt(newSyncKeyBundle);
|
||||
keys.upload(Service.resource(Service.cryptoKeysURL));
|
||||
},
|
||||
|
||||
setUp(server) {
|
||||
return configureIdentity({ username: "johndoe" }).then(
|
||||
() => {
|
||||
Service.serverURL = server.baseURI + "/";
|
||||
Service.clusterURL = server.baseURI + "/";
|
||||
}
|
||||
).then(
|
||||
() => EHTestsCommon.generateAndUploadKeys()
|
||||
);
|
||||
},
|
||||
|
||||
generateAndUploadKeys() {
|
||||
generateNewKeys(Service.collectionKeys);
|
||||
let serverKeys = Service.collectionKeys.asWBO("crypto", "keys");
|
||||
serverKeys.encrypt(Service.identity.syncKeyBundle);
|
||||
return serverKeys.upload(Service.resource(Service.cryptoKeysURL)).success;
|
||||
}
|
||||
};
|
||||
446
services/sync/tests/unit/head_helpers.js
Normal file
446
services/sync/tests/unit/head_helpers.js
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://testing-common/services/common/utils.js");
|
||||
Cu.import("resource://testing-common/PlacesTestUtils.jsm");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, 'SyncPingSchema', function() {
|
||||
let ns = {};
|
||||
Cu.import("resource://gre/modules/FileUtils.jsm", ns);
|
||||
let stream = Cc["@mozilla.org/network/file-input-stream;1"]
|
||||
.createInstance(Ci.nsIFileInputStream);
|
||||
let jsonReader = Cc["@mozilla.org/dom/json;1"]
|
||||
.createInstance(Components.interfaces.nsIJSON);
|
||||
let schema;
|
||||
try {
|
||||
let schemaFile = do_get_file("sync_ping_schema.json");
|
||||
stream.init(schemaFile, ns.FileUtils.MODE_RDONLY, ns.FileUtils.PERMS_FILE, 0);
|
||||
schema = jsonReader.decodeFromStream(stream, stream.available());
|
||||
} finally {
|
||||
stream.close();
|
||||
}
|
||||
|
||||
// Allow tests to make whatever engines they want, this shouldn't cause
|
||||
// validation failure.
|
||||
schema.definitions.engine.properties.name = { type: "string" };
|
||||
return schema;
|
||||
});
|
||||
|
||||
XPCOMUtils.defineLazyGetter(this, 'SyncPingValidator', function() {
|
||||
let ns = {};
|
||||
Cu.import("resource://testing-common/ajv-4.1.1.js", ns);
|
||||
let ajv = new ns.Ajv({ async: "co*" });
|
||||
return ajv.compile(SyncPingSchema);
|
||||
});
|
||||
|
||||
var provider = {
|
||||
getFile: function(prop, persistent) {
|
||||
persistent.value = true;
|
||||
switch (prop) {
|
||||
case "ExtPrefDL":
|
||||
return [Services.dirsvc.get("CurProcD", Ci.nsIFile)];
|
||||
default:
|
||||
throw Cr.NS_ERROR_FAILURE;
|
||||
}
|
||||
},
|
||||
QueryInterface: XPCOMUtils.generateQI([Ci.nsIDirectoryServiceProvider])
|
||||
};
|
||||
Services.dirsvc.QueryInterface(Ci.nsIDirectoryService).registerProvider(provider);
|
||||
|
||||
// This is needed for loadAddonTestFunctions().
|
||||
var gGlobalScope = this;
|
||||
|
||||
function ExtensionsTestPath(path) {
|
||||
if (path[0] != "/") {
|
||||
throw Error("Path must begin with '/': " + path);
|
||||
}
|
||||
|
||||
return "../../../../toolkit/mozapps/extensions/test/xpcshell" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the AddonManager test functions by importing its test file.
|
||||
*
|
||||
* This should be called in the global scope of any test file needing to
|
||||
* interface with the AddonManager. It should only be called once, or the
|
||||
* universe will end.
|
||||
*/
|
||||
function loadAddonTestFunctions() {
|
||||
const path = ExtensionsTestPath("/head_addons.js");
|
||||
let file = do_get_file(path);
|
||||
let uri = Services.io.newFileURI(file);
|
||||
Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
|
||||
createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "1.9.2");
|
||||
}
|
||||
|
||||
function webExtensionsTestPath(path) {
|
||||
if (path[0] != "/") {
|
||||
throw Error("Path must begin with '/': " + path);
|
||||
}
|
||||
|
||||
return "../../../../toolkit/components/extensions/test/xpcshell" + path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the WebExtension test functions by importing its test file.
|
||||
*/
|
||||
function loadWebExtensionTestFunctions() {
|
||||
const path = webExtensionsTestPath("/head_sync.js");
|
||||
let file = do_get_file(path);
|
||||
let uri = Services.io.newFileURI(file);
|
||||
Services.scriptloader.loadSubScript(uri.spec, gGlobalScope);
|
||||
}
|
||||
|
||||
function getAddonInstall(name) {
|
||||
let f = do_get_file(ExtensionsTestPath("/addons/" + name + ".xpi"));
|
||||
let cb = Async.makeSyncCallback();
|
||||
AddonManager.getInstallForFile(f, cb);
|
||||
|
||||
return Async.waitForSyncCallback(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtains an addon from the add-on manager by id.
|
||||
*
|
||||
* This is merely a synchronous wrapper.
|
||||
*
|
||||
* @param id
|
||||
* ID of add-on to fetch
|
||||
* @return addon object on success or undefined or null on failure
|
||||
*/
|
||||
function getAddonFromAddonManagerByID(id) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
AddonManager.getAddonByID(id, cb);
|
||||
return Async.waitForSyncCallback(cb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Installs an add-on synchronously from an addonInstall
|
||||
*
|
||||
* @param install addonInstall instance to install
|
||||
*/
|
||||
function installAddonFromInstall(install) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
let listener = {onInstallEnded: cb};
|
||||
AddonManager.addInstallListener(listener);
|
||||
install.install();
|
||||
Async.waitForSyncCallback(cb);
|
||||
AddonManager.removeAddonListener(listener);
|
||||
|
||||
do_check_neq(null, install.addon);
|
||||
do_check_neq(null, install.addon.syncGUID);
|
||||
|
||||
return install.addon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to install an add-on from the extensions unit tests.
|
||||
*
|
||||
* @param name
|
||||
* String name of add-on to install. e.g. test_install1
|
||||
* @return addon object that was installed
|
||||
*/
|
||||
function installAddon(name) {
|
||||
let install = getAddonInstall(name);
|
||||
do_check_neq(null, install);
|
||||
return installAddonFromInstall(install);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience function to uninstall an add-on synchronously.
|
||||
*
|
||||
* @param addon
|
||||
* Addon instance to uninstall
|
||||
*/
|
||||
function uninstallAddon(addon) {
|
||||
let cb = Async.makeSyncCallback();
|
||||
let listener = {onUninstalled: function(uninstalled) {
|
||||
if (uninstalled.id == addon.id) {
|
||||
AddonManager.removeAddonListener(listener);
|
||||
cb(uninstalled);
|
||||
}
|
||||
}};
|
||||
|
||||
AddonManager.addAddonListener(listener);
|
||||
addon.uninstall();
|
||||
Async.waitForSyncCallback(cb);
|
||||
}
|
||||
|
||||
function generateNewKeys(collectionKeys, collections=null) {
|
||||
let wbo = collectionKeys.generateNewKeysWBO(collections);
|
||||
let modified = new_timestamp();
|
||||
collectionKeys.setContents(wbo.cleartext, modified);
|
||||
}
|
||||
|
||||
// Helpers for testing open tabs.
|
||||
// These reflect part of the internal structure of TabEngine,
|
||||
// and stub part of Service.wm.
|
||||
|
||||
function mockShouldSkipWindow (win) {
|
||||
return win.closed ||
|
||||
win.mockIsPrivate;
|
||||
}
|
||||
|
||||
function mockGetTabState (tab) {
|
||||
return tab;
|
||||
}
|
||||
|
||||
function mockGetWindowEnumerator(url, numWindows, numTabs, indexes, moreURLs) {
|
||||
let elements = [];
|
||||
|
||||
function url2entry(url) {
|
||||
return {
|
||||
url: ((typeof url == "function") ? url() : url),
|
||||
title: "title"
|
||||
};
|
||||
}
|
||||
|
||||
for (let w = 0; w < numWindows; ++w) {
|
||||
let tabs = [];
|
||||
let win = {
|
||||
closed: false,
|
||||
mockIsPrivate: false,
|
||||
gBrowser: {
|
||||
tabs: tabs,
|
||||
},
|
||||
};
|
||||
elements.push(win);
|
||||
|
||||
for (let t = 0; t < numTabs; ++t) {
|
||||
tabs.push(TestingUtils.deepCopy({
|
||||
index: indexes ? indexes() : 1,
|
||||
entries: (moreURLs ? [url].concat(moreURLs()) : [url]).map(url2entry),
|
||||
attributes: {
|
||||
image: "image"
|
||||
},
|
||||
lastAccessed: 1499
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Always include a closed window and a private window.
|
||||
elements.push({
|
||||
closed: true,
|
||||
mockIsPrivate: false,
|
||||
gBrowser: {
|
||||
tabs: [],
|
||||
},
|
||||
});
|
||||
|
||||
elements.push({
|
||||
closed: false,
|
||||
mockIsPrivate: true,
|
||||
gBrowser: {
|
||||
tabs: [],
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
hasMoreElements: function () {
|
||||
return elements.length;
|
||||
},
|
||||
getNext: function () {
|
||||
return elements.shift();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper that allows checking array equality.
|
||||
function do_check_array_eq(a1, a2) {
|
||||
do_check_eq(a1.length, a2.length);
|
||||
for (let i = 0; i < a1.length; ++i) {
|
||||
do_check_eq(a1[i], a2[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get the sync telemetry and add the typically used test
|
||||
// engine names to its list of allowed engines.
|
||||
function get_sync_test_telemetry() {
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/telemetry.js", ns);
|
||||
let testEngines = ["rotary", "steam", "sterling", "catapult"];
|
||||
for (let engineName of testEngines) {
|
||||
ns.SyncTelemetry.allowedEngines.add(engineName);
|
||||
}
|
||||
ns.SyncTelemetry.submissionInterval = -1;
|
||||
return ns.SyncTelemetry;
|
||||
}
|
||||
|
||||
function assert_valid_ping(record) {
|
||||
// This is called as the test harness tears down due to shutdown. This
|
||||
// will typically have no recorded syncs, and the validator complains about
|
||||
// it. So ignore such records (but only ignore when *both* shutdown and
|
||||
// no Syncs - either of them not being true might be an actual problem)
|
||||
if (record && (record.why != "shutdown" || record.syncs.length != 0)) {
|
||||
if (!SyncPingValidator(record)) {
|
||||
deepEqual([], SyncPingValidator.errors, "Sync telemetry ping validation failed");
|
||||
}
|
||||
equal(record.version, 1);
|
||||
record.syncs.forEach(p => {
|
||||
lessOrEqual(p.when, Date.now());
|
||||
if (p.devices) {
|
||||
ok(!p.devices.some(device => device.id == p.deviceID));
|
||||
equal(new Set(p.devices.map(device => device.id)).size,
|
||||
p.devices.length, "Duplicate device ids in ping devices list");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Asserts that `ping` is a ping that doesn't contain any failure information
|
||||
function assert_success_ping(ping) {
|
||||
ok(!!ping);
|
||||
assert_valid_ping(ping);
|
||||
ping.syncs.forEach(record => {
|
||||
ok(!record.failureReason);
|
||||
equal(undefined, record.status);
|
||||
greater(record.engines.length, 0);
|
||||
for (let e of record.engines) {
|
||||
ok(!e.failureReason);
|
||||
equal(undefined, e.status);
|
||||
if (e.validation) {
|
||||
equal(undefined, e.validation.problems);
|
||||
equal(undefined, e.validation.failureReason);
|
||||
}
|
||||
if (e.outgoing) {
|
||||
for (let o of e.outgoing) {
|
||||
equal(undefined, o.failed);
|
||||
notEqual(undefined, o.sent);
|
||||
}
|
||||
}
|
||||
if (e.incoming) {
|
||||
equal(undefined, e.incoming.failed);
|
||||
equal(undefined, e.incoming.newFailed);
|
||||
notEqual(undefined, e.incoming.applied || e.incoming.reconciled);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Hooks into telemetry to validate all pings after calling.
|
||||
function validate_all_future_pings() {
|
||||
let telem = get_sync_test_telemetry();
|
||||
telem.submit = assert_valid_ping;
|
||||
}
|
||||
|
||||
function wait_for_ping(callback, allowErrorPings, getFullPing = false) {
|
||||
return new Promise(resolve => {
|
||||
let telem = get_sync_test_telemetry();
|
||||
let oldSubmit = telem.submit;
|
||||
telem.submit = function(record) {
|
||||
telem.submit = oldSubmit;
|
||||
if (allowErrorPings) {
|
||||
assert_valid_ping(record);
|
||||
} else {
|
||||
assert_success_ping(record);
|
||||
}
|
||||
if (getFullPing) {
|
||||
resolve(record);
|
||||
} else {
|
||||
equal(record.syncs.length, 1);
|
||||
resolve(record.syncs[0]);
|
||||
}
|
||||
};
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
// Short helper for wait_for_ping
|
||||
function sync_and_validate_telem(allowErrorPings, getFullPing = false) {
|
||||
return wait_for_ping(() => Service.sync(), allowErrorPings, getFullPing);
|
||||
}
|
||||
|
||||
// Used for the (many) cases where we do a 'partial' sync, where only a single
|
||||
// engine is actually synced, but we still want to ensure we're generating a
|
||||
// valid ping. Returns a promise that resolves to the ping, or rejects with the
|
||||
// thrown error after calling an optional callback.
|
||||
function sync_engine_and_validate_telem(engine, allowErrorPings, onError) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let telem = get_sync_test_telemetry();
|
||||
let caughtError = null;
|
||||
// Clear out status, so failures from previous syncs won't show up in the
|
||||
// telemetry ping.
|
||||
let ns = {};
|
||||
Cu.import("resource://services-sync/status.js", ns);
|
||||
ns.Status._engines = {};
|
||||
ns.Status.partial = false;
|
||||
// Ideally we'd clear these out like we do with engines, (probably via
|
||||
// Status.resetSync()), but this causes *numerous* tests to fail, so we just
|
||||
// assume that if no failureReason or engine failures are set, and the
|
||||
// status properties are the same as they were initially, that it's just
|
||||
// a leftover.
|
||||
// This is only an issue since we're triggering the sync of just one engine,
|
||||
// without doing any other parts of the sync.
|
||||
let initialServiceStatus = ns.Status._service;
|
||||
let initialSyncStatus = ns.Status._sync;
|
||||
|
||||
let oldSubmit = telem.submit;
|
||||
telem.submit = function(ping) {
|
||||
telem.submit = oldSubmit;
|
||||
ping.syncs.forEach(record => {
|
||||
if (record && record.status) {
|
||||
// did we see anything to lead us to believe that something bad actually happened
|
||||
let realProblem = record.failureReason || record.engines.some(e => {
|
||||
if (e.failureReason || e.status) {
|
||||
return true;
|
||||
}
|
||||
if (e.outgoing && e.outgoing.some(o => o.failed > 0)) {
|
||||
return true;
|
||||
}
|
||||
return e.incoming && e.incoming.failed;
|
||||
});
|
||||
if (!realProblem) {
|
||||
// no, so if the status is the same as it was initially, just assume
|
||||
// that its leftover and that we can ignore it.
|
||||
if (record.status.sync && record.status.sync == initialSyncStatus) {
|
||||
delete record.status.sync;
|
||||
}
|
||||
if (record.status.service && record.status.service == initialServiceStatus) {
|
||||
delete record.status.service;
|
||||
}
|
||||
if (!record.status.sync && !record.status.service) {
|
||||
delete record.status;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if (allowErrorPings) {
|
||||
assert_valid_ping(ping);
|
||||
} else {
|
||||
assert_success_ping(ping);
|
||||
}
|
||||
equal(ping.syncs.length, 1);
|
||||
if (caughtError) {
|
||||
if (onError) {
|
||||
onError(ping.syncs[0]);
|
||||
}
|
||||
reject(caughtError);
|
||||
} else {
|
||||
resolve(ping.syncs[0]);
|
||||
}
|
||||
}
|
||||
Svc.Obs.notify("weave:service:sync:start");
|
||||
try {
|
||||
engine.sync();
|
||||
} catch (e) {
|
||||
caughtError = e;
|
||||
}
|
||||
if (caughtError) {
|
||||
Svc.Obs.notify("weave:service:sync:error", caughtError);
|
||||
} else {
|
||||
Svc.Obs.notify("weave:service:sync:finish");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Avoid an issue where `client.name2` containing unicode characters causes
|
||||
// a number of tests to fail, due to them assuming that we do not need to utf-8
|
||||
// encode or decode data sent through the mocked server (see bug 1268912).
|
||||
Utils.getDefaultDeviceName = function() {
|
||||
return "Test device name";
|
||||
};
|
||||
|
||||
|
||||
1044
services/sync/tests/unit/head_http_server.js
Normal file
1044
services/sync/tests/unit/head_http_server.js
Normal file
File diff suppressed because it is too large
Load diff
27
services/sync/tests/unit/missing-sourceuri.xml
Normal file
27
services/sync/tests/unit/missing-sourceuri.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Restartless Test Extension</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>missing-sourceuri@tests.mozilla.org</guid>
|
||||
<slug>missing-sourceuri</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485"></install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
27
services/sync/tests/unit/missing-xpi-search.xml
Normal file
27
services/sync/tests/unit/missing-xpi-search.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Restartless Test Extension</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>missing-xpi@tests.mozilla.org</guid>
|
||||
<slug>missing-xpi</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>{3e3ba16c-1675-4e88-b9c8-afef81b3d2ef}</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485">http://127.0.0.1:8888/THIS_DOES_NOT_EXIST.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
BIN
services/sync/tests/unit/places_v10_from_v11.sqlite
Normal file
BIN
services/sync/tests/unit/places_v10_from_v11.sqlite
Normal file
Binary file not shown.
25
services/sync/tests/unit/prefs_test_prefs_store.js
Normal file
25
services/sync/tests/unit/prefs_test_prefs_store.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// This is a "preferences" file used by test_prefs_store.js
|
||||
|
||||
// The prefs that control what should be synced.
|
||||
// Most of these are "default" prefs, so the value itself will not sync.
|
||||
pref("services.sync.prefs.sync.testing.int", true);
|
||||
pref("services.sync.prefs.sync.testing.string", true);
|
||||
pref("services.sync.prefs.sync.testing.bool", true);
|
||||
pref("services.sync.prefs.sync.testing.dont.change", true);
|
||||
// this one is a user pref, so it *will* sync.
|
||||
user_pref("services.sync.prefs.sync.testing.turned.off", false);
|
||||
pref("services.sync.prefs.sync.testing.nonexistent", true);
|
||||
pref("services.sync.prefs.sync.testing.default", true);
|
||||
|
||||
// The preference values - these are all user_prefs, otherwise their value
|
||||
// will not be synced.
|
||||
user_pref("testing.int", 123);
|
||||
user_pref("testing.string", "ohai");
|
||||
user_pref("testing.bool", true);
|
||||
user_pref("testing.dont.change", "Please don't change me.");
|
||||
user_pref("testing.turned.off", "I won't get synced.");
|
||||
user_pref("testing.not.turned.on", "I won't get synced either!");
|
||||
|
||||
// A pref that exists but still has the default value - will be synced with
|
||||
// null as the value.
|
||||
pref("testing.default", "I'm the default value");
|
||||
27
services/sync/tests/unit/rewrite-search.xml
Normal file
27
services/sync/tests/unit/rewrite-search.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5617">
|
||||
<name>Rewrite Test Extension</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>rewrite@tests.mozilla.org</guid>
|
||||
<slug>rewrite</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>xpcshell@tests.mozilla.org</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="485">http://127.0.0.1:8888/require.xpi?src=api</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
198
services/sync/tests/unit/sync_ping_schema.json
Normal file
198
services/sync/tests/unit/sync_ping_schema.json
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"description": "schema for Sync pings, documentation avaliable in toolkit/components/telemetry/docs/sync-ping.rst",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "syncs", "why"],
|
||||
"properties": {
|
||||
"version": { "type": "integer", "minimum": 0 },
|
||||
"discarded": { "type": "integer", "minimum": 1 },
|
||||
"why": { "enum": ["shutdown", "schedule"] },
|
||||
"syncs": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/payload" }
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["when", "uid", "took"],
|
||||
"properties": {
|
||||
"didLogin": { "type": "boolean" },
|
||||
"when": { "type": "integer" },
|
||||
"uid": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{32}$"
|
||||
},
|
||||
"devices": {
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/device" }
|
||||
},
|
||||
"deviceID": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9a-f]{64}$"
|
||||
},
|
||||
"status": {
|
||||
"type": "object",
|
||||
"anyOf": [
|
||||
{ "required": ["sync"] },
|
||||
{ "required": ["service"] }
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"sync": { "type": "string" },
|
||||
"service": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"why": { "enum": ["startup", "schedule", "score", "user", "tabs"] },
|
||||
"took": { "type": "integer", "minimum": -1 },
|
||||
"failureReason": { "$ref": "#/definitions/error" },
|
||||
"engines": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/engine" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"device": {
|
||||
"required": ["os", "id", "version"],
|
||||
"additionalProperties": false,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
|
||||
"os": { "type": "string" },
|
||||
"version": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"required": ["name"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"failureReason": { "$ref": "#/definitions/error" },
|
||||
"name": { "enum": ["addons", "bookmarks", "clients", "forms", "history", "passwords", "prefs", "tabs"] },
|
||||
"took": { "type": "integer", "minimum": 1 },
|
||||
"status": { "type": "string" },
|
||||
"incoming": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"anyOf": [
|
||||
{"required": ["applied"]},
|
||||
{"required": ["failed"]},
|
||||
{"required": ["newFailed"]},
|
||||
{"required": ["reconciled"]}
|
||||
],
|
||||
"properties": {
|
||||
"applied": { "type": "integer", "minimum": 1 },
|
||||
"failed": { "type": "integer", "minimum": 1 },
|
||||
"newFailed": { "type": "integer", "minimum": 1 },
|
||||
"reconciled": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"outgoing": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/definitions/outgoingBatch" }
|
||||
},
|
||||
"validation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"anyOf": [
|
||||
{ "required": ["checked"] },
|
||||
{ "required": ["failureReason"] }
|
||||
],
|
||||
"properties": {
|
||||
"checked": { "type": "integer", "minimum": 0 },
|
||||
"failureReason": { "$ref": "#/definitions/error" },
|
||||
"took": { "type": "integer" },
|
||||
"version": { "type": "integer" },
|
||||
"problems": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"$ref": "#/definitions/validationProblem"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"outgoingBatch": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"anyOf": [
|
||||
{"required": ["sent"]},
|
||||
{"required": ["failed"]}
|
||||
],
|
||||
"properties": {
|
||||
"sent": { "type": "integer", "minimum": 1 },
|
||||
"failed": { "type": "integer", "minimum": 1 }
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/definitions/httpError" },
|
||||
{ "$ref": "#/definitions/nsError" },
|
||||
{ "$ref": "#/definitions/shutdownError" },
|
||||
{ "$ref": "#/definitions/authError" },
|
||||
{ "$ref": "#/definitions/otherError" },
|
||||
{ "$ref": "#/definitions/unexpectedError" },
|
||||
{ "$ref": "#/definitions/sqlError" }
|
||||
]
|
||||
},
|
||||
"httpError": {
|
||||
"required": ["name", "code"],
|
||||
"properties": {
|
||||
"name": { "enum": ["httperror"] },
|
||||
"code": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"nsError": {
|
||||
"required": ["name", "code"],
|
||||
"properties": {
|
||||
"name": { "enum": ["nserror"] },
|
||||
"code": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"shutdownError": {
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "enum": ["shutdownerror"] }
|
||||
}
|
||||
},
|
||||
"authError": {
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "enum": ["autherror"] },
|
||||
"from": { "enum": ["tokenserver", "fxaccounts", "hawkclient"] }
|
||||
}
|
||||
},
|
||||
"otherError": {
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "enum": ["othererror"] },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"unexpectedError": {
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "enum": ["unexpectederror"] },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"sqlError": {
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": { "enum": ["sqlerror"] },
|
||||
"code": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"validationProblem": {
|
||||
"required": ["name", "count"],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"count": { "type": "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
services/sync/tests/unit/systemaddon-search.xml
Normal file
27
services/sync/tests/unit/systemaddon-search.xml
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<searchresults total_results="1">
|
||||
<addon id="5618">
|
||||
<name>System Add-on Test</name>
|
||||
<type id="1">Extension</type>
|
||||
<guid>system1@tests.mozilla.org</guid>
|
||||
<slug>addon11</slug>
|
||||
<version>1.0</version>
|
||||
|
||||
<compatible_applications><application>
|
||||
<name>Firefox</name>
|
||||
<application_id>1</application_id>
|
||||
<min_version>3.6</min_version>
|
||||
<max_version>*</max_version>
|
||||
<appID>xpcshell@tests.mozilla.org</appID>
|
||||
</application></compatible_applications>
|
||||
<all_compatible_os><os>ALL</os></all_compatible_os>
|
||||
|
||||
<install os="ALL" size="999">http://127.0.0.1:8888/system.xpi</install>
|
||||
<created epoch="1252903662">
|
||||
2009-09-14T04:47:42Z
|
||||
</created>
|
||||
<last_updated epoch="1315255329">
|
||||
2011-09-05T20:42:09Z
|
||||
</last_updated>
|
||||
</addon>
|
||||
</searchresults>
|
||||
141
services/sync/tests/unit/test_addon_utils.js
Normal file
141
services/sync/tests/unit/test_addon_utils.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/Log.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://services-sync/addonutils.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
const HTTP_PORT = 8888;
|
||||
const SERVER_ADDRESS = "http://127.0.0.1:8888";
|
||||
|
||||
var prefs = new Preferences();
|
||||
|
||||
prefs.set("extensions.getAddons.get.url",
|
||||
SERVER_ADDRESS + "/search/guid:%IDS%");
|
||||
|
||||
loadAddonTestFunctions();
|
||||
startupManager();
|
||||
|
||||
function createAndStartHTTPServer(port=HTTP_PORT) {
|
||||
try {
|
||||
let server = new HttpServer();
|
||||
|
||||
let bootstrap1XPI = ExtensionsTestPath("/addons/test_bootstrap1_1.xpi");
|
||||
|
||||
server.registerFile("/search/guid:missing-sourceuri%40tests.mozilla.org",
|
||||
do_get_file("missing-sourceuri.xml"));
|
||||
|
||||
server.registerFile("/search/guid:rewrite%40tests.mozilla.org",
|
||||
do_get_file("rewrite-search.xml"));
|
||||
|
||||
server.start(port);
|
||||
|
||||
return server;
|
||||
} catch (ex) {
|
||||
_("Got exception starting HTTP server on port " + port);
|
||||
_("Error: " + Log.exceptionStr(ex));
|
||||
do_throw(ex);
|
||||
}
|
||||
}
|
||||
|
||||
function run_test() {
|
||||
initTestLogging("Trace");
|
||||
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_test(function test_handle_empty_source_uri() {
|
||||
_("Ensure that search results without a sourceURI are properly ignored.");
|
||||
|
||||
let server = createAndStartHTTPServer();
|
||||
|
||||
const ID = "missing-sourceuri@tests.mozilla.org";
|
||||
|
||||
let cb = Async.makeSpinningCallback();
|
||||
AddonUtils.installAddons([{id: ID, requireSecureURI: false}], cb);
|
||||
let result = cb.wait();
|
||||
|
||||
do_check_true("installedIDs" in result);
|
||||
do_check_eq(0, result.installedIDs.length);
|
||||
|
||||
do_check_true("skipped" in result);
|
||||
do_check_true(result.skipped.includes(ID));
|
||||
|
||||
server.stop(run_next_test);
|
||||
});
|
||||
|
||||
add_test(function test_ignore_untrusted_source_uris() {
|
||||
_("Ensures that source URIs from insecure schemes are rejected.");
|
||||
|
||||
let ioService = Cc["@mozilla.org/network/io-service;1"]
|
||||
.getService(Ci.nsIIOService);
|
||||
|
||||
const bad = ["http://example.com/foo.xpi",
|
||||
"ftp://example.com/foo.xpi",
|
||||
"silly://example.com/foo.xpi"];
|
||||
|
||||
const good = ["https://example.com/foo.xpi"];
|
||||
|
||||
for (let s of bad) {
|
||||
let sourceURI = ioService.newURI(s, null, null);
|
||||
let addon = {sourceURI: sourceURI, name: "bad", id: "bad"};
|
||||
|
||||
let canInstall = AddonUtils.canInstallAddon(addon);
|
||||
do_check_false(canInstall, "Correctly rejected a bad URL");
|
||||
}
|
||||
|
||||
for (let s of good) {
|
||||
let sourceURI = ioService.newURI(s, null, null);
|
||||
let addon = {sourceURI: sourceURI, name: "good", id: "good"};
|
||||
|
||||
let canInstall = AddonUtils.canInstallAddon(addon);
|
||||
do_check_true(canInstall, "Correctly accepted a good URL");
|
||||
}
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_source_uri_rewrite() {
|
||||
_("Ensure that a 'src=api' query string is rewritten to 'src=sync'");
|
||||
|
||||
// This tests for conformance with bug 708134 so server-side metrics aren't
|
||||
// skewed.
|
||||
|
||||
// We resort to monkeypatching because of the API design.
|
||||
let oldFunction = AddonUtils.__proto__.installAddonFromSearchResult;
|
||||
|
||||
let installCalled = false;
|
||||
AddonUtils.__proto__.installAddonFromSearchResult =
|
||||
function testInstallAddon(addon, metadata, cb) {
|
||||
|
||||
do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync",
|
||||
addon.sourceURI.spec);
|
||||
|
||||
installCalled = true;
|
||||
|
||||
AddonUtils.getInstallFromSearchResult(addon, function (error, install) {
|
||||
do_check_null(error);
|
||||
do_check_eq(SERVER_ADDRESS + "/require.xpi?src=sync",
|
||||
install.sourceURI.spec);
|
||||
|
||||
cb(null, {id: addon.id, addon: addon, install: install});
|
||||
}, false);
|
||||
};
|
||||
|
||||
let server = createAndStartHTTPServer();
|
||||
|
||||
let installCallback = Async.makeSpinningCallback();
|
||||
let installOptions = {
|
||||
id: "rewrite@tests.mozilla.org",
|
||||
requireSecureURI: false,
|
||||
}
|
||||
AddonUtils.installAddons([installOptions], installCallback);
|
||||
|
||||
installCallback.wait();
|
||||
do_check_true(installCalled);
|
||||
AddonUtils.__proto__.installAddonFromSearchResult = oldFunction;
|
||||
|
||||
server.stop(run_next_test);
|
||||
});
|
||||
253
services/sync/tests/unit/test_addons_engine.js
Normal file
253
services/sync/tests/unit/test_addons_engine.js
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/AddonManager.jsm");
|
||||
Cu.import("resource://gre/modules/Preferences.jsm");
|
||||
Cu.import("resource://gre/modules/Services.jsm");
|
||||
Cu.import("resource://services-common/async.js");
|
||||
Cu.import("resource://services-sync/addonsreconciler.js");
|
||||
Cu.import("resource://services-sync/engines/addons.js");
|
||||
Cu.import("resource://services-sync/service.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
Cu.import("resource://testing-common/services/sync/utils.js");
|
||||
|
||||
var prefs = new Preferences();
|
||||
prefs.set("extensions.getAddons.get.url",
|
||||
"http://localhost:8888/search/guid:%IDS%");
|
||||
prefs.set("extensions.install.requireSecureOrigin", false);
|
||||
|
||||
loadAddonTestFunctions();
|
||||
startupManager();
|
||||
|
||||
var engineManager = Service.engineManager;
|
||||
|
||||
engineManager.register(AddonsEngine);
|
||||
var engine = engineManager.get("addons");
|
||||
var reconciler = engine._reconciler;
|
||||
var tracker = engine._tracker;
|
||||
|
||||
function advance_test() {
|
||||
reconciler._addons = {};
|
||||
reconciler._changes = [];
|
||||
|
||||
let cb = Async.makeSpinningCallback();
|
||||
reconciler.saveState(null, cb);
|
||||
cb.wait();
|
||||
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
// This is a basic sanity test for the unit test itself. If this breaks, the
|
||||
// add-ons API likely changed upstream.
|
||||
add_test(function test_addon_install() {
|
||||
_("Ensure basic add-on APIs work as expected.");
|
||||
|
||||
let install = getAddonInstall("test_bootstrap1_1");
|
||||
do_check_neq(install, null);
|
||||
do_check_eq(install.type, "extension");
|
||||
do_check_eq(install.name, "Test Bootstrap 1");
|
||||
|
||||
advance_test();
|
||||
});
|
||||
|
||||
add_test(function test_find_dupe() {
|
||||
_("Ensure the _findDupe() implementation is sane.");
|
||||
|
||||
// This gets invoked at the top of sync, which is bypassed by this
|
||||
// test, so we do it manually.
|
||||
engine._refreshReconcilerState();
|
||||
|
||||
let addon = installAddon("test_bootstrap1_1");
|
||||
|
||||
let record = {
|
||||
id: Utils.makeGUID(),
|
||||
addonID: addon.id,
|
||||
enabled: true,
|
||||
applicationID: Services.appinfo.ID,
|
||||
source: "amo"
|
||||
};
|
||||
|
||||
let dupe = engine._findDupe(record);
|
||||
do_check_eq(addon.syncGUID, dupe);
|
||||
|
||||
record.id = addon.syncGUID;
|
||||
dupe = engine._findDupe(record);
|
||||
do_check_eq(null, dupe);
|
||||
|
||||
uninstallAddon(addon);
|
||||
advance_test();
|
||||
});
|
||||
|
||||
add_test(function test_get_changed_ids() {
|
||||
_("Ensure getChangedIDs() has the appropriate behavior.");
|
||||
|
||||
_("Ensure getChangedIDs() returns an empty object by default.");
|
||||
let changes = engine.getChangedIDs();
|
||||
do_check_eq("object", typeof(changes));
|
||||
do_check_eq(0, Object.keys(changes).length);
|
||||
|
||||
_("Ensure tracker changes are populated.");
|
||||
let now = new Date();
|
||||
let changeTime = now.getTime() / 1000;
|
||||
let guid1 = Utils.makeGUID();
|
||||
tracker.addChangedID(guid1, changeTime);
|
||||
|
||||
changes = engine.getChangedIDs();
|
||||
do_check_eq("object", typeof(changes));
|
||||
do_check_eq(1, Object.keys(changes).length);
|
||||
do_check_true(guid1 in changes);
|
||||
do_check_eq(changeTime, changes[guid1]);
|
||||
|
||||
tracker.clearChangedIDs();
|
||||
|
||||
_("Ensure reconciler changes are populated.");
|
||||
let addon = installAddon("test_bootstrap1_1");
|
||||
tracker.clearChangedIDs(); // Just in case.
|
||||
changes = engine.getChangedIDs();
|
||||
do_check_eq("object", typeof(changes));
|
||||
do_check_eq(1, Object.keys(changes).length);
|
||||
do_check_true(addon.syncGUID in changes);
|
||||
_("Change time: " + changeTime + ", addon change: " + changes[addon.syncGUID]);
|
||||
do_check_true(changes[addon.syncGUID] >= changeTime);
|
||||
|
||||
let oldTime = changes[addon.syncGUID];
|
||||
let guid2 = addon.syncGUID;
|
||||
uninstallAddon(addon);
|
||||
changes = engine.getChangedIDs();
|
||||
do_check_eq(1, Object.keys(changes).length);
|
||||
do_check_true(guid2 in changes);
|
||||
do_check_true(changes[guid2] > oldTime);
|
||||
|
||||
_("Ensure non-syncable add-ons aren't picked up by reconciler changes.");
|
||||
reconciler._addons = {};
|
||||
reconciler._changes = [];
|
||||
let record = {
|
||||
id: "DUMMY",
|
||||
guid: Utils.makeGUID(),
|
||||
enabled: true,
|
||||
installed: true,
|
||||
modified: new Date(),
|
||||
type: "UNSUPPORTED",
|
||||
scope: 0,
|
||||
foreignInstall: false
|
||||
};
|
||||
reconciler.addons["DUMMY"] = record;
|
||||
reconciler._addChange(record.modified, CHANGE_INSTALLED, record);
|
||||
|
||||
changes = engine.getChangedIDs();
|
||||
_(JSON.stringify(changes));
|
||||
do_check_eq(0, Object.keys(changes).length);
|
||||
|
||||
advance_test();
|
||||
});
|
||||
|
||||
add_test(function test_disabled_install_semantics() {
|
||||
_("Ensure that syncing a disabled add-on preserves proper state.");
|
||||
|
||||
// This is essentially a test for bug 712542, which snuck into the original
|
||||
// add-on sync drop. It ensures that when an add-on is installed that the
|
||||
// disabled state and incoming syncGUID is preserved, even on the next sync.
|
||||
const USER = "foo";
|
||||
const PASSWORD = "password";
|
||||
const PASSPHRASE = "abcdeabcdeabcdeabcdeabcdea";
|
||||
const ADDON_ID = "addon1@tests.mozilla.org";
|
||||
|
||||
let server = new SyncServer();
|
||||
server.start();
|
||||
new SyncTestingInfrastructure(server.server, USER, PASSWORD, PASSPHRASE);
|
||||
|
||||
generateNewKeys(Service.collectionKeys);
|
||||
|
||||
let contents = {
|
||||
meta: {global: {engines: {addons: {version: engine.version,
|
||||
syncID: engine.syncID}}}},
|
||||
crypto: {},
|
||||
addons: {}
|
||||
};
|
||||
|
||||
server.registerUser(USER, "password");
|
||||
server.createContents(USER, contents);
|
||||
|
||||
let amoServer = new HttpServer();
|
||||
amoServer.registerFile("/search/guid:addon1%40tests.mozilla.org",
|
||||
do_get_file("addon1-search.xml"));
|
||||
|
||||
let installXPI = ExtensionsTestPath("/addons/test_install1.xpi");
|
||||
amoServer.registerFile("/addon1.xpi", do_get_file(installXPI));
|
||||
amoServer.start(8888);
|
||||
|
||||
// Insert an existing record into the server.
|
||||
let id = Utils.makeGUID();
|
||||
let now = Date.now() / 1000;
|
||||
|
||||
let record = encryptPayload({
|
||||
id: id,
|
||||
applicationID: Services.appinfo.ID,
|
||||
addonID: ADDON_ID,
|
||||
enabled: false,
|
||||
deleted: false,
|
||||
source: "amo",
|
||||
});
|
||||
let wbo = new ServerWBO(id, record, now - 2);
|
||||
server.insertWBO(USER, "addons", wbo);
|
||||
|
||||
_("Performing sync of add-ons engine.");
|
||||
engine._sync();
|
||||
|
||||
// At this point the non-restartless extension should be staged for install.
|
||||
|
||||
// Don't need this server any more.
|
||||
let cb = Async.makeSpinningCallback();
|
||||
amoServer.stop(cb);
|
||||
cb.wait();
|
||||
|
||||
// We ensure the reconciler has recorded the proper ID and enabled state.
|
||||
let addon = reconciler.getAddonStateFromSyncGUID(id);
|
||||
do_check_neq(null, addon);
|
||||
do_check_eq(false, addon.enabled);
|
||||
|
||||
// We fake an app restart and perform another sync, just to make sure things
|
||||
// are sane.
|
||||
restartManager();
|
||||
|
||||
engine._sync();
|
||||
|
||||
// The client should not upload a new record. The old record should be
|
||||
// retained and unmodified.
|
||||
let collection = server.getCollection(USER, "addons");
|
||||
do_check_eq(1, collection.count());
|
||||
|
||||
let payload = collection.payloads()[0];
|
||||
do_check_neq(null, collection.wbo(id));
|
||||
do_check_eq(ADDON_ID, payload.addonID);
|
||||
do_check_false(payload.enabled);
|
||||
|
||||
server.stop(advance_test);
|
||||
});
|
||||
|
||||
add_test(function cleanup() {
|
||||
// There's an xpcom-shutdown hook for this, but let's give this a shot.
|
||||
reconciler.stopListening();
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
function run_test() {
|
||||
initTestLogging("Trace");
|
||||
Log.repository.getLogger("Sync.Engine.Addons").level =
|
||||
Log.Level.Trace;
|
||||
Log.repository.getLogger("Sync.Store.Addons").level = Log.Level.Trace;
|
||||
Log.repository.getLogger("Sync.Tracker.Addons").level =
|
||||
Log.Level.Trace;
|
||||
Log.repository.getLogger("Sync.AddonsRepository").level =
|
||||
Log.Level.Trace;
|
||||
|
||||
reconciler.startListening();
|
||||
|
||||
// Don't flush to disk in the middle of an event listener!
|
||||
// This causes test hangs on WinXP.
|
||||
reconciler._shouldPersist = false;
|
||||
|
||||
advance_test();
|
||||
}
|
||||
195
services/sync/tests/unit/test_addons_reconciler.js
Normal file
195
services/sync/tests/unit/test_addons_reconciler.js
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
/* Any copyright is dedicated to the Public Domain.
|
||||
http://creativecommons.org/publicdomain/zero/1.0/ */
|
||||
|
||||
"use strict";
|
||||
|
||||
Cu.import("resource://gre/modules/AddonManager.jsm");
|
||||
Cu.import("resource://services-sync/addonsreconciler.js");
|
||||
Cu.import("resource://services-sync/engines/addons.js");
|
||||
Cu.import("resource://services-sync/service.js");
|
||||
Cu.import("resource://services-sync/util.js");
|
||||
|
||||
loadAddonTestFunctions();
|
||||
startupManager();
|
||||
|
||||
function run_test() {
|
||||
initTestLogging("Trace");
|
||||
Log.repository.getLogger("Sync.AddonsReconciler").level = Log.Level.Trace;
|
||||
Log.repository.getLogger("Sync.AddonsReconciler").level =
|
||||
Log.Level.Trace;
|
||||
|
||||
Svc.Prefs.set("engine.addons", true);
|
||||
Service.engineManager.register(AddonsEngine);
|
||||
|
||||
run_next_test();
|
||||
}
|
||||
|
||||
add_test(function test_defaults() {
|
||||
_("Ensure new objects have reasonable defaults.");
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
|
||||
do_check_false(reconciler._listening);
|
||||
do_check_eq("object", typeof(reconciler.addons));
|
||||
do_check_eq(0, Object.keys(reconciler.addons).length);
|
||||
do_check_eq(0, reconciler._changes.length);
|
||||
do_check_eq(0, reconciler._listeners.length);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_load_state_empty_file() {
|
||||
_("Ensure loading from a missing file results in defaults being set.");
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
|
||||
reconciler.loadState(null, function(error, loaded) {
|
||||
do_check_eq(null, error);
|
||||
do_check_false(loaded);
|
||||
|
||||
do_check_eq("object", typeof(reconciler.addons));
|
||||
do_check_eq(0, Object.keys(reconciler.addons).length);
|
||||
do_check_eq(0, reconciler._changes.length);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
});
|
||||
|
||||
add_test(function test_install_detection() {
|
||||
_("Ensure that add-on installation results in appropriate side-effects.");
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
reconciler.startListening();
|
||||
|
||||
let before = new Date();
|
||||
let addon = installAddon("test_bootstrap1_1");
|
||||
let after = new Date();
|
||||
|
||||
do_check_eq(1, Object.keys(reconciler.addons).length);
|
||||
do_check_true(addon.id in reconciler.addons);
|
||||
let record = reconciler.addons[addon.id];
|
||||
|
||||
const KEYS = ["id", "guid", "enabled", "installed", "modified", "type",
|
||||
"scope", "foreignInstall"];
|
||||
for (let key of KEYS) {
|
||||
do_check_true(key in record);
|
||||
do_check_neq(null, record[key]);
|
||||
}
|
||||
|
||||
do_check_eq(addon.id, record.id);
|
||||
do_check_eq(addon.syncGUID, record.guid);
|
||||
do_check_true(record.enabled);
|
||||
do_check_true(record.installed);
|
||||
do_check_true(record.modified >= before && record.modified <= after);
|
||||
do_check_eq("extension", record.type);
|
||||
do_check_false(record.foreignInstall);
|
||||
|
||||
do_check_eq(1, reconciler._changes.length);
|
||||
let change = reconciler._changes[0];
|
||||
do_check_true(change[0] >= before && change[1] <= after);
|
||||
do_check_eq(CHANGE_INSTALLED, change[1]);
|
||||
do_check_eq(addon.id, change[2]);
|
||||
|
||||
uninstallAddon(addon);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_uninstall_detection() {
|
||||
_("Ensure that add-on uninstallation results in appropriate side-effects.");
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
reconciler.startListening();
|
||||
|
||||
reconciler._addons = {};
|
||||
reconciler._changes = [];
|
||||
|
||||
let addon = installAddon("test_bootstrap1_1");
|
||||
let id = addon.id;
|
||||
let guid = addon.syncGUID;
|
||||
|
||||
reconciler._changes = [];
|
||||
uninstallAddon(addon);
|
||||
|
||||
do_check_eq(1, Object.keys(reconciler.addons).length);
|
||||
do_check_true(id in reconciler.addons);
|
||||
|
||||
let record = reconciler.addons[id];
|
||||
do_check_false(record.installed);
|
||||
|
||||
do_check_eq(1, reconciler._changes.length);
|
||||
let change = reconciler._changes[0];
|
||||
do_check_eq(CHANGE_UNINSTALLED, change[1]);
|
||||
do_check_eq(id, change[2]);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
|
||||
add_test(function test_load_state_future_version() {
|
||||
_("Ensure loading a file from a future version results in no data loaded.");
|
||||
|
||||
const FILENAME = "TEST_LOAD_STATE_FUTURE_VERSION";
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
|
||||
// First we populate our new file.
|
||||
let state = {version: 100, addons: {foo: {}}, changes: [[1, 1, "foo"]]};
|
||||
let cb = Async.makeSyncCallback();
|
||||
|
||||
// jsonSave() expects an object with ._log, so we give it a reconciler
|
||||
// instance.
|
||||
Utils.jsonSave(FILENAME, reconciler, state, cb);
|
||||
Async.waitForSyncCallback(cb);
|
||||
|
||||
reconciler.loadState(FILENAME, function(error, loaded) {
|
||||
do_check_eq(null, error);
|
||||
do_check_false(loaded);
|
||||
|
||||
do_check_eq("object", typeof(reconciler.addons));
|
||||
do_check_eq(1, Object.keys(reconciler.addons).length);
|
||||
do_check_eq(1, reconciler._changes.length);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
});
|
||||
|
||||
add_test(function test_prune_changes_before_date() {
|
||||
_("Ensure that old changes are pruned properly.");
|
||||
|
||||
let reconciler = new AddonsReconciler();
|
||||
reconciler._ensureStateLoaded();
|
||||
reconciler._changes = [];
|
||||
|
||||
let now = new Date();
|
||||
const HOUR_MS = 1000 * 60 * 60;
|
||||
|
||||
_("Ensure pruning an empty changes array works.");
|
||||
reconciler.pruneChangesBeforeDate(now);
|
||||
do_check_eq(0, reconciler._changes.length);
|
||||
|
||||
let old = new Date(now.getTime() - HOUR_MS);
|
||||
let young = new Date(now.getTime() - 1000);
|
||||
reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]);
|
||||
reconciler._changes.push([young, CHANGE_INSTALLED, "bar"]);
|
||||
do_check_eq(2, reconciler._changes.length);
|
||||
|
||||
_("Ensure pruning with an old time won't delete anything.");
|
||||
let threshold = new Date(old.getTime() - 1);
|
||||
reconciler.pruneChangesBeforeDate(threshold);
|
||||
do_check_eq(2, reconciler._changes.length);
|
||||
|
||||
_("Ensure pruning a single item works.");
|
||||
threshold = new Date(young.getTime() - 1000);
|
||||
reconciler.pruneChangesBeforeDate(threshold);
|
||||
do_check_eq(1, reconciler._changes.length);
|
||||
do_check_neq(undefined, reconciler._changes[0]);
|
||||
do_check_eq(young, reconciler._changes[0][0]);
|
||||
do_check_eq("bar", reconciler._changes[0][2]);
|
||||
|
||||
_("Ensure pruning all changes works.");
|
||||
reconciler._changes.push([old, CHANGE_INSTALLED, "foo"]);
|
||||
reconciler.pruneChangesBeforeDate(now);
|
||||
do_check_eq(0, reconciler._changes.length);
|
||||
|
||||
run_next_test();
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue