summaryrefslogtreecommitdiffstats
path: root/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 01:47:29 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 01:47:29 +0000
commit0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d (patch)
treea31f07c9bcca9d56ce61e9a1ffd30ef350d513aa /toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
parentInitial commit. (diff)
downloadfirefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.tar.xz
firefox-esr-0ebf5bdf043a27fd3dfb7f92e0cb63d88954c44d.zip
Adding upstream version 115.8.0esr.upstream/115.8.0esr
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs')
-rw-r--r--toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs2219
1 files changed, 2219 insertions, 0 deletions
diff --git a/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
new file mode 100644
index 0000000000..186b53d78b
--- /dev/null
+++ b/toolkit/components/formautofill/FormAutofillStorageBase.sys.mjs
@@ -0,0 +1,2219 @@
+/* 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/. */
+
+/*
+ * Interface for the storage of Form Autofill.
+ *
+ * The data is stored in JSON format, without indentation and the computed
+ * fields, using UTF-8 encoding. With indentation and computed fields applied,
+ * the schema would look like this:
+ *
+ * {
+ * version: 1,
+ * addresses: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // address fields
+ * given-name,
+ * additional-name,
+ * family-name,
+ * organization, // Company
+ * street-address, // (Multiline)
+ * address-level3, // Suburb/Sublocality
+ * address-level2, // City/Town
+ * address-level1, // Province (Standardized code if possible)
+ * postal-code,
+ * country, // ISO 3166
+ * tel, // Stored in E.164 format
+ * email,
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * name,
+ * address-line1,
+ * address-line2,
+ * address-line3,
+ * country-name,
+ * tel-country-code,
+ * tel-national,
+ * tel-area-code,
+ * tel-local,
+ * tel-local-prefix,
+ * tel-local-suffix,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ],
+ * creditCards: [
+ * {
+ * guid, // 12 characters
+ * version, // schema version in integer
+ *
+ * // credit card fields
+ * billingAddressGUID, // An optional GUID of an autofill address record
+ * which may or may not exist locally.
+ *
+ * cc-name,
+ * cc-number, // will be stored in masked format (************1234)
+ * // (see details below)
+ * cc-exp-month,
+ * cc-exp-year, // 2-digit year will be converted to 4 digits
+ * // upon saving
+ * cc-type, // Optional card network id (instrument type)
+ *
+ * // computed fields (These fields are computed based on the above fields
+ * // and are not allowed to be modified directly.)
+ * cc-given-name,
+ * cc-additional-name,
+ * cc-family-name,
+ * cc-number-encrypted, // encrypted from the original unmasked "cc-number"
+ * // (see details below)
+ * cc-exp,
+ *
+ * // metadata
+ * timeCreated, // in ms
+ * timeLastUsed, // in ms
+ * timeLastModified, // in ms
+ * timesUsed,
+ * _sync: { ... optional sync metadata },
+ * ...unknown fields // We keep fields we don't understand/expect from other clients
+ * // to prevent data loss for other clients, we roundtrip them for sync
+ * }
+ * ]
+ * }
+ *
+ *
+ * Encrypt-related Credit Card Fields (cc-number & cc-number-encrypted):
+ *
+ * When saving or updating a credit-card record, the storage will encrypt the
+ * value of "cc-number", store the encrypted number in "cc-number-encrypted"
+ * field, and replace "cc-number" field with the masked number. These all happen
+ * in "computeFields". We do reverse actions in "_stripComputedFields", which
+ * decrypts "cc-number-encrypted", restores it to "cc-number", and deletes
+ * "cc-number-encrypted". Therefore, calling "_stripComputedFields" followed by
+ * "computeFields" can make sure the encrypt-related fields are up-to-date.
+ *
+ * In general, you have to decrypt the number by your own outside FormAutofillStorage
+ * when necessary. However, you will get the decrypted records when querying
+ * data with "rawData=true" to ensure they're ready to sync.
+ *
+ *
+ * Sync Metadata:
+ *
+ * Records may also have a _sync field, which consists of:
+ * {
+ * changeCounter, // integer - the number of changes made since the last
+ * // sync.
+ * lastSyncedFields, // object - hashes of the original values for fields
+ * // changed since the last sync.
+ * }
+ *
+ * Records with such a field have previously been synced. Records without such
+ * a field are yet to be synced, so are treated specially in some cases (eg,
+ * they don't need a tombstone, de-duping logic treats them as special etc).
+ * Records without the field are always considered "dirty" from Sync's POV
+ * (meaning they will be synced on the next sync), at which time they will gain
+ * this new field.
+ */
+
+import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
+ FormAutofillNameUtils:
+ "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
+ FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs",
+ OSKeyStore: "resource://gre/modules/OSKeyStore.sys.mjs",
+ PhoneNumber: "resource://autofill/phonenumberutils/PhoneNumber.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ AutofillTelemetry: "resource://autofill/AutofillTelemetry.jsm",
+});
+
+const CryptoHash = Components.Constructor(
+ "@mozilla.org/security/hash;1",
+ "nsICryptoHash",
+ "initWithString"
+);
+
+const STORAGE_SCHEMA_VERSION = 1;
+
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+// (And if it did ever change, it must never be "4" due to the reconcile hacks
+// below which repairs credit-cards with version=4)
+export const ADDRESS_SCHEMA_VERSION = 1;
+
+// Version 2: Bug 1486954 - Encrypt `cc-number`
+// Version 3: Bug 1639795 - Update keystore name
+// Version 4: (deprecated!!! See Bug 1812235): Bug 1667257 - Do not store `cc-type` field
+// Next version should be 5
+// NOTE: It's likely this number can never change.
+// Please talk to the sync team before changing this!
+export const CREDIT_CARD_SCHEMA_VERSION = 3;
+
+const VALID_ADDRESS_FIELDS = [
+ "given-name",
+ "additional-name",
+ "family-name",
+ "organization",
+ "street-address",
+ "address-level3",
+ "address-level2",
+ "address-level1",
+ "postal-code",
+ "country",
+ "tel",
+ "email",
+];
+
+const STREET_ADDRESS_COMPONENTS = [
+ "address-line1",
+ "address-line2",
+ "address-line3",
+];
+
+const TEL_COMPONENTS = [
+ "tel-country-code",
+ "tel-national",
+ "tel-area-code",
+ "tel-local",
+ "tel-local-prefix",
+ "tel-local-suffix",
+];
+
+const VALID_ADDRESS_COMPUTED_FIELDS = ["name", "country-name"].concat(
+ STREET_ADDRESS_COMPONENTS,
+ TEL_COMPONENTS
+);
+
+const VALID_CREDIT_CARD_FIELDS = [
+ "billingAddressGUID",
+ "cc-name",
+ "cc-number",
+ "cc-exp-month",
+ "cc-exp-year",
+ "cc-type",
+];
+
+const VALID_CREDIT_CARD_COMPUTED_FIELDS = [
+ "cc-given-name",
+ "cc-additional-name",
+ "cc-family-name",
+ "cc-number-encrypted",
+ "cc-exp",
+];
+
+const INTERNAL_FIELDS = [
+ "guid",
+ "version",
+ "timeCreated",
+ "timeLastUsed",
+ "timeLastModified",
+ "timesUsed",
+];
+
+function sha512(string) {
+ if (string == null) {
+ return null;
+ }
+ let encoder = new TextEncoder();
+ let bytes = encoder.encode(string);
+ let hash = new CryptoHash("sha512");
+ hash.update(bytes, bytes.length);
+ return hash.finish(/* base64 */ true);
+}
+
+/**
+ * Class that manipulates records in a specified collection.
+ *
+ * Note that it is responsible for converting incoming data to a consistent
+ * format in the storage. For example, computed fields will be transformed to
+ * the original fields and 2-digit years will be calculated into 4 digits.
+ */
+class AutofillRecords {
+ /**
+ * Creates an AutofillRecords.
+ *
+ * @param {JSONFile} store
+ * An instance of JSONFile.
+ * @param {string} collectionName
+ * A key of "store.data".
+ * @param {Array.<string>} validFields
+ * A list containing non-metadata field names.
+ * @param {Array.<string>} validComputedFields
+ * A list containing computed field names.
+ * @param {number} schemaVersion
+ * The schema version for the new record.
+ */
+ constructor(
+ store,
+ collectionName,
+ validFields,
+ validComputedFields,
+ schemaVersion
+ ) {
+ this.log = FormAutofill.defineLogGetter(
+ lazy,
+ "AutofillRecords:" + collectionName
+ );
+
+ this.VALID_FIELDS = validFields;
+ this.VALID_COMPUTED_FIELDS = validComputedFields;
+
+ this._store = store;
+ this._collectionName = collectionName;
+ this._schemaVersion = schemaVersion;
+
+ this._initialize();
+
+ Services.obs.addObserver(this, "formautofill-storage-changed");
+ }
+
+ _initialize() {
+ this._initializePromise = Promise.all(
+ this._data.map(async (record, index) =>
+ this._migrateRecord(record, index)
+ )
+ ).then(hasChangesArr => {
+ let dataHasChanges = hasChangesArr.includes(true);
+ if (dataHasChanges) {
+ this._store.saveSoon();
+ }
+ });
+ }
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "formautofill-storage-changed":
+ let collectionName = subject.wrappedJSObject.collectionName;
+ if (collectionName != this._collectionName) {
+ return;
+ }
+ const telemetryType =
+ subject.wrappedJSObject.collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ const count = this._data.filter(entry => !entry.deleted).length;
+ lazy.AutofillTelemetry.recordAutofillProfileCount(telemetryType, count);
+ break;
+ }
+ }
+
+ /**
+ * Gets the schema version number.
+ *
+ * @returns {number}
+ * The current schema version number.
+ */
+ get version() {
+ return this._schemaVersion;
+ }
+
+ /**
+ * Gets the data of this collection.
+ *
+ * @returns {Array}
+ * The data object.
+ */
+ get _data() {
+ return this._getData();
+ }
+
+ _getData() {
+ return this._store.data[this._collectionName];
+ }
+
+ // Ensures that we don't try to apply synced records with newer schema
+ // versions. This is a temporary measure to ensure we don't accidentally
+ // bump the schema version without a syncing strategy in place (bug 1377204).
+ _ensureMatchingVersion(record) {
+ if (record.version != this.version) {
+ throw new Error(
+ `Got unknown record version ${record.version}; want ${this.version}`
+ );
+ }
+ }
+
+ /**
+ * Initialize the records in the collection, resolves when the migration completes.
+ *
+ * @returns {Promise}
+ */
+ initialize() {
+ return this._initializePromise;
+ }
+
+ /**
+ * Adds a new record.
+ *
+ * @param {object} record
+ * The new record for saving.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did sync generate this addition?
+ * @returns {Promise<string>}
+ * The GUID of the newly added item..
+ */
+ async add(record, { sourceSync = false } = {}) {
+ let recordToSave = this._clone(record);
+
+ if (sourceSync) {
+ // Remove tombstones for incoming items that were changed on another
+ // device. Local deletions always lose to avoid data loss.
+ let index = this._findIndexByGUID(recordToSave.guid, {
+ includeDeleted: true,
+ });
+ if (index > -1) {
+ let existing = this._data[index];
+ if (existing.deleted) {
+ this._data.splice(index, 1);
+ } else {
+ throw new Error(`Record ${recordToSave.guid} already exists`);
+ }
+ }
+ } else if (!recordToSave.deleted) {
+ this._normalizeRecord(recordToSave);
+ // _normalizeRecord shouldn't do any validation (throw) because in the
+ // `update` case it is called with partial records whereas
+ // `_validateFields` is called with a complete one.
+ this._validateFields(recordToSave);
+
+ recordToSave.guid = this._generateGUID();
+ recordToSave.version = this.version;
+
+ // Metadata
+ let now = Date.now();
+ recordToSave.timeCreated = now;
+ recordToSave.timeLastModified = now;
+ recordToSave.timeLastUsed = 0;
+ recordToSave.timesUsed = 0;
+ }
+
+ return this._saveRecord(recordToSave, { sourceSync });
+ }
+
+ async _saveRecord(record, { sourceSync = false } = {}) {
+ if (!record.guid) {
+ throw new Error("Record missing GUID");
+ }
+
+ let recordToSave;
+ if (record.deleted) {
+ if (this._findByGUID(record.guid, { includeDeleted: true })) {
+ throw new Error("a record with this GUID already exists");
+ }
+ recordToSave = {
+ guid: record.guid,
+ timeLastModified: record.timeLastModified || Date.now(),
+ deleted: true,
+ };
+ } else {
+ this._ensureMatchingVersion(record);
+ recordToSave = record;
+ await this.computeFields(recordToSave);
+ }
+
+ if (sourceSync) {
+ let sync = this._getSyncMetaData(recordToSave, true);
+ sync.changeCounter = 0;
+ }
+
+ this._data.push(recordToSave);
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid: record.guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "add"
+ );
+ return recordToSave.guid;
+ }
+
+ _generateGUID() {
+ let guid;
+ while (!guid || this._findByGUID(guid)) {
+ guid = Services.uuid
+ .generateUUID()
+ .toString()
+ .replace(/[{}-]/g, "")
+ .substring(0, 12);
+ }
+ return guid;
+ }
+
+ /**
+ * Update the specified record.
+ *
+ * @param {string} guid
+ * Indicates which record to update.
+ * @param {object} record
+ * The new record used to overwrite the old one.
+ * @param {Promise<boolean>} [preserveOldProperties = false]
+ * Preserve old record's properties if they don't exist in new record.
+ */
+ async update(guid, record, preserveOldProperties = false) {
+ this.log.debug(`update: ${guid}`);
+
+ let recordFoundIndex = this._findIndexByGUID(guid);
+ if (recordFoundIndex == -1) {
+ throw new Error("No matching record.");
+ }
+
+ // Clone the record before modifying it to avoid exposing incomplete changes.
+ let recordFound = this._clone(this._data[recordFoundIndex]);
+ await this._stripComputedFields(recordFound);
+
+ let recordToUpdate = this._clone(record);
+ this._normalizeRecord(recordToUpdate, true);
+
+ let hasValidField = false;
+ for (let field of this.VALID_FIELDS) {
+ let oldValue = recordFound[field];
+ let newValue = recordToUpdate[field];
+
+ // Resume the old field value in the perserve case
+ if (preserveOldProperties && newValue === undefined) {
+ newValue = oldValue;
+ }
+
+ if (newValue === undefined || newValue === "") {
+ delete recordFound[field];
+ } else {
+ hasValidField = true;
+ recordFound[field] = newValue;
+ }
+
+ this._maybeStoreLastSyncedField(recordFound, field, oldValue);
+ }
+
+ if (!hasValidField) {
+ throw new Error("Record contains no valid field.");
+ }
+
+ // _normalizeRecord above is called with the `record` argument provided to
+ // `update` which may not contain all resulting fields when
+ // `preserveOldProperties` is used. This means we need to validate for
+ // missing fields after we compose the record (`recordFound`) with the stored
+ // record like we do in the loop above.
+ this._validateFields(recordFound);
+
+ recordFound.timeLastModified = Date.now();
+ let syncMetadata = this._getSyncMetaData(recordFound);
+ if (syncMetadata) {
+ syncMetadata.changeCounter += 1;
+ }
+
+ await this.computeFields(recordFound);
+ this._data[recordFoundIndex] = recordFound;
+
+ this._store.saveSoon();
+
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "update"
+ );
+ }
+
+ /**
+ * Notifies the storage of the use of the specified record, so we can update
+ * the metadata accordingly. This does not bump the Sync change counter, since
+ * we don't sync `timesUsed` or `timeLastUsed`.
+ *
+ * @param {string} guid
+ * Indicates which record to be notified.
+ */
+ notifyUsed(guid) {
+ this.log.debug("notifyUsed:", guid);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ throw new Error("No matching record.");
+ }
+
+ recordFound.timesUsed++;
+ recordFound.timeLastUsed = Date.now();
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "notifyUsed"
+ );
+ }
+
+ updateUseCountTelemetry() {
+ const telemetryType =
+ this._collectionName == "creditCards"
+ ? lazy.AutofillTelemetry.CREDIT_CARD
+ : lazy.AutofillTelemetry.ADDRESS;
+ let records = this._data.filter(r => !r.deleted);
+ lazy.AutofillTelemetry.recordNumberOfUse(telemetryType, records);
+ }
+
+ /**
+ * Removes the specified record. No error occurs if the record isn't found.
+ *
+ * @param {string} guid
+ * Indicates which record to remove.
+ * @param {object} options
+ * @param {boolean} [options.sourceSync = false]
+ * Did Sync generate this removal?
+ */
+ remove(guid, { sourceSync = false } = {}) {
+ this.log.debug("remove:", guid);
+
+ if (sourceSync) {
+ this._removeSyncedRecord(guid);
+ } else {
+ let index = this._findIndexByGUID(guid, { includeDeleted: false });
+ if (index == -1) {
+ this.log.warn("attempting to remove non-existing entry", guid);
+ return;
+ }
+ let existing = this._data[index];
+ if (existing.deleted) {
+ return; // already a tombstone - don't touch it.
+ }
+ let existingSync = this._getSyncMetaData(existing);
+ if (existingSync) {
+ // existing sync metadata means it has been synced. This means we must
+ // leave a tombstone behind.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: existingSync,
+ };
+ existingSync.changeCounter++;
+ } else {
+ // If there's no sync meta-data, this record has never been synced, so
+ // we can delete it.
+ this._data.splice(index, 1);
+ }
+ }
+
+ this.updateUseCountTelemetry();
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ guid,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "remove"
+ );
+ }
+
+ /**
+ * Returns the record with the specified GUID.
+ *
+ * @param {string} guid
+ * Indicates which record to retrieve.
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns a raw record without modifications and the computed fields
+ * (this includes private fields)
+ * @returns {Promise<object>}
+ * A clone of the record.
+ */
+ async get(guid, { rawData = false } = {}) {
+ this.log.debug(`get: ${guid}`);
+
+ let recordFound = this._findByGUID(guid);
+ if (!recordFound) {
+ return null;
+ }
+
+ // The record is cloned to avoid accidental modifications from outside.
+ let clonedRecord = this._cloneAndCleanUp(recordFound);
+ if (rawData) {
+ await this._stripComputedFields(clonedRecord);
+ } else {
+ this._recordReadProcessor(clonedRecord);
+ }
+ return clonedRecord;
+ }
+
+ /**
+ * Returns all records.
+ *
+ * @param {object} options
+ * @param {boolean} [options.rawData = false]
+ * Returns raw records without modifications and the computed fields.
+ * @param {boolean} [options.includeDeleted = false]
+ * Also return any tombstone records.
+ * @returns {Promise<Array.<object>>}
+ * An array containing clones of all records.
+ */
+ async getAll({ rawData = false, includeDeleted = false } = {}) {
+ this.log.debug(`getAll. includeDeleted = ${includeDeleted}`);
+
+ let records = this._data.filter(r => !r.deleted || includeDeleted);
+ // Records are cloned to avoid accidental modifications from outside.
+ let clonedRecords = records.map(r => this._cloneAndCleanUp(r));
+ await Promise.all(
+ clonedRecords.map(async record => {
+ if (rawData) {
+ await this._stripComputedFields(record);
+ } else {
+ this._recordReadProcessor(record);
+ }
+ })
+ );
+ return clonedRecords;
+ }
+
+ /**
+ * Return all saved field names in the collection.
+ *
+ * @returns {Promise<Set>} Set containing saved field names.
+ */
+ async getSavedFieldNames() {
+ this.log.debug("getSavedFieldNames");
+
+ let records = this._data.filter(r => !r.deleted);
+ records
+ .map(record => this._cloneAndCleanUp(record))
+ .forEach(record => this._recordReadProcessor(record));
+
+ let fieldNames = new Set();
+ for (let record of records) {
+ for (let fieldName of Object.keys(record)) {
+ if (INTERNAL_FIELDS.includes(fieldName) || !record[fieldName]) {
+ continue;
+ }
+ fieldNames.add(fieldName);
+ }
+ }
+
+ return fieldNames;
+ }
+
+ /**
+ * Functions intended to be used in the support of Sync.
+ */
+
+ /**
+ * Stores a hash of the last synced value for a field in a locally updated
+ * record. We use this value to rebuild the shared parent, or base, when
+ * reconciling incoming records that may have changed on another device.
+ *
+ * Storing the hash of the values that we last wrote to the Sync server lets
+ * us determine if a remote change conflicts with a local change. If the
+ * hashes for the base, current local value, and remote value all differ, we
+ * have a conflict.
+ *
+ * These fields are not themselves synced, and will be removed locally as
+ * soon as we have successfully written the record to the Sync server - so
+ * it is expected they will not remain for long, as changes which cause a
+ * last synced field to be written will itself cause a sync.
+ *
+ * We also skip this for updates made by Sync, for internal fields, for
+ * records that haven't been uploaded yet, and for fields which have already
+ * been changed since the last sync.
+ *
+ * @param {object} record
+ * The updated local record.
+ * @param {string} field
+ * The field name.
+ * @param {string} lastSyncedValue
+ * The last synced field value.
+ */
+ _maybeStoreLastSyncedField(record, field, lastSyncedValue) {
+ let sync = this._getSyncMetaData(record);
+ if (!sync) {
+ // The record hasn't been uploaded yet, so we can't end up with merge
+ // conflicts.
+ return;
+ }
+ let alreadyChanged = field in sync.lastSyncedFields;
+ if (alreadyChanged) {
+ // This field was already changed multiple times since the last sync.
+ return;
+ }
+ let newValue = record[field];
+ if (lastSyncedValue != newValue) {
+ sync.lastSyncedFields[field] = sha512(lastSyncedValue);
+ }
+ }
+
+ /**
+ * Attempts a three-way merge between a changed local record, an incoming
+ * remote record, and the shared parent that we synthesize from the last
+ * synced fields - see _maybeStoreLastSyncedField.
+ *
+ * @param {object} strippedLocalRecord
+ * The changed local record, currently in storage. Computed fields
+ * are stripped.
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {object | null}
+ * The merged record, or `null` if there are conflicts and the
+ * records can't be merged.
+ */
+ _mergeSyncedRecords(strippedLocalRecord, remoteRecord) {
+ let sync = this._getSyncMetaData(strippedLocalRecord, true);
+
+ // Copy all internal fields from the remote record. We'll update their
+ // values in `_replaceRecordAt`.
+ let mergedRecord = {};
+ for (let field of INTERNAL_FIELDS) {
+ if (remoteRecord[field] != null) {
+ mergedRecord[field] = remoteRecord[field];
+ }
+ }
+
+ for (let field of this.VALID_FIELDS) {
+ let isLocalSame = false;
+ let isRemoteSame = false;
+ if (field in sync.lastSyncedFields) {
+ // If the field has changed since the last sync, compare hashes to
+ // determine if the local and remote values are different. Hashing is
+ // expensive, but we don't expect this to happen frequently.
+ let lastSyncedValue = sync.lastSyncedFields[field];
+ isLocalSame = lastSyncedValue == sha512(strippedLocalRecord[field]);
+ isRemoteSame = lastSyncedValue == sha512(remoteRecord[field]);
+ } else {
+ // Otherwise, if the field hasn't changed since the last sync, we know
+ // it's the same locally.
+ isLocalSame = true;
+ isRemoteSame = strippedLocalRecord[field] == remoteRecord[field];
+ }
+
+ let value;
+ if (isLocalSame && isRemoteSame) {
+ // Local and remote are the same; doesn't matter which one we pick.
+ value = strippedLocalRecord[field];
+ } else if (isLocalSame && !isRemoteSame) {
+ value = remoteRecord[field];
+ } else if (!isLocalSame && isRemoteSame) {
+ // We don't need to bump the change counter when taking the local
+ // change, because the counter must already be > 0 if we're attempting
+ // a three-way merge.
+ value = strippedLocalRecord[field];
+ } else if (strippedLocalRecord[field] == remoteRecord[field]) {
+ // Shared parent doesn't match either local or remote, but the values
+ // are identical, so there's no conflict.
+ value = strippedLocalRecord[field];
+ } else {
+ // Both local and remote changed to different values. We'll need to fork
+ // the local record to resolve the conflict.
+ return null;
+ }
+
+ if (value != null) {
+ mergedRecord[field] = value;
+ }
+ }
+
+ // When merging records, we shouldn't persist any unknown fields on the local and instead
+ // rely on the remote for unknown fields, so we filter the fields we know and keep the rest
+ Object.keys(remoteRecord)
+ .filter(
+ key =>
+ !this.VALID_FIELDS.includes(key) && !INTERNAL_FIELDS.includes(key)
+ )
+ .forEach(key => (mergedRecord[key] = remoteRecord[key]));
+ return mergedRecord;
+ }
+
+ /**
+ * Replaces a local record with a remote or merged record, copying internal
+ * fields and Sync metadata.
+ *
+ * @param {number} index
+ * @param {object} remoteRecord
+ * @param {object} options
+ * @param {Promise<boolean>} [options.keepSyncMetadata = false]
+ * Should we copy Sync metadata? This is true if `remoteRecord` is a
+ * merged record with local changes that we need to upload. Passing
+ * `keepSyncMetadata` retains the record's change counter and
+ * last synced fields, so that we don't clobber the local change if
+ * the sync is interrupted after the record is merged, but before
+ * it's uploaded.
+ */
+ async _replaceRecordAt(
+ index,
+ remoteRecord,
+ { keepSyncMetadata = false } = {}
+ ) {
+ let localRecord = this._data[index];
+ let newRecord = this._clone(remoteRecord);
+
+ await this._stripComputedFields(newRecord);
+
+ this._data[index] = newRecord;
+
+ if (keepSyncMetadata) {
+ // It's safe to move the Sync metadata from the old record to the new
+ // record, since we always clone records when we return them, and we
+ // never hand out references to the metadata object via public methods.
+ newRecord._sync = localRecord._sync;
+ } else {
+ // As a side effect, `_getSyncMetaData` marks the record as syncing if the
+ // existing `localRecord` is a dupe of `remoteRecord`, and we're replacing
+ // local with remote.
+ let sync = this._getSyncMetaData(newRecord, true);
+ sync.changeCounter = 0;
+ }
+
+ if (
+ !newRecord.timeCreated ||
+ localRecord.timeCreated < newRecord.timeCreated
+ ) {
+ newRecord.timeCreated = localRecord.timeCreated;
+ }
+
+ if (
+ !newRecord.timeLastModified ||
+ localRecord.timeLastModified > newRecord.timeLastModified
+ ) {
+ newRecord.timeLastModified = localRecord.timeLastModified;
+ }
+
+ // Copy local-only fields from the existing local record.
+ for (let field of ["timeLastUsed", "timesUsed"]) {
+ if (localRecord[field] != null) {
+ newRecord[field] = localRecord[field];
+ }
+ }
+
+ await this.computeFields(newRecord);
+ }
+
+ /**
+ * Clones a local record, giving the clone a new GUID and Sync metadata. The
+ * original record remains unchanged in storage.
+ *
+ * @param {object} strippedLocalRecord
+ * The local record. Computed fields are stripped.
+ * @returns {string}
+ * A clone of the local record with a new GUID.
+ */
+ async _forkLocalRecord(strippedLocalRecord) {
+ let forkedLocalRecord = this._cloneAndCleanUp(strippedLocalRecord);
+ forkedLocalRecord.guid = this._generateGUID();
+
+ // Give the record fresh Sync metadata and bump its change counter as a
+ // side effect. This also excludes the forked record from de-duping on the
+ // next sync, if the current sync is interrupted before the record can be
+ // uploaded.
+ this._getSyncMetaData(forkedLocalRecord, true);
+
+ await this.computeFields(forkedLocalRecord);
+ this._data.push(forkedLocalRecord);
+
+ return forkedLocalRecord;
+ }
+
+ /**
+ * Reconciles an incoming remote record into the matching local record. This
+ * method is only used by Sync; other callers should use `merge`.
+ *
+ * @param {object} remoteRecord
+ * The incoming record. `remoteRecord` must not be a tombstone, and
+ * must have a matching local record with the same GUID. Use
+ * `add` to insert remote records that don't exist locally, and
+ * `remove` to apply remote tombstones.
+ * @returns {Promise<object>}
+ * A `{forkedGUID}` tuple. `forkedGUID` is `null` if the merge
+ * succeeded without conflicts, or a new GUID referencing the
+ * existing locally modified record if the conflicts could not be
+ * resolved.
+ */
+ async reconcile(remoteRecord) {
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ throw new Error(`Can't reconcile tombstone ${remoteRecord.guid}`);
+ }
+
+ let localIndex = this._findIndexByGUID(remoteRecord.guid);
+ if (localIndex < 0) {
+ throw new Error(`Record ${remoteRecord.guid} not found`);
+ }
+
+ let localRecord = this._data[localIndex];
+ let sync = this._getSyncMetaData(localRecord, true);
+
+ let forkedGUID = null;
+
+ // NOTE: This implies a credit-card - so it's critical ADDRESS_SCHEMA_VERSION
+ // never equals 4 while this code exists!
+ let requiresForceUpdate =
+ localRecord.version != remoteRecord.version && remoteRecord.version == 4;
+
+ if (requiresForceUpdate) {
+ // Another desktop device that is still using version=4 has created or
+ // modified a remote record. Here we downgrade it to version=3 so we can
+ // treat it normally, then cause it to be re-uploaded so other desktop
+ // or mobile devices can still see it.
+ // That device still using version=4 *will* again see it, and again
+ // upgrade it, but thankfully that 3->4 migration doesn't force a reupload
+ // of all records, or we'd be going back and forward on every sync.
+ // Once that version=4 device gets updated to roll back to version=3, it
+ // will then yet again re-upload it, this time with version=3, but the
+ // content will be the same here, so everything should work out in the end.
+ //
+ // If we just ignored this incoming record, it would remain on the server
+ // with version=4. If the device that wrote that went away (ie, never
+ // synced again) nothing would ever repair it back to 3, which would
+ // be bad because mobile would remain broken until the user edited the
+ // card somewhere.
+ remoteRecord = await this._computeMigratedRecord(remoteRecord);
+ }
+ if (sync.changeCounter === 0) {
+ // Local not modified. Replace local with remote.
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ } else {
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let mergedRecord = this._mergeSyncedRecords(
+ strippedLocalRecord,
+ remoteRecord
+ );
+ if (mergedRecord) {
+ // Local and remote modified, but we were able to merge. Replace the
+ // local record with the merged record.
+ await this._replaceRecordAt(localIndex, mergedRecord, {
+ keepSyncMetadata: true,
+ });
+ } else {
+ // Merge conflict. Fork the local record, then replace the original
+ // with the merged record.
+ let forkedLocalRecord = await this._forkLocalRecord(
+ strippedLocalRecord
+ );
+ forkedGUID = forkedLocalRecord.guid;
+ await this._replaceRecordAt(localIndex, remoteRecord, {
+ keepSyncMetadata: false,
+ });
+ }
+ }
+
+ if (requiresForceUpdate) {
+ // The incoming record was version=4 and we want to re-upload it as version=3.
+ // We need to reach directly into self._data[] so we can poke at the
+ // sync metadata directly.
+ let indexToUpdate = this._findIndexByGUID(remoteRecord.guid);
+ let toUpdate = this._data[indexToUpdate];
+ this._getSyncMetaData(toUpdate, true).changeCounter += 1;
+ this.log.info(
+ `Flagging record ${toUpdate.guid} for re-upload after record version downgrade`
+ );
+ }
+
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync: true,
+ guid: remoteRecord.guid,
+ forkedGUID,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "reconcile"
+ );
+
+ return { forkedGUID };
+ }
+
+ _removeSyncedRecord(guid) {
+ let index = this._findIndexByGUID(guid, { includeDeleted: true });
+ if (index == -1) {
+ // Removing a record we don't know about. It may have been synced and
+ // removed by another device before we saw it. Store the tombstone in
+ // case the server is later wiped and we need to reupload everything.
+ let tombstone = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ let sync = this._getSyncMetaData(tombstone, true);
+ sync.changeCounter = 0;
+ this._data.push(tombstone);
+ return;
+ }
+
+ let existing = this._data[index];
+ let sync = this._getSyncMetaData(existing, true);
+ if (sync.changeCounter > 0) {
+ // Deleting a record with unsynced local changes. To avoid potential
+ // data loss, we ignore the deletion in favor of the changed record.
+ this.log.info(
+ "Ignoring deletion for record with local changes",
+ existing
+ );
+ return;
+ }
+
+ if (existing.deleted) {
+ this.log.info("Ignoring deletion for tombstone", existing);
+ return;
+ }
+
+ // Removing a record that's not changed locally, and that's not already
+ // deleted. Replace the record with a synced tombstone.
+ this._data[index] = {
+ guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ _sync: sync,
+ };
+ }
+
+ /**
+ * Provide an object that describes the changes to sync.
+ *
+ * This is called at the start of the sync process to determine what needs
+ * to be updated on the server. As the server is updated, sync will update
+ * entries in the returned object, and when sync is complete it will pass
+ * the object to pushSyncChanges, which will apply the changes to the store.
+ *
+ * @returns {object}
+ * An object describing the changes to sync.
+ */
+ pullSyncChanges() {
+ let changes = {};
+
+ let profiles = this._data;
+ for (let profile of profiles) {
+ let sync = this._getSyncMetaData(profile, true);
+ if (sync.changeCounter < 1) {
+ if (sync.changeCounter != 0) {
+ this.log.error("negative change counter", profile);
+ }
+ continue;
+ }
+ changes[profile.guid] = {
+ profile,
+ counter: sync.changeCounter,
+ modified: profile.timeLastModified,
+ synced: false,
+ };
+ }
+ this._store.saveSoon();
+
+ return changes;
+ }
+
+ /**
+ * Apply the metadata changes made by Sync.
+ *
+ * This is called with metadata about what was synced - see pullSyncChanges.
+ *
+ * @param {object} changes
+ * The possibly modified object obtained via pullSyncChanges.
+ */
+ pushSyncChanges(changes) {
+ for (let [guid, { counter, synced }] of Object.entries(changes)) {
+ if (!synced) {
+ continue;
+ }
+ let recordFound = this._findByGUID(guid, { includeDeleted: true });
+ if (!recordFound) {
+ this.log.warn("No profile found to persist changes for guid " + guid);
+ continue;
+ }
+ let sync = this._getSyncMetaData(recordFound, true);
+ sync.changeCounter = Math.max(0, sync.changeCounter - counter);
+ if (sync.changeCounter === 0) {
+ // Clear the shared parent fields once we've uploaded all pending
+ // changes, since the server now matches what we have locally.
+ sync.lastSyncedFields = {};
+ }
+ }
+ this._store.saveSoon();
+ }
+
+ /**
+ * Reset all sync metadata for all items.
+ *
+ * This is called when Sync is disconnected from this device. All sync
+ * metadata for all items is removed.
+ */
+ resetSync() {
+ for (let record of this._data) {
+ delete record._sync;
+ }
+ // XXX - we should probably also delete all tombstones?
+ this.log.info("All sync metadata was reset");
+ }
+
+ /**
+ * Changes the GUID of an item. This should be called only by Sync. There
+ * must be an existing record with oldID and it must never have been synced
+ * or an error will be thrown. There must be no existing record with newID.
+ *
+ * No tombstone will be created for the old GUID - we check it hasn't
+ * been synced, so no tombstone is necessary.
+ *
+ * @param {string} oldID
+ * GUID of the existing item to change the GUID of.
+ * @param {string} newID
+ * The new GUID for the item.
+ */
+ changeGUID(oldID, newID) {
+ this.log.debug("changeGUID: ", oldID, newID);
+ if (oldID == newID) {
+ throw new Error("changeGUID: old and new IDs are the same");
+ }
+ if (this._findIndexByGUID(newID) >= 0) {
+ throw new Error("changeGUID: record with destination id exists already");
+ }
+
+ let index = this._findIndexByGUID(oldID);
+ let profile = this._data[index];
+ if (!profile) {
+ throw new Error("changeGUID: no source record");
+ }
+ if (this._getSyncMetaData(profile)) {
+ throw new Error("changeGUID: existing record has already been synced");
+ }
+
+ profile.guid = newID;
+
+ this._store.saveSoon();
+ }
+
+ // Used to get, and optionally create, sync metadata. Brand new records will
+ // *not* have sync meta-data - it will be created when they are first
+ // synced.
+ _getSyncMetaData(record, forceCreate = false) {
+ if (!record._sync && forceCreate) {
+ // create default metadata and indicate we need to save.
+ record._sync = {
+ changeCounter: 1,
+ lastSyncedFields: {},
+ };
+ this._store.saveSoon();
+ }
+ return record._sync;
+ }
+
+ /**
+ * Finds a local record with matching common fields and a different GUID.
+ * Sync uses this method to find and update unsynced local records with
+ * fields that match incoming remote records. This avoids creating
+ * duplicate profiles with the same information.
+ *
+ * @param {object} remoteRecord
+ * The remote record.
+ * @returns {Promise<string|null>}
+ * The GUID of the matching local record, or `null` if no records
+ * match.
+ */
+ async findDuplicateGUID(remoteRecord) {
+ if (!remoteRecord.guid) {
+ throw new Error("Record missing GUID");
+ }
+ this._ensureMatchingVersion(remoteRecord);
+ if (remoteRecord.deleted) {
+ // Tombstones don't carry enough info to de-dupe, and we should have
+ // handled them separately when applying the record.
+ throw new Error("Tombstones can't have duplicates");
+ }
+ let localRecords = this._data;
+ for (let localRecord of localRecords) {
+ if (localRecord.deleted) {
+ continue;
+ }
+ if (localRecord.guid == remoteRecord.guid) {
+ throw new Error(`Record ${remoteRecord.guid} already exists`);
+ }
+ if (this._getSyncMetaData(localRecord)) {
+ // This local record has already been uploaded, so it can't be a dupe of
+ // another incoming item.
+ continue;
+ }
+
+ // Ignore computed fields when matching records as they aren't synced at all.
+ let strippedLocalRecord = this._clone(localRecord);
+ await this._stripComputedFields(strippedLocalRecord);
+
+ let keys = new Set(Object.keys(remoteRecord));
+ for (let key of Object.keys(strippedLocalRecord)) {
+ keys.add(key);
+ }
+ // Ignore internal fields when matching records. Internal fields are synced,
+ // but almost certainly have different values than the local record, and
+ // we'll update them in `reconcile`.
+ for (let field of INTERNAL_FIELDS) {
+ keys.delete(field);
+ }
+ if (!keys.size) {
+ // This shouldn't ever happen; a valid record will always have fields
+ // that aren't computed or internal. Sync can't do anything about that,
+ // so we ignore the dubious local record instead of throwing.
+ continue;
+ }
+ let same = true;
+ for (let key of keys) {
+ // For now, we ensure that both (or neither) records have the field
+ // with matching values. This doesn't account for the version yet
+ // (bug 1377204).
+ same =
+ key in strippedLocalRecord == key in remoteRecord &&
+ strippedLocalRecord[key] == remoteRecord[key];
+ if (!same) {
+ break;
+ }
+ }
+ if (same) {
+ return strippedLocalRecord.guid;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Internal helper functions.
+ */
+
+ _clone(record) {
+ return Object.assign({}, record);
+ }
+
+ _cloneAndCleanUp(record) {
+ let result = {};
+ for (let key in record) {
+ // Do not expose hidden fields and fields with empty value (mainly used
+ // as placeholders of the computed fields).
+ if (!key.startsWith("_") && record[key] !== "") {
+ result[key] = record[key];
+ }
+ }
+ return result;
+ }
+
+ _findByGUID(guid, { includeDeleted = false } = {}) {
+ let found = this._findIndexByGUID(guid, { includeDeleted });
+ return found < 0 ? undefined : this._data[found];
+ }
+
+ _findIndexByGUID(guid, { includeDeleted = false } = {}) {
+ return this._data.findIndex(record => {
+ return record.guid == guid && (!record.deleted || includeDeleted);
+ });
+ }
+
+ async _migrateRecord(record, index) {
+ let hasChanges = false;
+
+ if (record.deleted) {
+ return hasChanges;
+ }
+
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ this.log.warn("Invalid record version:", record.version);
+
+ // Force to run the migration.
+ record.version = 0;
+ }
+
+ if (this._isMigrationNeeded(record.version)) {
+ hasChanges = true;
+
+ record = await this._computeMigratedRecord(record);
+
+ if (record.deleted) {
+ // record is deleted by _computeMigratedRecord(),
+ // go ahead and put it in the store.
+ this._data[index] = record;
+ return hasChanges;
+ }
+
+ // Compute the computed fields before putting it to store.
+ await this.computeFields(record);
+ this._data[index] = record;
+
+ return hasChanges;
+ }
+
+ hasChanges |= await this.computeFields(record);
+ return hasChanges;
+ }
+
+ _normalizeRecord(record, preserveEmptyFields = false) {
+ this._normalizeFields(record);
+
+ for (let key in record) {
+ if (!this.VALID_FIELDS.includes(key)) {
+ // Though we allow unknown fields, certain fields are still protected
+ // from being changed
+ if (INTERNAL_FIELDS.includes(key)) {
+ throw new Error(`"${key}" is not a valid field.`);
+ } else {
+ // We shouldn't try to normalize unknown fields. We'll just roundtrip them
+ this.log.warn(`${key} is not a known field. Skipping normalization.`);
+ continue;
+ }
+ }
+ if (typeof record[key] !== "string" && typeof record[key] !== "number") {
+ throw new Error(
+ `"${key}" contains invalid data type: ${typeof record[key]}`
+ );
+ }
+ if (!preserveEmptyFields && record[key] === "") {
+ delete record[key];
+ }
+ }
+
+ if (!Object.keys(record).length) {
+ throw new Error("Record contains no valid field.");
+ }
+ }
+
+ /**
+ * Merge the record if storage has multiple mergeable records.
+ *
+ * @param {object} targetRecord
+ * The record for merge.
+ * @param {boolean} [strict = false]
+ * In strict merge mode, we'll treat the subset record with empty field
+ * as unable to be merged, but mergeable if in non-strict mode.
+ * @returns {Array.<string>}
+ * Return an array of the merged GUID string.
+ */
+ async mergeToStorage(targetRecord, strict = false) {
+ let mergedGUIDs = [];
+ for (let record of this._data) {
+ if (
+ !record.deleted &&
+ (await this.mergeIfPossible(record.guid, targetRecord, strict))
+ ) {
+ mergedGUIDs.push(record.guid);
+ }
+ }
+ this.log.debug(
+ "Existing records matching and merging count is",
+ mergedGUIDs.length
+ );
+ return mergedGUIDs;
+ }
+
+ /**
+ * Unconditionally remove all data and tombstones for this collection.
+ */
+ removeAll({ sourceSync = false } = {}) {
+ this._store.data[this._collectionName] = [];
+ this._store.saveSoon();
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ sourceSync,
+ collectionName: this._collectionName,
+ },
+ },
+ "formautofill-storage-changed",
+ "removeAll"
+ );
+ }
+
+ _isMigrationNeeded(recordVersion) {
+ return recordVersion < this.version;
+ }
+
+ /**
+ * Strip the computed fields based on the record version.
+ *
+ * @param {object} record The record to migrate
+ * @returns {object} Migrated record.
+ * Record is always cloned, with version updated,
+ * with computed fields stripped.
+ * Could be a tombstone record, if the record
+ * should be discorded.
+ */
+ async _computeMigratedRecord(record) {
+ if (!record.deleted) {
+ record = this._clone(record);
+ await this._stripComputedFields(record);
+ record.version = this.version;
+ }
+ return record;
+ }
+
+ async _stripComputedFields(record) {
+ this.VALID_COMPUTED_FIELDS.forEach(field => delete record[field]);
+ }
+
+ // An interface to be inherited.
+ _recordReadProcessor(record) {}
+
+ // An interface to be inherited.
+ async computeFields(record) {}
+
+ /**
+ * An interface to be inherited to mutate the argument to normalize it.
+ *
+ * @param {object} partialRecord containing the record passed by the consumer of
+ * storage and in the case of `update` with
+ * `preserveOldProperties` will only include the
+ * properties that the user is changing so the
+ * lack of a field doesn't mean that the record
+ * won't have that field.
+ */
+ _normalizeFields(partialRecord) {}
+
+ /**
+ * An interface to be inherited to validate that the complete record is
+ * consistent and isn't missing required fields. Overrides should throw for
+ * invalid records.
+ *
+ * @param {object} record containing the complete record that would be stored
+ * if this doesn't throw due to an error.
+ * @throws
+ */
+ _validateFields(record) {}
+
+ // An interface to be inherited.
+ async mergeIfPossible(guid, record, strict) {}
+}
+
+export class AddressesBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "addresses",
+ VALID_ADDRESS_FIELDS,
+ VALID_ADDRESS_COMPUTED_FIELDS,
+ ADDRESS_SCHEMA_VERSION
+ );
+ }
+
+ _recordReadProcessor(address) {
+ if (address.country && !FormAutofill.countries.has(address.country)) {
+ delete address.country;
+ delete address["country-name"];
+ }
+ }
+
+ async computeFields(address) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (address.deleted) {
+ return hasNewComputedFields;
+ }
+
+ // Compute name
+ if (!("name" in address)) {
+ let name = lazy.FormAutofillNameUtils.joinNameParts({
+ given: address["given-name"],
+ middle: address["additional-name"],
+ family: address["family-name"],
+ });
+ address.name = name;
+ hasNewComputedFields = true;
+ }
+
+ // Compute address lines
+ if (!("address-line1" in address)) {
+ let streetAddress = [];
+ if (address["street-address"]) {
+ streetAddress = address["street-address"]
+ .split("\n")
+ .map(s => s.trim());
+ }
+ for (let i = 0; i < 3; i++) {
+ address[`address-line${i + 1}`] = streetAddress[i] || "";
+ }
+ if (streetAddress.length > 3) {
+ address["address-line3"] = lazy.FormAutofillUtils.toOneLineAddress(
+ streetAddress.slice(2)
+ );
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute country name
+ if (!("country-name" in address)) {
+ if (address.country) {
+ try {
+ address["country-name"] = Services.intl.getRegionDisplayNames(
+ undefined,
+ [address.country]
+ );
+ } catch (e) {
+ address["country-name"] = "";
+ }
+ } else {
+ address["country-name"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Compute tel
+ if (!("tel-national" in address)) {
+ if (address.tel) {
+ let tel = lazy.PhoneNumber.Parse(
+ address.tel,
+ address.country || FormAutofill.DEFAULT_REGION
+ );
+ if (tel) {
+ if (tel.countryCode) {
+ address["tel-country-code"] = tel.countryCode;
+ }
+ if (tel.nationalNumber) {
+ address["tel-national"] = tel.nationalNumber;
+ }
+
+ // PhoneNumberUtils doesn't support parsing the components of a telephone
+ // number so we hard coded the parser for US numbers only. We will need
+ // to figure out how to parse numbers from other regions when we support
+ // new countries in the future.
+ if (tel.nationalNumber && tel.countryCode == "+1") {
+ let telComponents = tel.nationalNumber.match(
+ /(\d{3})((\d{3})(\d{4}))$/
+ );
+ if (telComponents) {
+ address["tel-area-code"] = telComponents[1];
+ address["tel-local"] = telComponents[2];
+ address["tel-local-prefix"] = telComponents[3];
+ address["tel-local-suffix"] = telComponents[4];
+ }
+ }
+ } else {
+ // Treat "tel" as "tel-national" directly if it can't be parsed.
+ address["tel-national"] = address.tel;
+ }
+ }
+
+ TEL_COMPONENTS.forEach(c => {
+ address[c] = address[c] || "";
+ });
+ }
+
+ return hasNewComputedFields;
+ }
+
+ _normalizeFields(address) {
+ this._normalizeName(address);
+ this._normalizeAddress(address);
+ this._normalizeCountry(address);
+ this._normalizeTel(address);
+ }
+
+ _normalizeName(address) {
+ if (address.name) {
+ let nameParts = lazy.FormAutofillNameUtils.splitName(address.name);
+ if (!address["given-name"] && nameParts.given) {
+ address["given-name"] = nameParts.given;
+ }
+ if (!address["additional-name"] && nameParts.middle) {
+ address["additional-name"] = nameParts.middle;
+ }
+ if (!address["family-name"] && nameParts.family) {
+ address["family-name"] = nameParts.family;
+ }
+ }
+ delete address.name;
+ }
+
+ _normalizeAddress(address) {
+ if (STREET_ADDRESS_COMPONENTS.some(c => !!address[c])) {
+ // Treat "street-address" as "address-line1" if it contains only one line
+ // and "address-line1" is omitted.
+ if (
+ !address["address-line1"] &&
+ address["street-address"] &&
+ !address["street-address"].includes("\n")
+ ) {
+ address["address-line1"] = address["street-address"];
+ delete address["street-address"];
+ }
+
+ // Concatenate "address-line*" if "street-address" is omitted.
+ if (!address["street-address"]) {
+ address["street-address"] = STREET_ADDRESS_COMPONENTS.map(
+ c => address[c]
+ )
+ .join("\n")
+ .replace(/\n+$/, "");
+ }
+ }
+ STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ _normalizeCountry(address) {
+ let country;
+
+ if (address.country) {
+ country = address.country.toUpperCase();
+ } else if (address["country-name"]) {
+ country = lazy.FormAutofillUtils.identifyCountryCode(
+ address["country-name"]
+ );
+ }
+
+ // Only values included in the region list will be saved.
+ let hasLocalizedName = false;
+ try {
+ if (country) {
+ let localizedName = Services.intl.getRegionDisplayNames(undefined, [
+ country,
+ ]);
+ hasLocalizedName = localizedName != country;
+ }
+ } catch (e) {}
+
+ if (country && hasLocalizedName) {
+ address.country = country;
+ } else {
+ delete address.country;
+ }
+
+ delete address["country-name"];
+ }
+
+ _normalizeTel(address) {
+ if (address.tel || TEL_COMPONENTS.some(c => !!address[c])) {
+ lazy.FormAutofillUtils.compressTel(address);
+
+ let possibleRegion = address.country || FormAutofill.DEFAULT_REGION;
+ let tel = lazy.PhoneNumber.Parse(address.tel, possibleRegion);
+
+ if (tel && tel.internationalNumber) {
+ // Force to save numbers in E.164 format if parse success.
+ address.tel = tel.internationalNumber;
+ }
+ }
+ TEL_COMPONENTS.forEach(c => delete address[c]);
+ }
+
+ /**
+ * Merge new address into the specified address if mergeable.
+ *
+ * @param {string} guid
+ * Indicates which address to merge.
+ * @param {object} address
+ * The new address used to merge into the old one.
+ * @param {boolean} strict
+ * In strict merge mode, we'll treat the subset record with empty field
+ * as unable to be merged, but mergeable if in non-strict mode.
+ * @returns {Promise<boolean>}
+ * Return true if address is merged into target with specific guid or false if not.
+ */
+ async mergeIfPossible(guid, address, strict) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ compareAddressField(field, a, b, collator) {
+ switch (field) {
+ case "street-address":
+ let ret = lazy.FormAutofillUtils.compareStreetAddress(a, b, collator);
+ return ret;
+ // TODO: support other cases
+ default:
+ return a == b;
+ }
+ }
+
+ /**
+ * Normalize the given record and return records that are either the same
+ * or is superset of the normalized given record.
+ *
+ * See the comments in `getDuplicateRecords` to see the difference between
+ * `getDuplicateRecords` and `getMatchRecords`
+ *
+ * @param {object} record
+ * The address entry for match checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first matched record found in storage, null otherwise.
+ */
+ async *getMatchRecords(record) {
+ const collators = lazy.FormAutofillUtils.getSearchCollators(
+ FormAutofill.DEFAULT_REGION
+ );
+
+ for (const recordInStorage of this._data) {
+ if (
+ this.VALID_FIELDS.every(
+ field =>
+ !record[field] ||
+ this.compareAddressField(
+ field,
+ record[field],
+ recordInStorage[field],
+ collators
+ )
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Normalize the given record and return a duplicate address record in
+ * the storage.
+ *
+ * This is different from `getMatchRecords`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The address entry for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ const collators = lazy.FormAutofillUtils.getSearchCollators(
+ FormAutofill.DEFAULT_REGION
+ );
+
+ for (const recordInStorage of this._data) {
+ if (
+ this.VALID_FIELDS.every(
+ field =>
+ !record[field] ||
+ !recordInStorage[field] ||
+ this.compareAddressField(
+ field,
+ record[field],
+ recordInStorage[field],
+ collators
+ )
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+}
+
+export class CreditCardsBase extends AutofillRecords {
+ constructor(store) {
+ super(
+ store,
+ "creditCards",
+ VALID_CREDIT_CARD_FIELDS,
+ VALID_CREDIT_CARD_COMPUTED_FIELDS,
+ CREDIT_CARD_SCHEMA_VERSION
+ );
+ }
+
+ async computeFields(creditCard) {
+ // NOTE: Remember to bump the schema version number if any of the existing
+ // computing algorithm changes. (No need to bump when just adding new
+ // computed fields.)
+
+ // NOTE: Computed fields should be always present in the storage no matter
+ // it's empty or not.
+
+ let hasNewComputedFields = false;
+
+ if (creditCard.deleted) {
+ return hasNewComputedFields;
+ }
+
+ let type = lazy.CreditCard.getType(creditCard["cc-number"]);
+ if (type) {
+ creditCard["cc-type"] = type;
+ }
+
+ // Compute split names
+ if (!("cc-given-name" in creditCard)) {
+ let nameParts = lazy.FormAutofillNameUtils.splitName(
+ creditCard["cc-name"]
+ );
+ creditCard["cc-given-name"] = nameParts.given;
+ creditCard["cc-additional-name"] = nameParts.middle;
+ creditCard["cc-family-name"] = nameParts.family;
+ hasNewComputedFields = true;
+ }
+
+ // Compute credit card expiration date
+ if (!("cc-exp" in creditCard)) {
+ if (creditCard["cc-exp-month"] && creditCard["cc-exp-year"]) {
+ creditCard["cc-exp"] =
+ String(creditCard["cc-exp-year"]) +
+ "-" +
+ String(creditCard["cc-exp-month"]).padStart(2, "0");
+ } else {
+ creditCard["cc-exp"] = "";
+ }
+ hasNewComputedFields = true;
+ }
+
+ // Encrypt credit card number
+ await this._encryptNumber(creditCard);
+
+ return hasNewComputedFields;
+ }
+
+ async _encryptNumber(creditCard) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ _isMigrationNeeded(recordVersion) {
+ return (
+ // version 4 is deprecated and is rolled back to version 3
+ recordVersion == 4 || recordVersion < this.version
+ );
+ }
+
+ async _computeMigratedRecord(creditCard) {
+ if (creditCard.version <= 2) {
+ if (creditCard["cc-number-encrypted"]) {
+ // We cannot decrypt the data, so silently remove the record for
+ // the user.
+ if (!creditCard.deleted) {
+ this.log.warn(
+ "Removing version",
+ creditCard.version,
+ "credit card record to migrate to new encryption:",
+ creditCard.guid
+ );
+
+ // Replace the record with a tombstone record here,
+ // regardless of existence of sync metadata.
+ let existingSync = this._getSyncMetaData(creditCard);
+ creditCard = {
+ guid: creditCard.guid,
+ timeLastModified: Date.now(),
+ deleted: true,
+ };
+
+ if (existingSync) {
+ creditCard._sync = existingSync;
+ existingSync.changeCounter++;
+ }
+ }
+ }
+ }
+
+ // Do not remove the migration code until we're sure no users have version 4
+ // credit card records (created in Fx110 or Fx111)
+ if (creditCard.version == 4) {
+ // Version 4 is deprecated, so downgrade or upgrade to the current version
+ // Since the only change made in version 4 is deleting `cc-type` field, so
+ // nothing else need to be done here expect flagging sync needed
+ let existingSync = this._getSyncMetaData(creditCard);
+ if (existingSync) {
+ existingSync.changeCounter++;
+ }
+ }
+
+ return super._computeMigratedRecord(creditCard);
+ }
+
+ async _stripComputedFields(creditCard) {
+ if (creditCard["cc-number-encrypted"]) {
+ try {
+ creditCard["cc-number"] = await lazy.OSKeyStore.decrypt(
+ creditCard["cc-number-encrypted"]
+ );
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_ABORT) {
+ throw ex;
+ }
+ // Quietly recover from encryption error,
+ // so existing credit card entry with undecryptable number
+ // can be updated.
+ }
+ }
+ await super._stripComputedFields(creditCard);
+ }
+
+ _normalizeFields(creditCard) {
+ this._normalizeCCName(creditCard);
+ this._normalizeCCNumber(creditCard);
+ this._normalizeCCExpirationDate(creditCard);
+ }
+
+ _normalizeCCName(creditCard) {
+ if (
+ creditCard["cc-given-name"] ||
+ creditCard["cc-additional-name"] ||
+ creditCard["cc-family-name"]
+ ) {
+ if (!creditCard["cc-name"]) {
+ creditCard["cc-name"] = lazy.FormAutofillNameUtils.joinNameParts({
+ given: creditCard["cc-given-name"],
+ middle: creditCard["cc-additional-name"],
+ family: creditCard["cc-family-name"],
+ });
+ }
+ }
+ delete creditCard["cc-given-name"];
+ delete creditCard["cc-additional-name"];
+ delete creditCard["cc-family-name"];
+ }
+
+ _normalizeCCNumber(creditCard) {
+ if (!("cc-number" in creditCard)) {
+ return;
+ }
+ if (!lazy.CreditCard.isValidNumber(creditCard["cc-number"])) {
+ delete creditCard["cc-number"];
+ return;
+ }
+ let card = new lazy.CreditCard({ number: creditCard["cc-number"] });
+ creditCard["cc-number"] = card.number;
+ }
+
+ _normalizeCCExpirationDate(creditCard) {
+ let normalizedExpiration = lazy.CreditCard.normalizeExpiration({
+ expirationMonth: creditCard["cc-exp-month"],
+ expirationYear: creditCard["cc-exp-year"],
+ expirationString: creditCard["cc-exp"],
+ });
+ if (normalizedExpiration.month) {
+ creditCard["cc-exp-month"] = normalizedExpiration.month;
+ } else {
+ delete creditCard["cc-exp-month"];
+ }
+ if (normalizedExpiration.year) {
+ creditCard["cc-exp-year"] = normalizedExpiration.year;
+ } else {
+ delete creditCard["cc-exp-year"];
+ }
+ delete creditCard["cc-exp"];
+ }
+
+ _validateFields(creditCard) {
+ if (!creditCard["cc-number"]) {
+ throw new Error("Missing/invalid cc-number");
+ }
+ }
+
+ _ensureMatchingVersion(record) {
+ if (!record.version || isNaN(record.version) || record.version < 1) {
+ throw new Error(
+ `Got invalid record version ${record.version}; want ${this.version}`
+ );
+ }
+
+ if (record.version == 4) {
+ // Version 4 is deprecated, we need to force downloading it from sync
+ // and let migration do the work to downgrade it back to the current version.
+ return true;
+ } else if (record.version < this.version) {
+ switch (record.version) {
+ case 1:
+ case 2:
+ // The difference between version 1 and 2 is only about the encryption
+ // method used for the cc-number-encrypted field.
+ // The difference between version 2 and 3 is the name of the OS
+ // key encryption record.
+ // As long as the record is already decrypted, it is safe to bump the
+ // version directly.
+ if (!record["cc-number-encrypted"]) {
+ record.version = this.version;
+ } else {
+ throw new Error(
+ "Could not migrate record version:",
+ record.version,
+ "->",
+ this.version
+ );
+ }
+ break;
+ default:
+ throw new Error(
+ "Unknown credit card version to match: " + record.version
+ );
+ }
+ }
+
+ return super._ensureMatchingVersion(record);
+ }
+
+ /**
+ * Find a match credit card record in storage that is either exactly the same
+ * as the given record or a superset of the given record.
+ *
+ * See the comments in `getDuplicateRecords` to see the difference between
+ * `getDuplicateRecords` and `getMatchRecords`
+ *
+ * @param {object} record
+ * The credit card for match checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first matched record found in storage, null otherwise.
+ */
+ async *getMatchRecords(record) {
+ for await (const recordInStorage of this.getDuplicateRecords(record)) {
+ const fields = this.VALID_FIELDS.filter(f => f != "cc-number");
+ if (
+ fields.every(
+ field => !record[field] || record[field] == recordInStorage[field]
+ )
+ ) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Find a duplicate credit card record in the storage.
+ *
+ * A record is considered as a duplicate of another record when two records
+ * are the "same". This might be true even when some of their fields are
+ * different. For example, one record has the same credit card number but has
+ * different expiration date as the other record are still considered as
+ * "duplicate".
+ * This is different from `getMatchRecords`, which ensures all the fields with
+ * value in the the record is equal to the returned record.
+ *
+ * @param {object} record
+ * The credit card for duplication checking. please make sure the
+ * record is normalized.
+ * @returns {object}
+ * Return the first duplicated record found in storage, null otherwise.
+ */
+ async *getDuplicateRecords(record) {
+ if (!record["cc-number"]) {
+ return null;
+ }
+
+ for (const recordInStorage of this._data) {
+ if (recordInStorage.deleted) {
+ continue;
+ }
+
+ const decrypted = await lazy.OSKeyStore.decrypt(
+ recordInStorage["cc-number-encrypted"],
+ false
+ );
+
+ if (decrypted == record["cc-number"]) {
+ yield recordInStorage;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Merge new credit card into the specified record if cc-number is identical.
+ * (Note that credit card records always do non-strict merge.)
+ *
+ * @param {string} guid
+ * Indicates which credit card to merge.
+ * @param {object} creditCard
+ * The new credit card used to merge into the old one.
+ * @returns {boolean}
+ * Return true if credit card is merged into target with specific guid or false if not.
+ */
+ async mergeIfPossible(guid, creditCard) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+}
+
+export class FormAutofillStorageBase {
+ constructor(path) {
+ this._path = path;
+ this._initializePromise = null;
+ this.INTERNAL_FIELDS = INTERNAL_FIELDS;
+ }
+
+ get version() {
+ return STORAGE_SCHEMA_VERSION;
+ }
+
+ get addresses() {
+ return this.getAddresses();
+ }
+
+ get creditCards() {
+ return this.getCreditCards();
+ }
+
+ getAddresses() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ getCreditCards() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ /**
+ * Initialize storage to memory.
+ *
+ * @returns {Promise} When the operation finished successfully.
+ * @throws JavaScript exception.
+ */
+ initialize() {
+ if (!this._initializePromise) {
+ this._store = this._initializeStore();
+ this._initializePromise = this._store.load().then(() => {
+ let initializeAutofillRecords = [
+ this.addresses.initialize(),
+ this.creditCards.initialize(),
+ ];
+ return Promise.all(initializeAutofillRecords);
+ });
+ }
+ return this._initializePromise;
+ }
+
+ _initializeStore() {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ }
+
+ // For test only.
+ _saveImmediately() {
+ return this._store._save();
+ }
+
+ _finalize() {
+ return this._store.finalize();
+ }
+}