services/sync/modules/engines/addons.js

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

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 };

mercurial