Thu, 22 Jan 2015 13:21:57 +0100
Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /*
6 * This file defines the add-on sync functionality.
7 *
8 * There are currently a number of known limitations:
9 * - We only sync XPI extensions and themes available from addons.mozilla.org.
10 * We hope to expand support for other add-ons eventually.
11 * - We only attempt syncing of add-ons between applications of the same type.
12 * This means add-ons will not synchronize between Firefox desktop and
13 * Firefox mobile, for example. This is because of significant add-on
14 * incompatibility between application types.
15 *
16 * Add-on records exist for each known {add-on, app-id} pair in the Sync client
17 * set. Each record has a randomly chosen GUID. The records then contain
18 * basic metadata about the add-on.
19 *
20 * We currently synchronize:
21 *
22 * - Installations
23 * - Uninstallations
24 * - User enabling and disabling
25 *
26 * Synchronization is influenced by the following preferences:
27 *
28 * - services.sync.addons.ignoreRepositoryChecking
29 * - services.sync.addons.ignoreUserEnabledChanges
30 * - services.sync.addons.trustedSourceHostnames
31 *
32 * See the documentation in services-sync.js for the behavior of these prefs.
33 */
34 "use strict";
36 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
38 Cu.import("resource://services-sync/addonutils.js");
39 Cu.import("resource://services-sync/addonsreconciler.js");
40 Cu.import("resource://services-sync/engines.js");
41 Cu.import("resource://services-sync/record.js");
42 Cu.import("resource://services-sync/util.js");
43 Cu.import("resource://services-sync/constants.js");
44 Cu.import("resource://services-common/async.js");
46 Cu.import("resource://gre/modules/Preferences.jsm");
47 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
48 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
49 "resource://gre/modules/AddonManager.jsm");
50 XPCOMUtils.defineLazyModuleGetter(this, "AddonRepository",
51 "resource://gre/modules/addons/AddonRepository.jsm");
53 this.EXPORTED_SYMBOLS = ["AddonsEngine"];
55 // 7 days in milliseconds.
56 const PRUNE_ADDON_CHANGES_THRESHOLD = 60 * 60 * 24 * 7 * 1000;
58 /**
59 * AddonRecord represents the state of an add-on in an application.
60 *
61 * Each add-on has its own record for each application ID it is installed
62 * on.
63 *
64 * The ID of add-on records is a randomly-generated GUID. It is random instead
65 * of deterministic so the URIs of the records cannot be guessed and so
66 * compromised server credentials won't result in disclosure of the specific
67 * add-ons present in a Sync account.
68 *
69 * The record contains the following fields:
70 *
71 * addonID
72 * ID of the add-on. This correlates to the "id" property on an Addon type.
73 *
74 * applicationID
75 * The application ID this record is associated with.
76 *
77 * enabled
78 * Boolean stating whether add-on is enabled or disabled by the user.
79 *
80 * source
81 * String indicating where an add-on is from. Currently, we only support
82 * the value "amo" which indicates that the add-on came from the official
83 * add-ons repository, addons.mozilla.org. In the future, we may support
84 * installing add-ons from other sources. This provides a future-compatible
85 * mechanism for clients to only apply records they know how to handle.
86 */
87 function AddonRecord(collection, id) {
88 CryptoWrapper.call(this, collection, id);
89 }
90 AddonRecord.prototype = {
91 __proto__: CryptoWrapper.prototype,
92 _logName: "Record.Addon"
93 };
95 Utils.deferGetSet(AddonRecord, "cleartext", ["addonID",
96 "applicationID",
97 "enabled",
98 "source"]);
100 /**
101 * The AddonsEngine handles synchronization of add-ons between clients.
102 *
103 * The engine maintains an instance of an AddonsReconciler, which is the entity
104 * maintaining state for add-ons. It provides the history and tracking APIs
105 * that AddonManager doesn't.
106 *
107 * The engine instance overrides a handful of functions on the base class. The
108 * rationale for each is documented by that function.
109 */
110 this.AddonsEngine = function AddonsEngine(service) {
111 SyncEngine.call(this, "Addons", service);
113 this._reconciler = new AddonsReconciler();
114 }
115 AddonsEngine.prototype = {
116 __proto__: SyncEngine.prototype,
117 _storeObj: AddonsStore,
118 _trackerObj: AddonsTracker,
119 _recordObj: AddonRecord,
120 version: 1,
122 _reconciler: null,
124 /**
125 * Override parent method to find add-ons by their public ID, not Sync GUID.
126 */
127 _findDupe: function _findDupe(item) {
128 let id = item.addonID;
130 // The reconciler should have been updated at the top of the sync, so we
131 // can assume it is up to date when this function is called.
132 let addons = this._reconciler.addons;
133 if (!(id in addons)) {
134 return null;
135 }
137 let addon = addons[id];
138 if (addon.guid != item.id) {
139 return addon.guid;
140 }
142 return null;
143 },
145 /**
146 * Override getChangedIDs to pull in tracker changes plus changes from the
147 * reconciler log.
148 */
149 getChangedIDs: function getChangedIDs() {
150 let changes = {};
151 for (let [id, modified] in Iterator(this._tracker.changedIDs)) {
152 changes[id] = modified;
153 }
155 let lastSyncDate = new Date(this.lastSync * 1000);
157 // The reconciler should have been refreshed at the beginning of a sync and
158 // we assume this function is only called from within a sync.
159 let reconcilerChanges = this._reconciler.getChangesSinceDate(lastSyncDate);
160 let addons = this._reconciler.addons;
161 for each (let change in reconcilerChanges) {
162 let changeTime = change[0];
163 let id = change[2];
165 if (!(id in addons)) {
166 continue;
167 }
169 // Keep newest modified time.
170 if (id in changes && changeTime < changes[id]) {
171 continue;
172 }
174 if (!this._store.isAddonSyncable(addons[id])) {
175 continue;
176 }
178 this._log.debug("Adding changed add-on from changes log: " + id);
179 let addon = addons[id];
180 changes[addon.guid] = changeTime.getTime() / 1000;
181 }
183 return changes;
184 },
186 /**
187 * Override start of sync function to refresh reconciler.
188 *
189 * Many functions in this class assume the reconciler is refreshed at the
190 * top of a sync. If this ever changes, those functions should be revisited.
191 *
192 * Technically speaking, we don't need to refresh the reconciler on every
193 * sync since it is installed as an AddonManager listener. However, add-ons
194 * are complicated and we force a full refresh, just in case the listeners
195 * missed something.
196 */
197 _syncStartup: function _syncStartup() {
198 // We refresh state before calling parent because syncStartup in the parent
199 // looks for changed IDs, which is dependent on add-on state being up to
200 // date.
201 this._refreshReconcilerState();
203 SyncEngine.prototype._syncStartup.call(this);
204 },
206 /**
207 * Override end of sync to perform a little housekeeping on the reconciler.
208 *
209 * We prune changes to prevent the reconciler state from growing without
210 * bound. Even if it grows unbounded, there would have to be many add-on
211 * changes (thousands) for it to slow things down significantly. This is
212 * highly unlikely to occur. Still, we exercise defense just in case.
213 */
214 _syncCleanup: function _syncCleanup() {
215 let ms = 1000 * this.lastSync - PRUNE_ADDON_CHANGES_THRESHOLD;
216 this._reconciler.pruneChangesBeforeDate(new Date(ms));
218 SyncEngine.prototype._syncCleanup.call(this);
219 },
221 /**
222 * Helper function to ensure reconciler is up to date.
223 *
224 * This will synchronously load the reconciler's state from the file
225 * system (if needed) and refresh the state of the reconciler.
226 */
227 _refreshReconcilerState: function _refreshReconcilerState() {
228 this._log.debug("Refreshing reconciler state");
229 let cb = Async.makeSpinningCallback();
230 this._reconciler.refreshGlobalState(cb);
231 cb.wait();
232 }
233 };
235 /**
236 * This is the primary interface between Sync and the Addons Manager.
237 *
238 * In addition to the core store APIs, we provide convenience functions to wrap
239 * Add-on Manager APIs with Sync-specific semantics.
240 */
241 function AddonsStore(name, engine) {
242 Store.call(this, name, engine);
243 }
244 AddonsStore.prototype = {
245 __proto__: Store.prototype,
247 // Define the add-on types (.type) that we support.
248 _syncableTypes: ["extension", "theme"],
250 _extensionsPrefs: new Preferences("extensions."),
252 get reconciler() {
253 return this.engine._reconciler;
254 },
256 /**
257 * Override applyIncoming to filter out records we can't handle.
258 */
259 applyIncoming: function applyIncoming(record) {
260 // The fields we look at aren't present when the record is deleted.
261 if (!record.deleted) {
262 // Ignore records not belonging to our application ID because that is the
263 // current policy.
264 if (record.applicationID != Services.appinfo.ID) {
265 this._log.info("Ignoring incoming record from other App ID: " +
266 record.id);
267 return;
268 }
270 // Ignore records that aren't from the official add-on repository, as that
271 // is our current policy.
272 if (record.source != "amo") {
273 this._log.info("Ignoring unknown add-on source (" + record.source + ")" +
274 " for " + record.id);
275 return;
276 }
277 }
279 Store.prototype.applyIncoming.call(this, record);
280 },
283 /**
284 * Provides core Store API to create/install an add-on from a record.
285 */
286 create: function create(record) {
287 let cb = Async.makeSpinningCallback();
288 AddonUtils.installAddons([{
289 id: record.addonID,
290 syncGUID: record.id,
291 enabled: record.enabled,
292 requireSecureURI: !Svc.Prefs.get("addons.ignoreRepositoryChecking", false),
293 }], cb);
295 // This will throw if there was an error. This will get caught by the sync
296 // engine and the record will try to be applied later.
297 let results = cb.wait();
299 let addon;
300 for each (let a in results.addons) {
301 if (a.id == record.addonID) {
302 addon = a;
303 break;
304 }
305 }
307 // This should never happen, but is present as a fail-safe.
308 if (!addon) {
309 throw new Error("Add-on not found after install: " + record.addonID);
310 }
312 this._log.info("Add-on installed: " + record.addonID);
313 },
315 /**
316 * Provides core Store API to remove/uninstall an add-on from a record.
317 */
318 remove: function remove(record) {
319 // If this is called, the payload is empty, so we have to find by GUID.
320 let addon = this.getAddonByGUID(record.id);
321 if (!addon) {
322 // We don't throw because if the add-on could not be found then we assume
323 // it has already been uninstalled and there is nothing for this function
324 // to do.
325 return;
326 }
328 this._log.info("Uninstalling add-on: " + addon.id);
329 let cb = Async.makeSpinningCallback();
330 AddonUtils.uninstallAddon(addon, cb);
331 cb.wait();
332 },
334 /**
335 * Provides core Store API to update an add-on from a record.
336 */
337 update: function update(record) {
338 let addon = this.getAddonByID(record.addonID);
340 // update() is called if !this.itemExists. And, since itemExists consults
341 // the reconciler only, we need to take care of some corner cases.
342 //
343 // First, the reconciler could know about an add-on that was uninstalled
344 // and no longer present in the add-ons manager.
345 if (!addon) {
346 this.create(record);
347 return;
348 }
350 // It's also possible that the add-on is non-restartless and has pending
351 // install/uninstall activity.
352 //
353 // We wouldn't get here if the incoming record was for a deletion. So,
354 // check for pending uninstall and cancel if necessary.
355 if (addon.pendingOperations & AddonManager.PENDING_UNINSTALL) {
356 addon.cancelUninstall();
358 // We continue with processing because there could be state or ID change.
359 }
361 let cb = Async.makeSpinningCallback();
362 this.updateUserDisabled(addon, !record.enabled, cb);
363 cb.wait();
364 },
366 /**
367 * Provide core Store API to determine if a record exists.
368 */
369 itemExists: function itemExists(guid) {
370 let addon = this.reconciler.getAddonStateFromSyncGUID(guid);
372 return !!addon;
373 },
375 /**
376 * Create an add-on record from its GUID.
377 *
378 * @param guid
379 * Add-on GUID (from extensions DB)
380 * @param collection
381 * Collection to add record to.
382 *
383 * @return AddonRecord instance
384 */
385 createRecord: function createRecord(guid, collection) {
386 let record = new AddonRecord(collection, guid);
387 record.applicationID = Services.appinfo.ID;
389 let addon = this.reconciler.getAddonStateFromSyncGUID(guid);
391 // If we don't know about this GUID or if it has been uninstalled, we mark
392 // the record as deleted.
393 if (!addon || !addon.installed) {
394 record.deleted = true;
395 return record;
396 }
398 record.modified = addon.modified.getTime() / 1000;
400 record.addonID = addon.id;
401 record.enabled = addon.enabled;
403 // This needs to be dynamic when add-ons don't come from AddonRepository.
404 record.source = "amo";
406 return record;
407 },
409 /**
410 * Changes the id of an add-on.
411 *
412 * This implements a core API of the store.
413 */
414 changeItemID: function changeItemID(oldID, newID) {
415 // We always update the GUID in the reconciler because it will be
416 // referenced later in the sync process.
417 let state = this.reconciler.getAddonStateFromSyncGUID(oldID);
418 if (state) {
419 state.guid = newID;
420 let cb = Async.makeSpinningCallback();
421 this.reconciler.saveState(null, cb);
422 cb.wait();
423 }
425 let addon = this.getAddonByGUID(oldID);
426 if (!addon) {
427 this._log.debug("Cannot change item ID (" + oldID + ") in Add-on " +
428 "Manager because old add-on not present: " + oldID);
429 return;
430 }
432 addon.syncGUID = newID;
433 },
435 /**
436 * Obtain the set of all syncable add-on Sync GUIDs.
437 *
438 * This implements a core Store API.
439 */
440 getAllIDs: function getAllIDs() {
441 let ids = {};
443 let addons = this.reconciler.addons;
444 for each (let addon in addons) {
445 if (this.isAddonSyncable(addon)) {
446 ids[addon.guid] = true;
447 }
448 }
450 return ids;
451 },
453 /**
454 * Wipe engine data.
455 *
456 * This uninstalls all syncable addons from the application. In case of
457 * error, it logs the error and keeps trying with other add-ons.
458 */
459 wipe: function wipe() {
460 this._log.info("Processing wipe.");
462 this.engine._refreshReconcilerState();
464 // We only wipe syncable add-ons. Wipe is a Sync feature not a security
465 // feature.
466 for (let guid in this.getAllIDs()) {
467 let addon = this.getAddonByGUID(guid);
468 if (!addon) {
469 this._log.debug("Ignoring add-on because it couldn't be obtained: " +
470 guid);
471 continue;
472 }
474 this._log.info("Uninstalling add-on as part of wipe: " + addon.id);
475 Utils.catch(addon.uninstall)();
476 }
477 },
479 /***************************************************************************
480 * Functions below are unique to this store and not part of the Store API *
481 ***************************************************************************/
483 /**
484 * Synchronously obtain an add-on from its public ID.
485 *
486 * @param id
487 * Add-on ID
488 * @return Addon or undefined if not found
489 */
490 getAddonByID: function getAddonByID(id) {
491 let cb = Async.makeSyncCallback();
492 AddonManager.getAddonByID(id, cb);
493 return Async.waitForSyncCallback(cb);
494 },
496 /**
497 * Synchronously obtain an add-on from its Sync GUID.
498 *
499 * @param guid
500 * Add-on Sync GUID
501 * @return DBAddonInternal or null
502 */
503 getAddonByGUID: function getAddonByGUID(guid) {
504 let cb = Async.makeSyncCallback();
505 AddonManager.getAddonBySyncGUID(guid, cb);
506 return Async.waitForSyncCallback(cb);
507 },
509 /**
510 * Determines whether an add-on is suitable for Sync.
511 *
512 * @param addon
513 * Addon instance
514 * @return Boolean indicating whether it is appropriate for Sync
515 */
516 isAddonSyncable: function isAddonSyncable(addon) {
517 // Currently, we limit syncable add-ons to those that are:
518 // 1) In a well-defined set of types
519 // 2) Installed in the current profile
520 // 3) Not installed by a foreign entity (i.e. installed by the app)
521 // since they act like global extensions.
522 // 4) Is not a hotfix.
523 // 5) Are installed from AMO
525 // We could represent the test as a complex boolean expression. We go the
526 // verbose route so the failure reason is logged.
527 if (!addon) {
528 this._log.debug("Null object passed to isAddonSyncable.");
529 return false;
530 }
532 if (this._syncableTypes.indexOf(addon.type) == -1) {
533 this._log.debug(addon.id + " not syncable: type not in whitelist: " +
534 addon.type);
535 return false;
536 }
538 if (!(addon.scope & AddonManager.SCOPE_PROFILE)) {
539 this._log.debug(addon.id + " not syncable: not installed in profile.");
540 return false;
541 }
543 // This may be too aggressive. If an add-on is downloaded from AMO and
544 // manually placed in the profile directory, foreignInstall will be set.
545 // Arguably, that add-on should be syncable.
546 // TODO Address the edge case and come up with more robust heuristics.
547 if (addon.foreignInstall) {
548 this._log.debug(addon.id + " not syncable: is foreign install.");
549 return false;
550 }
552 // Ignore hotfix extensions (bug 741670). The pref may not be defined.
553 if (this._extensionsPrefs.get("hotfix.id", null) == addon.id) {
554 this._log.debug(addon.id + " not syncable: is a hotfix.");
555 return false;
556 }
558 // We provide a back door to skip the repository checking of an add-on.
559 // This is utilized by the tests to make testing easier. Users could enable
560 // this, but it would sacrifice security.
561 if (Svc.Prefs.get("addons.ignoreRepositoryChecking", false)) {
562 return true;
563 }
565 let cb = Async.makeSyncCallback();
566 AddonRepository.getCachedAddonByID(addon.id, cb);
567 let result = Async.waitForSyncCallback(cb);
569 if (!result) {
570 this._log.debug(addon.id + " not syncable: add-on not found in add-on " +
571 "repository.");
572 return false;
573 }
575 return this.isSourceURITrusted(result.sourceURI);
576 },
578 /**
579 * Determine whether an add-on's sourceURI field is trusted and the add-on
580 * can be installed.
581 *
582 * This function should only ever be called from isAddonSyncable(). It is
583 * exposed as a separate function to make testing easier.
584 *
585 * @param uri
586 * nsIURI instance to validate
587 * @return bool
588 */
589 isSourceURITrusted: function isSourceURITrusted(uri) {
590 // For security reasons, we currently limit synced add-ons to those
591 // installed from trusted hostname(s). We additionally require TLS with
592 // the add-ons site to help prevent forgeries.
593 let trustedHostnames = Svc.Prefs.get("addons.trustedSourceHostnames", "")
594 .split(",");
596 if (!uri) {
597 this._log.debug("Undefined argument to isSourceURITrusted().");
598 return false;
599 }
601 // Scheme is validated before the hostname because uri.host may not be
602 // populated for certain schemes. It appears to always be populated for
603 // https, so we avoid the potential NS_ERROR_FAILURE on field access.
604 if (uri.scheme != "https") {
605 this._log.debug("Source URI not HTTPS: " + uri.spec);
606 return false;
607 }
609 if (trustedHostnames.indexOf(uri.host) == -1) {
610 this._log.debug("Source hostname not trusted: " + uri.host);
611 return false;
612 }
614 return true;
615 },
617 /**
618 * Update the userDisabled flag on an add-on.
619 *
620 * This will enable or disable an add-on and call the supplied callback when
621 * the action is complete. If no action is needed, the callback gets called
622 * immediately.
623 *
624 * @param addon
625 * Addon instance to manipulate.
626 * @param value
627 * Boolean to which to set userDisabled on the passed Addon.
628 * @param callback
629 * Function to be called when action is complete. Will receive 2
630 * arguments, a truthy value that signifies error, and the Addon
631 * instance passed to this function.
632 */
633 updateUserDisabled: function updateUserDisabled(addon, value, callback) {
634 if (addon.userDisabled == value) {
635 callback(null, addon);
636 return;
637 }
639 // A pref allows changes to the enabled flag to be ignored.
640 if (Svc.Prefs.get("addons.ignoreUserEnabledChanges", false)) {
641 this._log.info("Ignoring enabled state change due to preference: " +
642 addon.id);
643 callback(null, addon);
644 return;
645 }
647 AddonUtils.updateUserDisabled(addon, value, callback);
648 },
649 };
651 /**
652 * The add-ons tracker keeps track of real-time changes to add-ons.
653 *
654 * It hooks up to the reconciler and receives notifications directly from it.
655 */
656 function AddonsTracker(name, engine) {
657 Tracker.call(this, name, engine);
658 }
659 AddonsTracker.prototype = {
660 __proto__: Tracker.prototype,
662 get reconciler() {
663 return this.engine._reconciler;
664 },
666 get store() {
667 return this.engine._store;
668 },
670 /**
671 * This callback is executed whenever the AddonsReconciler sends out a change
672 * notification. See AddonsReconciler.addChangeListener().
673 */
674 changeListener: function changeHandler(date, change, addon) {
675 this._log.debug("changeListener invoked: " + change + " " + addon.id);
676 // Ignore changes that occur during sync.
677 if (this.ignoreAll) {
678 return;
679 }
681 if (!this.store.isAddonSyncable(addon)) {
682 this._log.debug("Ignoring change because add-on isn't syncable: " +
683 addon.id);
684 return;
685 }
687 this.addChangedID(addon.guid, date.getTime() / 1000);
688 this.score += SCORE_INCREMENT_XLARGE;
689 },
691 startTracking: function() {
692 if (this.engine.enabled) {
693 this.reconciler.startListening();
694 }
696 this.reconciler.addChangeListener(this);
697 },
699 stopTracking: function() {
700 this.reconciler.removeChangeListener(this);
701 this.reconciler.stopListening();
702 },
703 };