Merge mozilla-central to autoland. CLOSED TREE
[gecko.git] / toolkit / components / formautofill / FormAutofillSync.sys.mjs
blob15ae9b60b51aafd983ebdd1425f8a9f25c5d8199
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 import {
6   Changeset,
7   Store,
8   SyncEngine,
9   Tracker,
10 } from "resource://services-sync/engines.sys.mjs";
11 import { CryptoWrapper } from "resource://services-sync/record.sys.mjs";
12 import { Utils } from "resource://services-sync/util.sys.mjs";
14 import { SCORE_INCREMENT_XLARGE } from "resource://services-sync/constants.sys.mjs";
16 const lazy = {};
18 ChromeUtils.defineESModuleGetters(lazy, {
19   Log: "resource://gre/modules/Log.sys.mjs",
20   formAutofillStorage: "resource://autofill/FormAutofillStorage.sys.mjs",
21 });
23 // A helper to sanitize address and creditcard records suitable for logging.
24 export function sanitizeStorageObject(ob) {
25   if (!ob) {
26     return null;
27   }
28   const allowList = ["timeCreated", "timeLastUsed", "timeLastModified"];
29   let result = {};
30   for (let key of Object.keys(ob)) {
31     let origVal = ob[key];
32     if (allowList.includes(key)) {
33       result[key] = origVal;
34     } else if (typeof origVal == "string") {
35       result[key] = "X".repeat(origVal.length);
36     } else {
37       result[key] = typeof origVal; // *shrug*
38     }
39   }
40   return result;
43 export function AutofillRecord(collection, id) {
44   CryptoWrapper.call(this, collection, id);
47 AutofillRecord.prototype = {
48   toEntry() {
49     return Object.assign(
50       {
51         guid: this.id,
52       },
53       this.entry
54     );
55   },
57   fromEntry(entry) {
58     this.id = entry.guid;
59     this.entry = entry;
60     // The GUID is already stored in record.id, so we nuke it from the entry
61     // itself to save a tiny bit of space. The formAutofillStorage clones profiles,
62     // so nuking in-place is OK.
63     delete this.entry.guid;
64   },
66   cleartextToString() {
67     // And a helper so logging a *Sync* record auto sanitizes.
68     let record = this.cleartext;
69     return JSON.stringify({ entry: sanitizeStorageObject(record.entry) });
70   },
72 Object.setPrototypeOf(AutofillRecord.prototype, CryptoWrapper.prototype);
74 // Profile data is stored in the "entry" object of the record.
75 Utils.deferGetSet(AutofillRecord, "cleartext", ["entry"]);
77 function FormAutofillStore(name, engine) {
78   Store.call(this, name, engine);
81 FormAutofillStore.prototype = {
82   _subStorageName: null, // overridden below.
83   _storage: null,
85   get storage() {
86     if (!this._storage) {
87       this._storage = lazy.formAutofillStorage[this._subStorageName];
88     }
89     return this._storage;
90   },
92   async getAllIDs() {
93     let result = {};
94     for (let { guid } of await this.storage.getAll({ includeDeleted: true })) {
95       result[guid] = true;
96     }
97     return result;
98   },
100   async changeItemID(oldID, newID) {
101     this.storage.changeGUID(oldID, newID);
102   },
104   // Note: this function intentionally returns false in cases where we only have
105   // a (local) tombstone - and formAutofillStorage.get() filters them for us.
106   async itemExists(id) {
107     return Boolean(await this.storage.get(id));
108   },
110   async applyIncoming(remoteRecord) {
111     if (remoteRecord.deleted) {
112       this._log.trace("Deleting record", remoteRecord);
113       this.storage.remove(remoteRecord.id, { sourceSync: true });
114       return;
115     }
117     // Records from the remote might come from an older device. To ensure that
118     // remote records from older devices can still sync with the local records,
119     // we migrate the remote records. This enables the merging of older records
120     // with newer records.
121     //
122     // Currently, this migration is only used for converting `*-name` fields to `name` fields.
123     // The migration process involves:
124     // 1. Generating a `name` field so we don't assume the `name` field is empty, thereby
125     //    avoiding erasing its value.
126     // 2. Removing deprecated *-name fields from the remote record because the autofill storage
127     //    does not expect to see those fields.
128     this.storage.migrateRemoteRecord(remoteRecord.entry);
130     if (await this.itemExists(remoteRecord.id)) {
131       // We will never get a tombstone here, so we are updating a real record.
132       await this._doUpdateRecord(remoteRecord);
133       return;
134     }
136     // No matching local record. Try to dedupe a NEW local record.
137     let localDupeID = await this.storage.findDuplicateGUID(
138       remoteRecord.toEntry()
139     );
140     if (localDupeID) {
141       this._log.trace(
142         `Deduping local record ${localDupeID} to remote`,
143         remoteRecord
144       );
145       // Change the local GUID to match the incoming record, then apply the
146       // incoming record.
147       await this.changeItemID(localDupeID, remoteRecord.id);
148       await this._doUpdateRecord(remoteRecord);
149       return;
150     }
152     // We didn't find a dupe, either, so must be a new record (or possibly
153     // a non-deleted version of an item we have a tombstone for, which add()
154     // handles for us.)
155     this._log.trace("Add record", remoteRecord);
156     let entry = remoteRecord.toEntry();
157     await this.storage.add(entry, { sourceSync: true });
158   },
160   async createRecord(id, collection) {
161     this._log.trace("Create record", id);
162     let record = new AutofillRecord(collection, id);
163     let entry = await this.storage.get(id, {
164       rawData: true,
165     });
166     if (entry) {
167       record.fromEntry(entry);
168     } else {
169       // We should consider getting a more authortative indication it's actually deleted.
170       this._log.debug(
171         `Failed to get autofill record with id "${id}", assuming deleted`
172       );
173       record.deleted = true;
174     }
175     return record;
176   },
178   async _doUpdateRecord(record) {
179     this._log.trace("Updating record", record);
181     let entry = record.toEntry();
182     let { forkedGUID } = await this.storage.reconcile(entry);
183     if (this._log.level <= lazy.Log.Level.Debug) {
184       let forkedRecord = forkedGUID ? await this.storage.get(forkedGUID) : null;
185       let reconciledRecord = await this.storage.get(record.id);
186       this._log.debug("Updated local record", {
187         forked: sanitizeStorageObject(forkedRecord),
188         updated: sanitizeStorageObject(reconciledRecord),
189       });
190     }
191   },
193   // NOTE: Because we re-implement the incoming/reconcilliation logic we leave
194   // the |create|, |remove| and |update| methods undefined - the base
195   // implementation throws, which is what we want to happen so we can identify
196   // any places they are "accidentally" called.
198 Object.setPrototypeOf(FormAutofillStore.prototype, Store.prototype);
200 function FormAutofillTracker(name, engine) {
201   Tracker.call(this, name, engine);
204 FormAutofillTracker.prototype = {
205   async observe(subject, topic, data) {
206     if (topic != "formautofill-storage-changed") {
207       return;
208     }
209     if (
210       subject &&
211       subject.wrappedJSObject &&
212       subject.wrappedJSObject.sourceSync
213     ) {
214       return;
215     }
216     switch (data) {
217       case "add":
218       case "update":
219       case "remove":
220         this.score += SCORE_INCREMENT_XLARGE;
221         break;
222       default:
223         this._log.debug("unrecognized autofill notification", data);
224         break;
225     }
226   },
228   onStart() {
229     Services.obs.addObserver(this, "formautofill-storage-changed");
230   },
232   onStop() {
233     Services.obs.removeObserver(this, "formautofill-storage-changed");
234   },
236 Object.setPrototypeOf(FormAutofillTracker.prototype, Tracker.prototype);
238 // This uses the same conventions as BookmarkChangeset in
239 // services/sync/modules/engines/bookmarks.js. Specifically,
240 // - "synced" means the item has already been synced (or we have another reason
241 //   to ignore it), and should be ignored in most methods.
242 class AutofillChangeset extends Changeset {
243   constructor() {
244     super();
245   }
247   getModifiedTimestamp(_id) {
248     throw new Error("Don't use timestamps to resolve autofill merge conflicts");
249   }
251   has(id) {
252     let change = this.changes[id];
253     if (change) {
254       return !change.synced;
255     }
256     return false;
257   }
259   delete(id) {
260     let change = this.changes[id];
261     if (change) {
262       // Mark the change as synced without removing it from the set. We do this
263       // so that we can update FormAutofillStorage in `trackRemainingChanges`.
264       change.synced = true;
265     }
266   }
269 function FormAutofillEngine(service, name) {
270   SyncEngine.call(this, name, service);
273 FormAutofillEngine.prototype = {
274   // the priority for this engine is == addons, so will happen after bookmarks
275   // prefs and tabs, but before forms, history, etc.
276   syncPriority: 5,
278   // We don't use SyncEngine.initialize() for this, as we initialize even if
279   // the engine is disabled, and we don't want to be the loader of
280   // FormAutofillStorage in this case.
281   async _syncStartup() {
282     await lazy.formAutofillStorage.initialize();
283     await SyncEngine.prototype._syncStartup.call(this);
284   },
286   // We handle reconciliation in the store, not the engine.
287   async _reconcile() {
288     return true;
289   },
291   emptyChangeset() {
292     return new AutofillChangeset();
293   },
295   async _uploadOutgoing() {
296     this._modified.replace(this._store.storage.pullSyncChanges());
297     await SyncEngine.prototype._uploadOutgoing.call(this);
298   },
300   // Typically, engines populate the changeset before downloading records.
301   // However, we handle conflict resolution in the store, so we can wait
302   // to pull changes until we're ready to upload.
303   async pullAllChanges() {
304     return {};
305   },
307   async pullNewChanges() {
308     return {};
309   },
311   async trackRemainingChanges() {
312     this._store.storage.pushSyncChanges(this._modified.changes);
313   },
315   _deleteId(id) {
316     this._noteDeletedId(id);
317   },
319   async _resetClient() {
320     await lazy.formAutofillStorage.initialize();
321     this._store.storage.resetSync();
322     await this.resetLastSync(0);
323   },
325   async _wipeClient() {
326     await lazy.formAutofillStorage.initialize();
327     this._store.storage.removeAll({ sourceSync: true });
328   },
330 Object.setPrototypeOf(FormAutofillEngine.prototype, SyncEngine.prototype);
332 // The concrete engines
334 function AddressesRecord(collection, id) {
335   AutofillRecord.call(this, collection, id);
338 AddressesRecord.prototype = {
339   _logName: "Sync.Record.Addresses",
341 Object.setPrototypeOf(AddressesRecord.prototype, AutofillRecord.prototype);
343 function AddressesStore(name, engine) {
344   FormAutofillStore.call(this, name, engine);
347 AddressesStore.prototype = {
348   _subStorageName: "addresses",
350 Object.setPrototypeOf(AddressesStore.prototype, FormAutofillStore.prototype);
352 export function AddressesEngine(service) {
353   FormAutofillEngine.call(this, service, "Addresses");
356 AddressesEngine.prototype = {
357   _trackerObj: FormAutofillTracker,
358   _storeObj: AddressesStore,
359   _recordObj: AddressesRecord,
361   get prefName() {
362     return "addresses";
363   },
365 Object.setPrototypeOf(AddressesEngine.prototype, FormAutofillEngine.prototype);
367 function CreditCardsRecord(collection, id) {
368   AutofillRecord.call(this, collection, id);
371 CreditCardsRecord.prototype = {
372   _logName: "Sync.Record.CreditCards",
374 Object.setPrototypeOf(CreditCardsRecord.prototype, AutofillRecord.prototype);
376 function CreditCardsStore(name, engine) {
377   FormAutofillStore.call(this, name, engine);
380 CreditCardsStore.prototype = {
381   _subStorageName: "creditCards",
383 Object.setPrototypeOf(CreditCardsStore.prototype, FormAutofillStore.prototype);
385 export function CreditCardsEngine(service) {
386   FormAutofillEngine.call(this, service, "CreditCards");
389 CreditCardsEngine.prototype = {
390   _trackerObj: FormAutofillTracker,
391   _storeObj: CreditCardsStore,
392   _recordObj: CreditCardsRecord,
393   get prefName() {
394     return "creditcards";
395   },
397 Object.setPrototypeOf(
398   CreditCardsEngine.prototype,
399   FormAutofillEngine.prototype