summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/FormAutofillSync.jsm
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--toolkit/components/formautofill/FormAutofillSync.jsm402
1 files changed, 402 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillSync.jsm b/toolkit/components/formautofill/FormAutofillSync.jsm
new file mode 100644
index 0000000000..4f8e405a9a
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillSync.jsm
@@ -0,0 +1,402 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = [
+ "AddressesEngine",
+ "CreditCardsEngine",
+ // The items below are exported for test purposes.
+ "sanitizeStorageObject",
+ "AutofillRecord",
+];
+
+const { Changeset, Store, SyncEngine, Tracker } = ChromeUtils.import(
+ "resource://services-sync/engines.js"
+);
+const { CryptoWrapper } = ChromeUtils.import(
+ "resource://services-sync/record.js"
+);
+const { Utils } = ChromeUtils.import("resource://services-sync/util.js");
+const { SCORE_INCREMENT_XLARGE } = ChromeUtils.import(
+ "resource://services-sync/constants.js"
+);
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ Log: "resource://gre/modules/Log.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ formAutofillStorage: "resource://autofill/FormAutofillStorage.jsm",
+});
+
+// A helper to sanitize address and creditcard records suitable for logging.
+function sanitizeStorageObject(ob) {
+ if (!ob) {
+ return null;
+ }
+ const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"];
+ let result = {};
+ for (let key of Object.keys(ob)) {
+ let origVal = ob[key];
+ if (allowList.includes(key)) {
+ result[key] = origVal;
+ } else if (typeof origVal == "string") {
+ result[key] = "X".repeat(origVal.length);
+ } else {
+ result[key] = typeof origVal; // *shrug*
+ }
+ }
+ return result;
+}
+
+function AutofillRecord(collection, id) {
+ CryptoWrapper.call(this, collection, id);
+}
+
+AutofillRecord.prototype = {
+ toEntry() {
+ return Object.assign(
+ {
+ guid: this.id,
+ },
+ this.entry
+ );
+ },
+
+ fromEntry(entry) {
+ this.id = entry.guid;
+ this.entry = entry;
+ // The GUID is already stored in record.id, so we nuke it from the entry
+ // itself to save a tiny bit of space. The formAutofillStorage clones profiles,
+ // so nuking in-place is OK.
+ delete this.entry.guid;
+ },
+
+ cleartextToString() {
+ // And a helper so logging a *Sync* record auto sanitizes.
+ let record = this.cleartext;
+ return JSON.stringify({ entry: sanitizeStorageObject(record.entry) });
+ },
+};
+Object.setPrototypeOf(AutofillRecord.prototype, CryptoWrapper.prototype);
+
+// Profile data is stored in the "entry" object of the record.
+Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]);
+
+function FormAutofillStore(name, engine) {
+ Store.call(this, name, engine);
+}
+
+FormAutofillStore.prototype = {
+ _subStorageName: null, // overridden below.
+ _storage: null,
+
+ get storage() {
+ if (!this._storage) {
+ this._storage = lazy.formAutofillStorage[this._subStorageName];
+ }
+ return this._storage;
+ },
+
+ async getAllIDs() {
+ let result = {};
+ for (let { guid } of await this.storage.getAll({ includeDeleted: true })) {
+ result[guid] = true;
+ }
+ return result;
+ },
+
+ async changeItemID(oldID, newID) {
+ this.storage.changeGUID(oldID, newID);
+ },
+
+ // Note: this function intentionally returns false in cases where we only have
+ // a (local) tombstone - and formAutofillStorage.get() filters them for us.
+ async itemExists(id) {
+ return Boolean(await this.storage.get(id));
+ },
+
+ async applyIncoming(remoteRecord) {
+ if (remoteRecord.deleted) {
+ this._log.trace("Deleting record", remoteRecord);
+ this.storage.remove(remoteRecord.id, { sourceSync: true });
+ return;
+ }
+
+ if (await this.itemExists(remoteRecord.id)) {
+ // We will never get a tombstone here, so we are updating a real record.
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // No matching local record. Try to dedupe a NEW local record.
+ let localDupeID = await this.storage.findDuplicateGUID(
+ remoteRecord.toEntry()
+ );
+ if (localDupeID) {
+ this._log.trace(
+ `Deduping local record ${localDupeID} to remote`,
+ remoteRecord
+ );
+ // Change the local GUID to match the incoming record, then apply the
+ // incoming record.
+ await this.changeItemID(localDupeID, remoteRecord.id);
+ await this._doUpdateRecord(remoteRecord);
+ return;
+ }
+
+ // We didn't find a dupe, either, so must be a new record (or possibly
+ // a non-deleted version of an item we have a tombstone for, which add()
+ // handles for us.)
+ this._log.trace("Add record", remoteRecord);
+ let entry = remoteRecord.toEntry();
+ await this.storage.add(entry, { sourceSync: true });
+ },
+
+ async createRecord(id, collection) {
+ this._log.trace("Create record", id);
+ let record = new AutofillRecord(collection, id);
+ let entry = await this.storage.get(id, {
+ rawData: true,
+ });
+ if (entry) {
+ record.fromEntry(entry);
+ } else {
+ // We should consider getting a more authortative indication it's actually deleted.
+ this._log.debug(
+ `Failed to get autofill record with id "${id}", assuming deleted`
+ );
+ record.deleted = true;
+ }
+ return record;
+ },
+
+ async _doUpdateRecord(record) {
+ this._log.trace("Updating record", record);
+
+ let entry = record.toEntry();
+ let { forkedGUID } = await this.storage.reconcile(entry);
+ if (this._log.level <= lazy.Log.Level.Debug) {
+ let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null;
+ let reconciledRecord = await this.storage.get(record.id);
+ this._log.debug("Updated local record", {
+ forked: sanitizeStorageObject(forkedRecord),
+ updated: sanitizeStorageObject(reconciledRecord),
+ });
+ }
+ },
+
+ // NOTE: Because we re-implement the incoming/reconcilliation logic we leave
+ // the |create|, |remove| and |update| methods undefined - the base
+ // implementation throws, which is what we want to happen so we can identify
+ // any places they are "accidentally" called.
+};
+Object.setPrototypeOf(FormAutofillStore.prototype, Store.prototype);
+
+function FormAutofillTracker(name, engine) {
+ Tracker.call(this, name, engine);
+}
+
+FormAutofillTracker.prototype = {
+ async observe(subject, topic, data) {
+ if (topic != "formautofill-storage-changed") {
+ return;
+ }
+ if (
+ subject &&
+ subject.wrappedJSObject &&
+ subject.wrappedJSObject.sourceSync
+ ) {
+ return;
+ }
+ switch (data) {
+ case "add":
+ case "update":
+ case "remove":
+ this.score += SCORE_INCREMENT_XLARGE;
+ break;
+ default:
+ this._log.debug("unrecognized autofill notification", data);
+ break;
+ }
+ },
+
+ onStart() {
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ },
+
+ onStop() {
+ Services.obs.removeObserver(this, "formautofill-storage-changed");
+ },
+};
+Object.setPrototypeOf(FormAutofillTracker.prototype, Tracker.prototype);
+
+// This uses the same conventions as BookmarkChangeset in
+// services/sync/modules/engines/bookmarks.js. Specifically,
+// - "synced" means the item has already been synced (or we have another reason
+// to ignore it), and should be ignored in most methods.
+class AutofillChangeset extends Changeset {
+ constructor() {
+ super();
+ }
+
+ getModifiedTimestamp(id) {
+ throw new Error("Don't use timestamps to resolve autofill merge conflicts");
+ }
+
+ has(id) {
+ let change = this.changes[id];
+ if (change) {
+ return !change.synced;
+ }
+ return false;
+ }
+
+ delete(id) {
+ let change = this.changes[id];
+ if (change) {
+ // Mark the change as synced without removing it from the set. We do this
+ // so that we can update FormAutofillStorage in `trackRemainingChanges`.
+ change.synced = true;
+ }
+ }
+}
+
+function FormAutofillEngine(service, name) {
+ SyncEngine.call(this, name, service);
+}
+
+FormAutofillEngine.prototype = {
+ // the priority for this engine is == addons, so will happen after bookmarks
+ // prefs and tabs, but before forms, history, etc.
+ syncPriority: 5,
+
+ // We don't use SyncEngine.initialize() for this, as we initialize even if
+ // the engine is disabled, and we don't want to be the loader of
+ // FormAutofillStorage in this case.
+ async _syncStartup() {
+ await lazy.formAutofillStorage.initialize();
+ await SyncEngine.prototype._syncStartup.call(this);
+ },
+
+ // We handle reconciliation in the store, not the engine.
+ async _reconcile() {
+ return true;
+ },
+
+ emptyChangeset() {
+ return new AutofillChangeset();
+ },
+
+ async _uploadOutgoing() {
+ this._modified.replace(this._store.storage.pullSyncChanges());
+ await SyncEngine.prototype._uploadOutgoing.call(this);
+ },
+
+ // Typically, engines populate the changeset before downloading records.
+ // However, we handle conflict resolution in the store, so we can wait
+ // to pull changes until we're ready to upload.
+ async pullAllChanges() {
+ return {};
+ },
+
+ async pullNewChanges() {
+ return {};
+ },
+
+ async trackRemainingChanges() {
+ this._store.storage.pushSyncChanges(this._modified.changes);
+ },
+
+ _deleteId(id) {
+ this._noteDeletedId(id);
+ },
+
+ async _resetClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.resetSync();
+ },
+
+ async _wipeClient() {
+ await lazy.formAutofillStorage.initialize();
+ this._store.storage.removeAll({ sourceSync: true });
+ },
+};
+Object.setPrototypeOf(FormAutofillEngine.prototype, SyncEngine.prototype);
+
+// The concrete engines
+
+function AddressesRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+AddressesRecord.prototype = {
+ _logName: "Sync.Record.Addresses",
+};
+Object.setPrototypeOf(AddressesRecord.prototype, AutofillRecord.prototype);
+
+function AddressesStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+AddressesStore.prototype = {
+ _subStorageName: "addresses",
+};
+Object.setPrototypeOf(AddressesStore.prototype, FormAutofillStore.prototype);
+
+function AddressesEngine(service) {
+ FormAutofillEngine.call(this, service, "Addresses");
+}
+
+AddressesEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: AddressesStore,
+ _recordObj: AddressesRecord,
+
+ get prefName() {
+ return "addresses";
+ },
+};
+Object.setPrototypeOf(AddressesEngine.prototype, FormAutofillEngine.prototype);
+
+function CreditCardsRecord(collection, id) {
+ AutofillRecord.call(this, collection, id);
+}
+
+CreditCardsRecord.prototype = {
+ _logName: "Sync.Record.CreditCards",
+};
+Object.setPrototypeOf(CreditCardsRecord.prototype, AutofillRecord.prototype);
+
+function CreditCardsStore(name, engine) {
+ FormAutofillStore.call(this, name, engine);
+}
+
+CreditCardsStore.prototype = {
+ _subStorageName: "creditCards",
+};
+Object.setPrototypeOf(CreditCardsStore.prototype, FormAutofillStore.prototype);
+
+function CreditCardsEngine(service) {
+ FormAutofillEngine.call(this, service, "CreditCards");
+}
+
+CreditCardsEngine.prototype = {
+ _trackerObj: FormAutofillTracker,
+ _storeObj: CreditCardsStore,
+ _recordObj: CreditCardsRecord,
+ get prefName() {
+ return "creditcards";
+ },
+};
+Object.setPrototypeOf(
+ CreditCardsEngine.prototype,
+ FormAutofillEngine.prototype
+);