browser/components/migration/ChromeProfileMigrator.jsm
author Emma Malysz <emalysz@mozilla.com>
Wed, 11 Dec 2019 00:27:19 +0000
changeset 571928 23c591259da010fe4857703af04a6b90458f91de
parent 558365 84da7e2b386893332dda2fae1204743c76db43bc
child 576005 b329e8beb91fb010c0e088625cd6b6ef381af06d
permissions -rw-r--r--
Bug 1601094, rename the remaining .xul files in browser/ to .xhtml r=marionette-reviewers,whimboo,mossop Differential Revision: https://phabricator.services.mozilla.com/D55751

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
 * vim: sw=2 ts=2 sts=2 et */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

"use strict";

const AUTH_TYPE = {
  SCHEME_HTML: 0,
  SCHEME_BASIC: 1,
  SCHEME_DIGEST: 2,
};

const { AppConstants } = ChromeUtils.import(
  "resource://gre/modules/AppConstants.jsm"
);
const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { ChromeMigrationUtils } = ChromeUtils.import(
  "resource:///modules/ChromeMigrationUtils.jsm"
);
const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
  "resource:///modules/MigrationUtils.jsm"
);

ChromeUtils.defineModuleGetter(
  this,
  "PlacesUtils",
  "resource://gre/modules/PlacesUtils.jsm"
);

/**
 * Converts an array of chrome bookmark objects into one our own places code
 * understands.
 *
 * @param   items
 *          bookmark items to be inserted on this parent
 * @param   errorAccumulator
 *          function that gets called with any errors thrown so we don't drop them on the floor.
 */
function convertBookmarks(items, errorAccumulator) {
  let itemsToInsert = [];
  for (let item of items) {
    try {
      if (item.type == "url") {
        if (item.url.trim().startsWith("chrome:")) {
          // Skip invalid chrome URIs. Creating an actual URI always reports
          // messages to the console, so we avoid doing that.
          continue;
        }
        itemsToInsert.push({ url: item.url, title: item.name });
      } else if (item.type == "folder") {
        let folderItem = {
          type: PlacesUtils.bookmarks.TYPE_FOLDER,
          title: item.name,
        };
        folderItem.children = convertBookmarks(item.children, errorAccumulator);
        itemsToInsert.push(folderItem);
      }
    } catch (ex) {
      Cu.reportError(ex);
      errorAccumulator(ex);
    }
  }
  return itemsToInsert;
}

function ChromeProfileMigrator() {
  this._chromeUserDataPathSuffix = "Chrome";
}

ChromeProfileMigrator.prototype = Object.create(MigratorPrototype);

ChromeProfileMigrator.prototype._keychainServiceName = "Chrome Safe Storage";
ChromeProfileMigrator.prototype._keychainAccountName = "Chrome";

ChromeProfileMigrator.prototype._getChromeUserDataPathIfExists = async function() {
  if (this._chromeUserDataPath) {
    return this._chromeUserDataPath;
  }
  let path = ChromeMigrationUtils.getDataPath(this._chromeUserDataPathSuffix);
  let exists = await OS.File.exists(path);
  if (exists) {
    this._chromeUserDataPath = path;
  } else {
    this._chromeUserDataPath = null;
  }
  return this._chromeUserDataPath;
};

ChromeProfileMigrator.prototype.getResources = async function Chrome_getResources(
  aProfile
) {
  let chromeUserDataPath = await this._getChromeUserDataPathIfExists();
  if (chromeUserDataPath) {
    let profileFolder = OS.Path.join(chromeUserDataPath, aProfile.id);
    if (await OS.File.exists(profileFolder)) {
      let possibleResourcePromises = [
        GetBookmarksResource(profileFolder),
        GetHistoryResource(profileFolder),
        GetCookiesResource(profileFolder),
      ];
      if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
        possibleResourcePromises.push(
          this._GetPasswordsResource(profileFolder)
        );
      }
      let possibleResources = await Promise.all(possibleResourcePromises);
      return possibleResources.filter(r => r != null);
    }
  }
  return [];
};

ChromeProfileMigrator.prototype.getLastUsedDate = async function Chrome_getLastUsedDate() {
  let sourceProfiles = await this.getSourceProfiles();
  let chromeUserDataPath = await this._getChromeUserDataPathIfExists();
  if (!chromeUserDataPath) {
    return new Date(0);
  }
  let datePromises = sourceProfiles.map(async profile => {
    let basePath = OS.Path.join(chromeUserDataPath, profile.id);
    let fileDatePromises = ["Bookmarks", "History", "Cookies"].map(
      async leafName => {
        let path = OS.Path.join(basePath, leafName);
        let info = await OS.File.stat(path).catch(() => null);
        return info ? info.lastModificationDate : 0;
      }
    );
    let dates = await Promise.all(fileDatePromises);
    return Math.max(...dates);
  });
  let datesOuter = await Promise.all(datePromises);
  datesOuter.push(0);
  return new Date(Math.max(...datesOuter));
};

ChromeProfileMigrator.prototype.getSourceProfiles = async function Chrome_getSourceProfiles() {
  if ("__sourceProfiles" in this) {
    return this.__sourceProfiles;
  }

  let chromeUserDataPath = await this._getChromeUserDataPathIfExists();
  if (!chromeUserDataPath) {
    return [];
  }

  let profiles = [];
  try {
    let localState = await ChromeMigrationUtils.getLocalState(
      this._chromeUserDataPathSuffix
    );
    let info_cache = localState.profile.info_cache;
    for (let profileFolderName in info_cache) {
      profiles.push({
        id: profileFolderName,
        name: info_cache[profileFolderName].name || profileFolderName,
      });
    }
  } catch (e) {
    Cu.reportError("Error detecting Chrome profiles: " + e);
    // If we weren't able to detect any profiles above, fallback to the Default profile.
    let defaultProfilePath = OS.Path.join(chromeUserDataPath, "Default");
    if (await OS.File.exists(defaultProfilePath)) {
      profiles = [
        {
          id: "Default",
          name: "Default",
        },
      ];
    }
  }

  let profileResources = await Promise.all(
    profiles.map(async profile => ({
      profile,
      resources: await this.getResources(profile),
    }))
  );

  // Only list profiles from which any data can be imported
  this.__sourceProfiles = profileResources
    .filter(({ resources }) => {
      return resources && !!resources.length;
    }, this)
    .map(({ profile }) => profile);
  return this.__sourceProfiles;
};

Object.defineProperty(ChromeProfileMigrator.prototype, "sourceLocked", {
  get: function Chrome_sourceLocked() {
    // There is an exclusive lock on some SQLite databases. Assume they are locked for now.
    return true;
  },
});

async function GetBookmarksResource(aProfileFolder) {
  let bookmarksPath = OS.Path.join(aProfileFolder, "Bookmarks");
  if (!(await OS.File.exists(bookmarksPath))) {
    return null;
  }

  return {
    type: MigrationUtils.resourceTypes.BOOKMARKS,

    migrate(aCallback) {
      return (async function() {
        let gotErrors = false;
        let errorGatherer = function() {
          gotErrors = true;
        };
        // Parse Chrome bookmark file that is JSON format
        let bookmarkJSON = await OS.File.read(bookmarksPath, {
          encoding: "UTF-8",
        });
        let roots = JSON.parse(bookmarkJSON).roots;

        // Importing bookmark bar items
        if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) {
          // Toolbar
          let parentGuid = PlacesUtils.bookmarks.toolbarGuid;
          let bookmarks = convertBookmarks(
            roots.bookmark_bar.children,
            errorGatherer
          );
          if (!MigrationUtils.isStartupMigration) {
            parentGuid = await MigrationUtils.createImportedBookmarksFolder(
              "Chrome",
              parentGuid
            );
          }
          await MigrationUtils.insertManyBookmarksWrapper(
            bookmarks,
            parentGuid
          );
        }

        // Importing bookmark menu items
        if (roots.other.children && roots.other.children.length) {
          // Bookmark menu
          let parentGuid = PlacesUtils.bookmarks.menuGuid;
          let bookmarks = convertBookmarks(roots.other.children, errorGatherer);
          if (!MigrationUtils.isStartupMigration) {
            parentGuid = await MigrationUtils.createImportedBookmarksFolder(
              "Chrome",
              parentGuid
            );
          }
          await MigrationUtils.insertManyBookmarksWrapper(
            bookmarks,
            parentGuid
          );
        }
        if (gotErrors) {
          throw new Error("The migration included errors.");
        }
      })().then(() => aCallback(true), () => aCallback(false));
    },
  };
}

async function GetHistoryResource(aProfileFolder) {
  let historyPath = OS.Path.join(aProfileFolder, "History");
  if (!(await OS.File.exists(historyPath))) {
    return null;
  }

  return {
    type: MigrationUtils.resourceTypes.HISTORY,

    migrate(aCallback) {
      (async function() {
        const MAX_AGE_IN_DAYS = Services.prefs.getIntPref(
          "browser.migrate.chrome.history.maxAgeInDays"
        );
        const LIMIT = Services.prefs.getIntPref(
          "browser.migrate.chrome.history.limit"
        );

        let query =
          "SELECT url, title, last_visit_time, typed_count FROM urls WHERE hidden = 0";
        if (MAX_AGE_IN_DAYS) {
          let maxAge = ChromeMigrationUtils.dateToChromeTime(
            Date.now() - MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000
          );
          query += " AND last_visit_time > " + maxAge;
        }
        if (LIMIT) {
          query += " ORDER BY last_visit_time DESC LIMIT " + LIMIT;
        }

        let rows = await MigrationUtils.getRowsFromDBWithoutLocks(
          historyPath,
          "Chrome history",
          query
        );
        let pageInfos = [];
        let fallbackVisitDate = new Date();
        for (let row of rows) {
          try {
            // if having typed_count, we changes transition type to typed.
            let transition = PlacesUtils.history.TRANSITIONS.LINK;
            if (row.getResultByName("typed_count") > 0) {
              transition = PlacesUtils.history.TRANSITIONS.TYPED;
            }

            pageInfos.push({
              title: row.getResultByName("title"),
              url: new URL(row.getResultByName("url")),
              visits: [
                {
                  transition,
                  date: ChromeMigrationUtils.chromeTimeToDate(
                    row.getResultByName("last_visit_time"),
                    fallbackVisitDate
                  ),
                },
              ],
            });
          } catch (e) {
            Cu.reportError(e);
          }
        }

        if (pageInfos.length) {
          await MigrationUtils.insertVisitsWrapper(pageInfos);
        }
      })().then(
        () => {
          aCallback(true);
        },
        ex => {
          Cu.reportError(ex);
          aCallback(false);
        }
      );
    },
  };
}

async function GetCookiesResource(aProfileFolder) {
  let cookiesPath = OS.Path.join(aProfileFolder, "Cookies");
  if (!(await OS.File.exists(cookiesPath))) {
    return null;
  }

  return {
    type: MigrationUtils.resourceTypes.COOKIES,

    async migrate(aCallback) {
      // Get columns names and set is_sceure, is_httponly fields accordingly.
      let columns = await MigrationUtils.getRowsFromDBWithoutLocks(
        cookiesPath,
        "Chrome cookies",
        `PRAGMA table_info(cookies)`
      ).catch(ex => {
        Cu.reportError(ex);
        aCallback(false);
      });
      // If the promise was rejected we will have already called aCallback,
      // so we can just return here.
      if (!columns) {
        return;
      }
      columns = columns.map(c => c.getResultByName("name"));
      let isHttponly = columns.includes("is_httponly")
        ? "is_httponly"
        : "httponly";
      let isSecure = columns.includes("is_secure") ? "is_secure" : "secure";

      // We don't support decrypting cookies yet so only import plaintext ones.
      let rows = await MigrationUtils.getRowsFromDBWithoutLocks(
        cookiesPath,
        "Chrome cookies",
        `SELECT host_key, name, value, path, expires_utc, ${isSecure}, ${isHttponly}, encrypted_value
        FROM cookies
        WHERE length(encrypted_value) = 0`
      ).catch(ex => {
        Cu.reportError(ex);
        aCallback(false);
      });
      // If the promise was rejected we will have already called aCallback,
      // so we can just return here.
      if (!rows) {
        return;
      }

      let fallbackExpiryDate = 0;
      for (let row of rows) {
        let host_key = row.getResultByName("host_key");
        if (host_key.match(/^\./)) {
          // 1st character of host_key may be ".", so we have to remove it
          host_key = host_key.substr(1);
        }

        try {
          let expiresUtc =
            ChromeMigrationUtils.chromeTimeToDate(
              row.getResultByName("expires_utc"),
              fallbackExpiryDate
            ) / 1000;
          // No point adding cookies that don't have a valid expiry.
          if (!expiresUtc) {
            continue;
          }
          Services.cookies.add(
            host_key,
            row.getResultByName("path"),
            row.getResultByName("name"),
            row.getResultByName("value"),
            row.getResultByName(isSecure),
            row.getResultByName(isHttponly),
            false,
            parseInt(expiresUtc),
            {},
            Ci.nsICookie.SAMESITE_NONE
          );
        } catch (e) {
          Cu.reportError(e);
        }
      }
      aCallback(true);
    },
  };
}

ChromeProfileMigrator.prototype._GetPasswordsResource = async function(
  aProfileFolder
) {
  let loginPath = OS.Path.join(aProfileFolder, "Login Data");
  if (!(await OS.File.exists(loginPath))) {
    return null;
  }

  let {
    _keychainServiceName,
    _keychainAccountName,
    _keychainMockPassphrase = null,
  } = this;

  return {
    type: MigrationUtils.resourceTypes.PASSWORDS,

    async migrate(aCallback) {
      let rows = await MigrationUtils.getRowsFromDBWithoutLocks(
        loginPath,
        "Chrome passwords",
        `SELECT origin_url, action_url, username_element, username_value,
        password_element, password_value, signon_realm, scheme, date_created,
        times_used FROM logins WHERE blacklisted_by_user = 0`
      ).catch(ex => {
        Cu.reportError(ex);
        aCallback(false);
      });
      // If the promise was rejected we will have already called aCallback,
      // so we can just return here.
      if (!rows) {
        return;
      }

      let crypto;
      try {
        if (AppConstants.platform == "win") {
          let { OSCrypto } = ChromeUtils.import(
            "resource://gre/modules/OSCrypto.jsm"
          );
          crypto = new OSCrypto();
        } else if (AppConstants.platform == "macosx") {
          let { ChromeMacOSLoginCrypto } = ChromeUtils.import(
            "resource:///modules/ChromeMacOSLoginCrypto.jsm"
          );
          crypto = new ChromeMacOSLoginCrypto(
            _keychainServiceName,
            _keychainAccountName,
            _keychainMockPassphrase
          );
        } else {
          aCallback(false);
          return;
        }
      } catch (ex) {
        // Handle the user canceling Keychain access or other OSCrypto errors.
        Cu.reportError(ex);
        aCallback(false);
        return;
      }

      let logins = [];
      let fallbackCreationDate = new Date();
      for (let row of rows) {
        try {
          let origin_url = NetUtil.newURI(row.getResultByName("origin_url"));
          // Ignore entries for non-http(s)/ftp URLs because we likely can't
          // use them anyway.
          const kValidSchemes = new Set(["https", "http", "ftp"]);
          if (!kValidSchemes.has(origin_url.scheme)) {
            continue;
          }
          let loginInfo = {
            username: row.getResultByName("username_value"),
            password: await crypto.decryptData(
              crypto.arrayToString(row.getResultByName("password_value")),
              null
            ),
            origin: origin_url.prePath,
            formActionOrigin: null,
            httpRealm: null,
            usernameElement: row.getResultByName("username_element"),
            passwordElement: row.getResultByName("password_element"),
            timeCreated: ChromeMigrationUtils.chromeTimeToDate(
              row.getResultByName("date_created") + 0,
              fallbackCreationDate
            ).getTime(),
            timesUsed: row.getResultByName("times_used") + 0,
          };

          switch (row.getResultByName("scheme")) {
            case AUTH_TYPE.SCHEME_HTML:
              let action_url = row.getResultByName("action_url");
              if (!action_url) {
                // If there is no action_url, store the wildcard "" value.
                // See the `formActionOrigin` IDL comments.
                loginInfo.formActionOrigin = "";
                break;
              }
              let action_uri = NetUtil.newURI(action_url);
              if (!kValidSchemes.has(action_uri.scheme)) {
                continue; // This continues the outer for loop.
              }
              loginInfo.formActionOrigin = action_uri.prePath;
              break;
            case AUTH_TYPE.SCHEME_BASIC:
            case AUTH_TYPE.SCHEME_DIGEST:
              // signon_realm format is URIrealm, so we need remove URI
              loginInfo.httpRealm = row
                .getResultByName("signon_realm")
                .substring(loginInfo.origin.length + 1);
              break;
            default:
              throw new Error(
                "Login data scheme type not supported: " +
                  row.getResultByName("scheme")
              );
          }
          logins.push(loginInfo);
        } catch (e) {
          Cu.reportError(e);
        }
      }
      try {
        if (logins.length) {
          await MigrationUtils.insertLoginsWrapper(logins);
        }
      } catch (e) {
        Cu.reportError(e);
      }
      if (crypto.finalize) {
        crypto.finalize();
      }
      aCallback(true);
    },
  };
};

ChromeProfileMigrator.prototype.classDescription = "Chrome Profile Migrator";
ChromeProfileMigrator.prototype.contractID =
  "@mozilla.org/profile/migrator;1?app=browser&type=chrome";
ChromeProfileMigrator.prototype.classID = Components.ID(
  "{4cec1de4-1671-4fc3-a53e-6c539dc77a26}"
);

/**
 *  Chromium migration
 **/
function ChromiumProfileMigrator() {
  this._chromeUserDataPathSuffix = "Chromium";
  this._keychainServiceName = "Chromium Safe Storage";
  this._keychainAccountName = "Chromium";
}

ChromiumProfileMigrator.prototype = Object.create(
  ChromeProfileMigrator.prototype
);
ChromiumProfileMigrator.prototype.classDescription =
  "Chromium Profile Migrator";
ChromiumProfileMigrator.prototype.contractID =
  "@mozilla.org/profile/migrator;1?app=browser&type=chromium";
ChromiumProfileMigrator.prototype.classID = Components.ID(
  "{8cece922-9720-42de-b7db-7cef88cb07ca}"
);

var EXPORTED_SYMBOLS = ["ChromeProfileMigrator", "ChromiumProfileMigrator"];

/**
 * Chrome Canary
 * Not available on Linux
 **/
function CanaryProfileMigrator() {
  this._chromeUserDataPathSuffix = "Canary";
}
CanaryProfileMigrator.prototype = Object.create(
  ChromeProfileMigrator.prototype
);
CanaryProfileMigrator.prototype.classDescription =
  "Chrome Canary Profile Migrator";
CanaryProfileMigrator.prototype.contractID =
  "@mozilla.org/profile/migrator;1?app=browser&type=canary";
CanaryProfileMigrator.prototype.classID = Components.ID(
  "{4bf85aa5-4e21-46ca-825f-f9c51a5e8c76}"
);

if (AppConstants.platform == "win" || AppConstants.platform == "macosx") {
  EXPORTED_SYMBOLS.push("CanaryProfileMigrator");
}

/**
 * Chrome Dev / Unstable and Beta. Only separate from `regular` chrome on Linux
 */
if (AppConstants.platform != "win" && AppConstants.platform != "macosx") {
  function ChromeDevMigrator() {
    this._chromeUserDataPathSuffix = "Chrome Dev";
  }
  ChromeDevMigrator.prototype = Object.create(ChromeProfileMigrator.prototype);
  ChromeDevMigrator.prototype.classDescription = "Chrome Dev Profile Migrator";
  ChromeDevMigrator.prototype.contractID =
    "@mozilla.org/profile/migrator;1?app=browser&type=chrome-dev";
  ChromeDevMigrator.prototype.classID = Components.ID(
    "{7370a02a-4886-42c3-a4ec-d48c726ec30a}"
  );

  function ChromeBetaMigrator() {
    this._chromeUserDataPathSuffix = "Chrome Beta";
  }
  ChromeBetaMigrator.prototype = Object.create(ChromeProfileMigrator.prototype);
  ChromeBetaMigrator.prototype.classDescription =
    "Chrome Beta Profile Migrator";
  ChromeBetaMigrator.prototype.contractID =
    "@mozilla.org/profile/migrator;1?app=browser&type=chrome-beta";
  ChromeBetaMigrator.prototype.classID = Components.ID(
    "{47f75963-840b-4950-a1f0-d9c1864f8b8e}"
  );

  EXPORTED_SYMBOLS.push("ChromeDevMigrator", "ChromeBetaMigrator");
}