mirror of
				https://github.com/mozilla/gecko-dev.git
				synced 2025-11-04 02:09:05 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			2033 lines
		
	
	
	
		
			65 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			2033 lines
		
	
	
	
		
			65 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
/* 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,
 | 
						|
 *       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.)
 | 
						|
 *       given-name,
 | 
						|
 *       additional-name,
 | 
						|
 *       family-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 { AddressRecord } from "resource://gre/modules/shared/AddressRecord.sys.mjs";
 | 
						|
 | 
						|
const lazy = {};
 | 
						|
 | 
						|
ChromeUtils.defineESModuleGetters(lazy, {
 | 
						|
  AutofillTelemetry: "resource://gre/modules/shared/AutofillTelemetry.sys.mjs",
 | 
						|
  CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
 | 
						|
  CreditCardRecord: "resource://gre/modules/shared/CreditCardRecord.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://gre/modules/shared/PhoneNumber.sys.mjs",
 | 
						|
});
 | 
						|
 | 
						|
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 = [
 | 
						|
  "name",
 | 
						|
  "organization",
 | 
						|
  "street-address",
 | 
						|
  "address-level3",
 | 
						|
  "address-level2",
 | 
						|
  "address-level1",
 | 
						|
  "postal-code",
 | 
						|
  "country",
 | 
						|
  "tel",
 | 
						|
  "email",
 | 
						|
];
 | 
						|
 | 
						|
const VALID_ADDRESS_COMPUTED_FIELDS = [
 | 
						|
  "country-name",
 | 
						|
  ...AddressRecord.NAME_COMPONENTS,
 | 
						|
  ...AddressRecord.STREET_ADDRESS_COMPONENTS,
 | 
						|
  ...AddressRecord.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) {
 | 
						|
    if (topic == "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);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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) {
 | 
						|
      // The *-name fields, previously listed in VALID_FIELDS, have been moved to
 | 
						|
      // COMPUTED_FIELDS. By default, the sync payload includes only those fields in VALID_FIELDS.
 | 
						|
      // Excluding *-name fields from the sync payload would prevent older devices from
 | 
						|
      // synchronizing with newer devices. To maintain backward compatibility, keep those deprecated
 | 
						|
      // ields in the payload, ensuring that older devices can still sync with newer devices.
 | 
						|
      const fieldsToKeep = AddressRecord.NAME_COMPONENTS;
 | 
						|
      await this._stripComputedFields(clonedRecord, fieldsToKeep);
 | 
						|
    } 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) {
 | 
						|
          const fieldsToKeep = AddressRecord.NAME_COMPONENTS;
 | 
						|
          await this._stripComputedFields(record, fieldsToKeep);
 | 
						|
        } else {
 | 
						|
          this._recordReadProcessor(record);
 | 
						|
        }
 | 
						|
      })
 | 
						|
    );
 | 
						|
    return clonedRecords;
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Returns true if the data set is empty. If the `includeDeleted` option is set to true,
 | 
						|
   * it will also consider items that are marked as deleted.
 | 
						|
   *
 | 
						|
   * @param   {object}  [options={}] options
 | 
						|
   * @param   {boolean} [options.includeDeleted = false]
 | 
						|
   *                    Indicates whether to include deleted items in the check.
 | 
						|
   * @returns {boolean} Returns `true` if the data set is empty, otherwise `false`.
 | 
						|
   */
 | 
						|
  isEmpty({ includeDeleted = false } = {}) {
 | 
						|
    return !this._data.find(r => !r.deleted || includeDeleted);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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)) {
 | 
						|
      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;
 | 
						|
    }
 | 
						|
 | 
						|
    const originalNumFields = Object.keys(record).length;
 | 
						|
    await this.computeFields(record);
 | 
						|
    const hasNewComputedFields =
 | 
						|
      Object.keys(record).length != originalNumFields;
 | 
						|
 | 
						|
    hasChanges |= hasNewComputedFields;
 | 
						|
    return hasChanges;
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeRecord(record, preserveEmptyFields = false) {
 | 
						|
    this._normalizeFields(record);
 | 
						|
 | 
						|
    for (const 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];
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    const keys = Object.keys(record);
 | 
						|
    // By default we ensure there is always a country field, so if this record
 | 
						|
    // doesn't contain other fields, this is an empty record
 | 
						|
    if (!keys.length || (keys.length == 1 && keys[0] == "country")) {
 | 
						|
      throw new Error("Record contains no valid field.");
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * 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(record) {
 | 
						|
    return record.version < 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, fieldsToKeep = []) {
 | 
						|
    for (const field of this.VALID_COMPUTED_FIELDS) {
 | 
						|
      if (fieldsToKeep.includes(field)) {
 | 
						|
        continue;
 | 
						|
      }
 | 
						|
      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.
 | 
						|
  migrateRemoteRecord(_remoteRecord) {}
 | 
						|
}
 | 
						|
 | 
						|
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"];
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  _isMigrationNeeded(record) {
 | 
						|
    if (super._isMigrationNeeded(record)) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
 | 
						|
    if (
 | 
						|
      !record.name &&
 | 
						|
      (record["given-name"] ||
 | 
						|
        record["additional-name"] ||
 | 
						|
        record["family-name"])
 | 
						|
    ) {
 | 
						|
      return true;
 | 
						|
    }
 | 
						|
    return false;
 | 
						|
  }
 | 
						|
 | 
						|
  async _computeMigratedRecord(address) {
 | 
						|
    // Bug 1836438 - `name` field was moved from computed fields to valid fields.
 | 
						|
    if (
 | 
						|
      !address.name &&
 | 
						|
      (address["given-name"] ||
 | 
						|
        address["additional-name"] ||
 | 
						|
        address["family-name"])
 | 
						|
    ) {
 | 
						|
      address.name = lazy.FormAutofillNameUtils.joinNameParts({
 | 
						|
        given: address["given-name"] ?? "",
 | 
						|
        middle: address["additional-name"] ?? "",
 | 
						|
        family: address["family-name"] ?? "",
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    return super._computeMigratedRecord(address);
 | 
						|
  }
 | 
						|
 | 
						|
  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.
 | 
						|
 | 
						|
    if (!address.deleted) {
 | 
						|
      AddressRecord.computeFields(address);
 | 
						|
    }
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeFields(address) {
 | 
						|
    this._normalizeCountryFields(address);
 | 
						|
    this._normalizeNameFields(address);
 | 
						|
    this._normalizeAddressFields(address);
 | 
						|
    this._normalizeTelFields(address);
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeNameFields(address) {
 | 
						|
    if (
 | 
						|
      !address.name &&
 | 
						|
      (address["given-name"] ||
 | 
						|
        address["additional-name"] ||
 | 
						|
        address["family-name"])
 | 
						|
    ) {
 | 
						|
      address.name = lazy.FormAutofillNameUtils.joinNameParts({
 | 
						|
        given: address["given-name"] ?? "",
 | 
						|
        middle: address["additional-name"] ?? "",
 | 
						|
        family: address["family-name"] ?? "",
 | 
						|
      });
 | 
						|
    }
 | 
						|
 | 
						|
    delete address["given-name"];
 | 
						|
    delete address["additional-name"];
 | 
						|
    delete address["family-name"];
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeAddressFields(address) {
 | 
						|
    if (AddressRecord.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"] = AddressRecord.STREET_ADDRESS_COMPONENTS.map(
 | 
						|
          c => address[c]
 | 
						|
        )
 | 
						|
          .join("\n")
 | 
						|
          .replace(/\n+$/, "");
 | 
						|
      }
 | 
						|
    }
 | 
						|
    AddressRecord.STREET_ADDRESS_COMPONENTS.forEach(c => delete address[c]);
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeCountryFields(address) {
 | 
						|
    // When we can't identify the country code, it is possible because that the region exists
 | 
						|
    // in regionNames.properties but not in libaddressinput.
 | 
						|
    const country =
 | 
						|
      lazy.FormAutofillUtils.identifyCountryCode(
 | 
						|
        address.country || address["country-name"]
 | 
						|
      ) || address.country;
 | 
						|
 | 
						|
    // 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 {
 | 
						|
      address.country = FormAutofill.DEFAULT_REGION;
 | 
						|
    }
 | 
						|
 | 
						|
    delete address["country-name"];
 | 
						|
  }
 | 
						|
 | 
						|
  _normalizeTelFields(address) {
 | 
						|
    if (address.tel || AddressRecord.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;
 | 
						|
      }
 | 
						|
    }
 | 
						|
    AddressRecord.TEL_COMPONENTS.forEach(c => delete address[c]);
 | 
						|
  }
 | 
						|
 | 
						|
  /**
 | 
						|
   * Migrate the remote record to the expected format.
 | 
						|
   *
 | 
						|
   * @param {object} remoteRecord The remote record.
 | 
						|
   */
 | 
						|
  migrateRemoteRecord(remoteRecord) {
 | 
						|
    // When a remote record lacks the `name` field but includes any `*-name` fields, we can
 | 
						|
    // assume that the record originates from an older device. This is because even if an older
 | 
						|
    // device pulls the `name` field from a newer record from the sync server, the `name` field,
 | 
						|
    // being a computed field for an older device, will always be stripped.
 | 
						|
 | 
						|
    // If the remote record comes from an older device, we compare the `*-name` fields in the
 | 
						|
    // remote record with those in the corresponding local record. If the values of the `*-name`
 | 
						|
    // fields differ, it indicates that the remote record has updated these fields. If the
 | 
						|
    // values are the same, we replace the name field of the remote record with the local
 | 
						|
    // name field to ensure the completeness of the name field when reconciling.
 | 
						|
    //
 | 
						|
    // Here is an example:
 | 
						|
    // Assume the local record is {"name": "Mr. John Doe"}. If an updated remote record
 | 
						|
    // has {"given-name": "John", "family-name": "Doe"}, we will NOT join the `*-name` fields
 | 
						|
    // and replace the local `name` field with "John Doe". This allows us to retain the complete
 | 
						|
    // name - "Mr. John Doe".
 | 
						|
    // However, if the updated remote record has {"given-name": "Jane", "family-name": "Poe"},
 | 
						|
    // we will rebuild it and replace the local `name` field with "Jane Poe".
 | 
						|
    if (
 | 
						|
      !("name" in remoteRecord) &&
 | 
						|
      AddressRecord.NAME_COMPONENTS.some(c => c in remoteRecord)
 | 
						|
    ) {
 | 
						|
      const localRecord = this._findByGUID(remoteRecord.guid);
 | 
						|
      if (
 | 
						|
        localRecord &&
 | 
						|
        AddressRecord.NAME_COMPONENTS.every(
 | 
						|
          c => remoteRecord[c] == localRecord[c]
 | 
						|
        )
 | 
						|
      ) {
 | 
						|
        remoteRecord.name = localRecord.name;
 | 
						|
      } else {
 | 
						|
        remoteRecord.name = lazy.FormAutofillNameUtils.joinNameParts({
 | 
						|
          given: remoteRecord["given-name"],
 | 
						|
          middle: remoteRecord["additional-name"],
 | 
						|
          family: remoteRecord["family-name"],
 | 
						|
        });
 | 
						|
      }
 | 
						|
    }
 | 
						|
 | 
						|
    // To enable new devices to sync name field changes with older devices, we still
 | 
						|
    // include the computed *-name fields in the sync payload while uploading.
 | 
						|
    // This also means that the incoming remote record will also contain *-name fields.
 | 
						|
    // However, since the autofill storage does not expect remote records to contain
 | 
						|
    // computed fields while merging, we remove them from the remote record.
 | 
						|
    AddressRecord.NAME_COMPONENTS.forEach(f => delete remoteRecord[f]);
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
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)) {
 | 
						|
      const 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(record) {
 | 
						|
    return (
 | 
						|
      // version 4 is deprecated and is rolled back to version 3
 | 
						|
      record.version == 4 || record.version < 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) {
 | 
						|
    lazy.CreditCardRecord.normalizeFields(creditCard);
 | 
						|
  }
 | 
						|
 | 
						|
  _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;
 | 
						|
  }
 | 
						|
}
 | 
						|
 | 
						|
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();
 | 
						|
  }
 | 
						|
}
 |