summaryrefslogtreecommitdiffstats
path: root/browser/components/migration
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/migration')
-rw-r--r--browser/components/migration/.eslintrc.js42
-rw-r--r--browser/components/migration/360seProfileMigrator.jsm388
-rw-r--r--browser/components/migration/ChromeMacOSLoginCrypto.jsm185
-rw-r--r--browser/components/migration/ChromeMigrationUtils.jsm449
-rw-r--r--browser/components/migration/ChromeProfileMigrator.jsm757
-rw-r--r--browser/components/migration/ChromeWindowsLoginCrypto.jsm181
-rw-r--r--browser/components/migration/ESEDBReader.jsm810
-rw-r--r--browser/components/migration/EdgeProfileMigrator.jsm612
-rw-r--r--browser/components/migration/FirefoxProfileMigrator.jsm383
-rw-r--r--browser/components/migration/IEProfileMigrator.jsm416
-rw-r--r--browser/components/migration/MSMigrationUtils.jsm1041
-rw-r--r--browser/components/migration/MigrationUtils.jsm1326
-rw-r--r--browser/components/migration/ProfileMigrator.jsm21
-rw-r--r--browser/components/migration/SafariProfileMigrator.jsm535
-rw-r--r--browser/components/migration/components.conf114
-rw-r--r--browser/components/migration/content/aboutWelcomeBack.xhtml74
-rw-r--r--browser/components/migration/content/migration.js658
-rw-r--r--browser/components/migration/content/migration.xhtml104
-rw-r--r--browser/components/migration/jar.mn8
-rw-r--r--browser/components/migration/moz.build67
-rw-r--r--browser/components/migration/nsIBrowserProfileMigrator.idl73
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.cpp116
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.h39
-rw-r--r--browser/components/migration/nsIKeychainMigrationUtils.idl12
-rw-r--r--browser/components/migration/nsKeychainMigrationUtils.h23
-rw-r--r--browser/components/migration/nsKeychainMigrationUtils.mm62
-rw-r--r--browser/components/migration/nsWindowsMigrationUtils.h33
-rw-r--r--browser/components/migration/tests/marionette/manifest.ini4
-rw-r--r--browser/components/migration/tests/marionette/test_refresh_firefox.py698
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State5
-rw-r--r--browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.dbbin0 -> 6144 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini17
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookiesbin0 -> 10240 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json9
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json10
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json9
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json5
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json5
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMasterbin0 -> 118784 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State22
-rw-r--r--browser/components/migration/tests/unit/Library/Safari/Bookmarks.plistbin0 -> 1956 bytes
-rw-r--r--browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Databin0 -> 24576 bytes
-rw-r--r--browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State22
-rw-r--r--browser/components/migration/tests/unit/head_migration.js109
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp38
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/moz.build18
-rw-r--r--browser/components/migration/tests/unit/test_360se_bookmarks.js75
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils.js84
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js114
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_bookmarks.js198
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_cookies.js72
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_history.js206
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_passwords.js379
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js83
-rw-r--r--browser/components/migration/tests/unit/test_Edge_db_migration.js791
-rw-r--r--browser/components/migration/tests/unit/test_IE7_passwords.js1369
-rw-r--r--browser/components/migration/tests/unit/test_IE_bookmarks.js70
-rw-r--r--browser/components/migration/tests/unit/test_IE_cookies.js149
-rw-r--r--browser/components/migration/tests/unit/test_IE_history.js50
-rw-r--r--browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js27
-rw-r--r--browser/components/migration/tests/unit/test_Safari_bookmarks.js105
-rw-r--r--browser/components/migration/tests/unit/test_fx_telemetry.js265
-rw-r--r--browser/components/migration/tests/unit/xpcshell.ini40
67 files changed, 13578 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js
new file mode 100644
index 0000000000..a0a0238764
--- /dev/null
+++ b/browser/components/migration/.eslintrc.js
@@ -0,0 +1,42 @@
+/* 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";
+
+module.exports = {
+ rules: {
+ "block-scoped-var": "error",
+ complexity: ["error", { max: 22 }],
+ "max-nested-callbacks": ["error", 3],
+ "no-extend-native": "error",
+ "no-fallthrough": [
+ "error",
+ {
+ commentPattern:
+ ".*[Ii]ntentional(?:ly)?\\s+fall(?:ing)?[\\s-]*through.*",
+ },
+ ],
+ "no-multi-str": "error",
+ "no-return-assign": "error",
+ "no-shadow": "error",
+ "no-unused-vars": ["error", { args: "after-used", vars: "all" }],
+ strict: ["error", "global"],
+ yoda: "error",
+ },
+
+ overrides: [
+ {
+ files: "tests/unit/head*.js",
+ rules: {
+ "no-unused-vars": [
+ "error",
+ {
+ args: "none",
+ vars: "local",
+ },
+ ],
+ },
+ },
+ ],
+};
diff --git a/browser/components/migration/360seProfileMigrator.jsm b/browser/components/migration/360seProfileMigrator.jsm
new file mode 100644
index 0000000000..940236eff9
--- /dev/null
+++ b/browser/components/migration/360seProfileMigrator.jsm
@@ -0,0 +1,388 @@
+/* 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 { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+const { FileUtils } = ChromeUtils.import(
+ "resource://gre/modules/FileUtils.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUIUtils",
+ "resource:///modules/PlacesUIUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Sqlite",
+ "resource://gre/modules/Sqlite.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const kBookmarksFileName = "360sefav.db";
+
+function copyToTempUTF8File(file, charset) {
+ let inputStream = Cc[
+ "@mozilla.org/network/file-input-stream;1"
+ ].createInstance(Ci.nsIFileInputStream);
+ inputStream.init(file, -1, -1, 0);
+ let inputStr = NetUtil.readInputStreamToString(
+ inputStream,
+ inputStream.available(),
+ { charset }
+ );
+
+ // Use random to reduce the likelihood of a name collision in createUnique.
+ let rand = Math.floor(Math.random() * Math.pow(2, 15));
+ let leafName = "mozilla-temp-" + rand;
+ let tempUTF8File = FileUtils.getFile(
+ "TmpD",
+ ["mozilla-temp-files", leafName],
+ true
+ );
+ tempUTF8File.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+
+ let out = FileUtils.openAtomicFileOutputStream(tempUTF8File);
+ try {
+ let bufferedOut = Cc[
+ "@mozilla.org/network/buffered-output-stream;1"
+ ].createInstance(Ci.nsIBufferedOutputStream);
+ bufferedOut.init(out, 4096);
+ try {
+ let converterOut = Cc[
+ "@mozilla.org/intl/converter-output-stream;1"
+ ].createInstance(Ci.nsIConverterOutputStream);
+ converterOut.init(bufferedOut, "utf-8");
+ try {
+ converterOut.writeString(inputStr || "");
+ bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish();
+ } finally {
+ converterOut.close();
+ }
+ } finally {
+ bufferedOut.close();
+ }
+ } finally {
+ out.close();
+ }
+
+ return tempUTF8File;
+}
+
+function parseINIStrings(file) {
+ let factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].getService(
+ Ci.nsIINIParserFactory
+ );
+ let parser = factory.createINIParser(file);
+ let obj = {};
+ for (let section of parser.getSections()) {
+ obj[section] = {};
+
+ for (let key of parser.getKeys(section)) {
+ obj[section][key] = parser.getString(section, key);
+ }
+ }
+ return obj;
+}
+
+function getHash(aStr) {
+ // return the two-digit hexadecimal code for a byte
+ let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2);
+
+ let hasher = Cc["@mozilla.org/security/hash;1"].createInstance(
+ Ci.nsICryptoHash
+ );
+ hasher.init(Ci.nsICryptoHash.MD5);
+ let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stringStream.data = aStr;
+ hasher.updateFromStream(stringStream, -1);
+
+ // convert the binary hash data to a hex string.
+ let binary = hasher.finish(false);
+ return Array.from(binary, (c, i) => toHexString(binary.charCodeAt(i)))
+ .join("")
+ .toLowerCase();
+}
+
+function Bookmarks(aProfileFolder) {
+ let file = aProfileFolder.clone();
+ file.append(kBookmarksFileName);
+
+ this._file = file;
+}
+Bookmarks.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ get exists() {
+ return this._file.exists() && this._file.isReadable();
+ },
+
+ migrate(aCallback) {
+ return (async () => {
+ let folderMap = new Map();
+ let toolbarBMs = [];
+
+ let connection = await Sqlite.openConnection({
+ path: this._file.path,
+ });
+
+ let histogramBookmarkRoots = 0;
+ try {
+ let rows = await connection.execute(
+ `WITH RECURSIVE
+ bookmark(id, parent_id, is_folder, title, url, pos) AS (
+ VALUES(0, -1, 1, '', '', 0)
+ UNION
+ SELECT f.id, f.parent_id, f.is_folder, f.title, f.url, f.pos
+ FROM tb_fav AS f
+ JOIN bookmark AS b ON f.parent_id = b.id
+ ORDER BY f.pos ASC
+ )
+ SELECT id, parent_id, is_folder, title, url FROM bookmark WHERE id`
+ );
+
+ for (let row of rows) {
+ let id = parseInt(row.getResultByName("id"), 10);
+ let parent_id = parseInt(row.getResultByName("parent_id"), 10);
+ let is_folder = parseInt(row.getResultByName("is_folder"), 10);
+ let title = row.getResultByName("title");
+ let url = row.getResultByName("url");
+
+ let bmToInsert;
+
+ if (is_folder) {
+ bmToInsert = {
+ children: [],
+ title,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ };
+ folderMap.set(id, bmToInsert);
+ } else {
+ try {
+ new URL(url);
+ } catch (ex) {
+ Cu.reportError(
+ `Ignoring ${url} when importing from 360se because of exception: ${ex}`
+ );
+ continue;
+ }
+
+ bmToInsert = {
+ title,
+ url,
+ };
+ }
+
+ if (folderMap.has(parent_id)) {
+ folderMap.get(parent_id).children.push(bmToInsert);
+ } else if (parent_id === 0) {
+ toolbarBMs.push(bmToInsert);
+ }
+ }
+ } finally {
+ await connection.close();
+ }
+
+ if (toolbarBMs.length) {
+ histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ let parentGuid = PlacesUtils.bookmarks.toolbarGuid;
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(
+ PlacesUtils.bookmarks.toolbarGuid
+ ) > PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ parentGuid = await MigrationUtils.createImportedBookmarksFolder(
+ "360se",
+ parentGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid);
+ PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration();
+ }
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS")
+ .add("360se", histogramBookmarkRoots);
+ })().then(
+ () => aCallback(true),
+ e => {
+ Cu.reportError(e);
+ aCallback(false);
+ }
+ );
+ },
+};
+
+function Qihoo360seProfileMigrator() {
+ let paths = [
+ // for v6 and above
+ {
+ users: ["360se6", "apps", "data", "users"],
+ defaultUser: "default",
+ },
+ // for earlier versions
+ {
+ users: ["360se"],
+ defaultUser: "data",
+ },
+ ];
+ this._usersDir = null;
+ this._defaultUserPath = null;
+ for (let path of paths) {
+ let usersDir = FileUtils.getDir("AppData", path.users, false);
+ if (usersDir.exists()) {
+ this._usersDir = usersDir;
+ this._defaultUserPath = path.defaultUser;
+ break;
+ }
+ }
+}
+
+Qihoo360seProfileMigrator.prototype = Object.create(MigratorPrototype);
+
+Qihoo360seProfileMigrator.prototype.getSourceProfiles = function() {
+ if ("__sourceProfiles" in this) {
+ return this.__sourceProfiles;
+ }
+
+ if (!this._usersDir) {
+ this.__sourceProfiles = [];
+ return this.__sourceProfiles;
+ }
+
+ let profiles = [];
+ let noLoggedInUser = true;
+ try {
+ let loginIni = this._usersDir.clone();
+ loginIni.append("login.ini");
+ if (!loginIni.exists()) {
+ throw new Error("360 Secure Browser's 'login.ini' does not exist.");
+ }
+ if (!loginIni.isReadable()) {
+ throw new Error(
+ "360 Secure Browser's 'login.ini' file could not be read."
+ );
+ }
+
+ let loginIniInUtf8 = copyToTempUTF8File(loginIni, "GBK");
+ let loginIniObj = parseINIStrings(loginIniInUtf8);
+ try {
+ loginIniInUtf8.remove(false);
+ } catch (ex) {}
+
+ let nowLoginEmail = loginIniObj.NowLogin && loginIniObj.NowLogin.email;
+
+ /*
+ * NowLogin section may:
+ * 1. be missing or without email, before any user logs in.
+ * 2. represents the current logged in user
+ * 3. represents the most recent logged in user
+ *
+ * In the second case, user represented by NowLogin should be the first
+ * profile; otherwise the default user should be selected by default.
+ */
+ if (nowLoginEmail) {
+ if (loginIniObj.NowLogin.IsLogined === "1") {
+ noLoggedInUser = false;
+ }
+
+ profiles.push({
+ id: this._getIdFromConfig(loginIniObj.NowLogin),
+ name: nowLoginEmail,
+ });
+ }
+
+ for (let section in loginIniObj) {
+ if (
+ !loginIniObj[section].email ||
+ (nowLoginEmail && loginIniObj[section].email == nowLoginEmail)
+ ) {
+ continue;
+ }
+
+ profiles.push({
+ id: this._getIdFromConfig(loginIniObj[section]),
+ name: loginIniObj[section].email,
+ });
+ }
+ } catch (e) {
+ Cu.reportError("Error detecting 360 Secure Browser profiles: " + e);
+ } finally {
+ profiles[noLoggedInUser ? "unshift" : "push"]({
+ id: this._defaultUserPath,
+ name: "Default",
+ });
+ }
+
+ this.__sourceProfiles = profiles.filter(profile => {
+ let resources = this.getResources(profile);
+ return resources && !!resources.length;
+ });
+ return this.__sourceProfiles;
+};
+
+Qihoo360seProfileMigrator.prototype._getIdFromConfig = function(aConfig) {
+ return aConfig.UserMd5 || getHash(aConfig.email);
+};
+
+Qihoo360seProfileMigrator.prototype.getResources = function(aProfile) {
+ let profileFolder = this._usersDir.clone();
+ profileFolder.append(aProfile.id);
+
+ if (!profileFolder.exists()) {
+ return [];
+ }
+
+ let resources = [new Bookmarks(profileFolder)];
+ return resources.filter(r => r.exists);
+};
+
+Qihoo360seProfileMigrator.prototype.getLastUsedDate = async function() {
+ let sourceProfiles = await this.getSourceProfiles();
+ let bookmarksPaths = sourceProfiles.map(({ id }) => {
+ return OS.Path.join(this._usersDir.path, id, kBookmarksFileName);
+ });
+ if (!bookmarksPaths.length) {
+ return new Date(0);
+ }
+ let datePromises = bookmarksPaths.map(path => {
+ return OS.File.stat(path)
+ .catch(() => null)
+ .then(info => {
+ return info ? info.lastModificationDate : 0;
+ });
+ });
+ return Promise.all(datePromises).then(dates => {
+ return new Date(Math.max.apply(Math, dates));
+ });
+};
+
+Qihoo360seProfileMigrator.prototype.classDescription =
+ "360 Secure Browser Profile Migrator";
+Qihoo360seProfileMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=360se";
+Qihoo360seProfileMigrator.prototype.classID = Components.ID(
+ "{d0037b95-296a-4a4e-94b2-c3d075d20ab1}"
+);
+
+var EXPORTED_SYMBOLS = ["Qihoo360seProfileMigrator"];
diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.jsm b/browser/components/migration/ChromeMacOSLoginCrypto.jsm
new file mode 100644
index 0000000000..f3d017615b
--- /dev/null
+++ b/browser/components/migration/ChromeMacOSLoginCrypto.jsm
@@ -0,0 +1,185 @@
+/* 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";
+
+/**
+ * Class to handle encryption and decryption of logins stored in Chrome/Chromium
+ * on macOS.
+ */
+
+var EXPORTED_SYMBOLS = ["ChromeMacOSLoginCrypto"];
+
+Cu.importGlobalProperties(["crypto"]);
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gKeychainUtils",
+ "@mozilla.org/profile/migrator/keychainmigrationutils;1",
+ "nsIKeychainMigrationUtils"
+);
+
+const gTextEncoder = new TextEncoder();
+const gTextDecoder = new TextDecoder();
+
+/**
+ * From macOS' CommonCrypto/CommonCryptor.h
+ */
+const kCCBlockSizeAES128 = 16;
+
+/* Chromium constants */
+
+/**
+ * kSalt from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const SALT = "saltysalt";
+
+/**
+ * kDerivedKeySizeInBits from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const DERIVED_KEY_SIZE_BITS = 128;
+
+/**
+ * kEncryptionIterations from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const ITERATIONS = 1003;
+
+/**
+ * kEncryptionVersionPrefix from Chromium.
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const ENCRYPTION_VERSION_PREFIX = "v10";
+
+/**
+ * The initialization vector is 16 space characters (character code 32 in decimal).
+ * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0
+ */
+const IV = new Uint8Array(kCCBlockSizeAES128).fill(32);
+
+/**
+ * Instances of this class have a shape similar to OSCrypto so it can be dropped
+ * into code which uses that. This isn't implemented as OSCrypto_mac.js since
+ * it isn't calling into encryption functions provided by macOS but instead
+ * relies on OS encryption key storage in Keychain. The algorithms here are
+ * specific to what is needed for Chrome login storage on macOS.
+ */
+class ChromeMacOSLoginCrypto {
+ /**
+ * @param {string} serviceName of the Keychain Item to use to derive a key.
+ * @param {string} accountName of the Keychain Item to use to derive a key.
+ * @param {string?} [testingPassphrase = null] A string to use as the passphrase
+ * to derive a key for testing purposes rather than retrieving
+ * it from the macOS Keychain since we don't yet have a way to
+ * mock the Keychain auth dialog.
+ */
+ constructor(serviceName, accountName, testingPassphrase = null) {
+ // We still exercise the keychain migration utils code when using a
+ // `testingPassphrase` in order to get some test coverage for that
+ // component, even though it's expected to throw since a login item with the
+ // service name and account name usually won't be found.
+ let encKey = testingPassphrase;
+ try {
+ encKey = gKeychainUtils.getGenericPassword(serviceName, accountName);
+ } catch (ex) {
+ if (!testingPassphrase) {
+ throw ex;
+ }
+ }
+
+ this.ALGORITHM = "AES-CBC";
+
+ this._keyPromise = crypto.subtle
+ .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [
+ "deriveKey",
+ ])
+ .then(key => {
+ return crypto.subtle.deriveKey(
+ {
+ name: "PBKDF2",
+ salt: gTextEncoder.encode(SALT),
+ iterations: ITERATIONS,
+ hash: "SHA-1",
+ },
+ key,
+ { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS },
+ false,
+ ["decrypt", "encrypt"]
+ );
+ })
+ .catch(Cu.reportError);
+ }
+
+ /**
+ * Convert an array containing only two bytes unsigned numbers to a string.
+ * @param {number[]} arr - the array that needs to be converted.
+ * @returns {string} the string representation of the array.
+ */
+ arrayToString(arr) {
+ let str = "";
+ for (let i = 0; i < arr.length; i++) {
+ str += String.fromCharCode(arr[i]);
+ }
+ return str;
+ }
+
+ stringToArray(binary_string) {
+ let len = binary_string.length;
+ let bytes = new Uint8Array(len);
+ for (var i = 0; i < len; i++) {
+ bytes[i] = binary_string.charCodeAt(i);
+ }
+ return bytes;
+ }
+
+ /**
+ * @param {array} ciphertextArray ciphertext prefixed by the encryption version
+ * (see ENCRYPTION_VERSION_PREFIX).
+ * @returns {string} plaintext password
+ */
+ async decryptData(ciphertextArray) {
+ let ciphertext = this.arrayToString(ciphertextArray);
+ if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) {
+ throw new Error("Unknown encryption version");
+ }
+ let key = await this._keyPromise;
+ if (!key) {
+ throw new Error("Cannot decrypt without a key");
+ }
+ let plaintext = await crypto.subtle.decrypt(
+ { name: this.ALGORITHM, iv: IV },
+ key,
+ this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length))
+ );
+ return gTextDecoder.decode(plaintext);
+ }
+
+ /**
+ * @param {USVString} plaintext to encrypt
+ * @returns {string} encrypted string consisting of UTF-16 code units prefixed
+ * by the ENCRYPTION_VERSION_PREFIX.
+ */
+ async encryptData(plaintext) {
+ let key = await this._keyPromise;
+ if (!key) {
+ throw new Error("Cannot encrypt without a key");
+ }
+
+ let ciphertext = await crypto.subtle.encrypt(
+ { name: this.ALGORITHM, iv: IV },
+ key,
+ gTextEncoder.encode(plaintext)
+ );
+ return (
+ ENCRYPTION_VERSION_PREFIX +
+ String.fromCharCode(...new Uint8Array(ciphertext))
+ );
+ }
+}
diff --git a/browser/components/migration/ChromeMigrationUtils.jsm b/browser/components/migration/ChromeMigrationUtils.jsm
new file mode 100644
index 0000000000..2abf12c12a
--- /dev/null
+++ b/browser/components/migration/ChromeMigrationUtils.jsm
@@ -0,0 +1,449 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ChromeMigrationUtils"];
+
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AppConstants: "resource://gre/modules/AppConstants.jsm",
+ LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+ MigrationUtils: "resource:///modules/MigrationUtils.jsm",
+ OS: "resource://gre/modules/osfile.jsm",
+ Services: "resource://gre/modules/Services.jsm",
+});
+
+const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
+const S100NS_PER_MS = 10;
+
+var ChromeMigrationUtils = {
+ // Supported browsers with importable logins.
+ CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"],
+
+ _extensionVersionDirectoryNames: {},
+
+ // The cache for the locale strings.
+ // For example, the data could be:
+ // {
+ // "profile-id-1": {
+ // "extension-id-1": {
+ // "name": {
+ // "message": "Fake App 1"
+ // }
+ // },
+ // }
+ _extensionLocaleStrings: {},
+
+ get supportsLoginsForPlatform() {
+ return ["macosx", "win"].includes(AppConstants.platform);
+ },
+
+ /**
+ * Get all extensions installed in a specific profile.
+ * @param {String} profileId - A Chrome user profile ID. For example, "Profile 1".
+ * @returns {Array} All installed Chrome extensions information.
+ */
+ async getExtensionList(profileId) {
+ if (profileId === undefined) {
+ profileId = await this.getLastUsedProfileId();
+ }
+ let path = this.getExtensionPath(profileId);
+ let iterator = new OS.File.DirectoryIterator(path);
+ let extensionList = [];
+ await iterator
+ .forEach(async entry => {
+ if (entry.isDir) {
+ let extensionInformation = await this.getExtensionInformation(
+ entry.name,
+ profileId
+ );
+ if (extensionInformation) {
+ extensionList.push(extensionInformation);
+ }
+ }
+ })
+ .catch(ex => Cu.reportError(ex));
+ return extensionList;
+ },
+
+ /**
+ * Get information of a specific Chrome extension.
+ * @param {String} extensionId - The extension ID.
+ * @param {String} profileId - The user profile's ID.
+ * @retruns {Object} The Chrome extension information.
+ */
+ async getExtensionInformation(extensionId, profileId) {
+ if (profileId === undefined) {
+ profileId = await this.getLastUsedProfileId();
+ }
+ let extensionInformation = null;
+ try {
+ let manifestPath = this.getExtensionPath(profileId);
+ manifestPath = OS.Path.join(manifestPath, extensionId);
+ // If there are multiple sub-directories in the extension directory,
+ // read the files in the latest directory.
+ let directories = await this._getSortedByVersionSubDirectoryNames(
+ manifestPath
+ );
+ if (!directories[0]) {
+ return null;
+ }
+
+ manifestPath = OS.Path.join(
+ manifestPath,
+ directories[0],
+ "manifest.json"
+ );
+ let manifest = await OS.File.read(manifestPath, { encoding: "utf-8" });
+ manifest = JSON.parse(manifest);
+ // No app attribute means this is a Chrome extension not a Chrome app.
+ if (!manifest.app) {
+ const DEFAULT_LOCALE = manifest.default_locale;
+ let name = await this._getLocaleString(
+ manifest.name,
+ DEFAULT_LOCALE,
+ extensionId,
+ profileId
+ );
+ let description = await this._getLocaleString(
+ manifest.description,
+ DEFAULT_LOCALE,
+ extensionId,
+ profileId
+ );
+ if (name) {
+ extensionInformation = {
+ id: extensionId,
+ name,
+ description,
+ };
+ } else {
+ throw new Error("Cannot read the Chrome extension's name property.");
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ return extensionInformation;
+ },
+
+ /**
+ * Get the manifest's locale string.
+ * @param {String} key - The key of a locale string, for example __MSG_name__.
+ * @param {String} locale - The specific language of locale string.
+ * @param {String} extensionId - The extension ID.
+ * @param {String} profileId - The user profile's ID.
+ * @retruns {String} The locale string.
+ */
+ async _getLocaleString(key, locale, extensionId, profileId) {
+ // Return the key string if it is not a locale key.
+ // The key string starts with "__MSG_" and ends with "__".
+ // For example, "__MSG_name__".
+ // https://developer.chrome.com/apps/i18n
+ if (!key.startsWith("__MSG_") || !key.endsWith("__")) {
+ return key;
+ }
+
+ let localeString = null;
+ try {
+ let localeFile;
+ if (
+ this._extensionLocaleStrings[profileId] &&
+ this._extensionLocaleStrings[profileId][extensionId]
+ ) {
+ localeFile = this._extensionLocaleStrings[profileId][extensionId];
+ } else {
+ if (!this._extensionLocaleStrings[profileId]) {
+ this._extensionLocaleStrings[profileId] = {};
+ }
+ let localeFilePath = this.getExtensionPath(profileId);
+ localeFilePath = OS.Path.join(localeFilePath, extensionId);
+ let directories = await this._getSortedByVersionSubDirectoryNames(
+ localeFilePath
+ );
+ // If there are multiple sub-directories in the extension directory,
+ // read the files in the latest directory.
+ localeFilePath = OS.Path.join(
+ localeFilePath,
+ directories[0],
+ "_locales",
+ locale,
+ "messages.json"
+ );
+ localeFile = await OS.File.read(localeFilePath, { encoding: "utf-8" });
+ localeFile = JSON.parse(localeFile);
+ this._extensionLocaleStrings[profileId][extensionId] = localeFile;
+ }
+ const PREFIX_LENGTH = 6;
+ const SUFFIX_LENGTH = 2;
+ // Get the locale key from the string with locale prefix and suffix.
+ // For example, it will get the "name" sub-string from the "__MSG_name__" string.
+ key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH);
+ if (localeFile[key] && localeFile[key].message) {
+ localeString = localeFile[key].message;
+ }
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ return localeString;
+ },
+
+ /**
+ * Check that a specific extension is installed or not.
+ * @param {String} extensionId - The extension ID.
+ * @param {String} profileId - The user profile's ID.
+ * @returns {Boolean} Return true if the extension is installed otherwise return false.
+ */
+ async isExtensionInstalled(extensionId, profileId) {
+ if (profileId === undefined) {
+ profileId = await this.getLastUsedProfileId();
+ }
+ let extensionPath = this.getExtensionPath(profileId);
+ let isInstalled = await OS.File.exists(
+ OS.Path.join(extensionPath, extensionId)
+ );
+ return isInstalled;
+ },
+
+ /**
+ * Get the last used user profile's ID.
+ * @returns {String} The last used user profile's ID.
+ */
+ async getLastUsedProfileId() {
+ let localState = await this.getLocalState();
+ return localState ? localState.profile.last_used : "Default";
+ },
+
+ /**
+ * Get the local state file content.
+ * @param {String} dataPath the type of Chrome data we're looking for (Chromium, Canary, etc.)
+ * @returns {Object} The JSON-based content.
+ */
+ async getLocalState(dataPath = "Chrome") {
+ let localState = null;
+ try {
+ let localStatePath = PathUtils.join(
+ this.getDataPath(dataPath),
+ "Local State"
+ );
+ localState = JSON.parse(await IOUtils.readUTF8(localStatePath));
+ } catch (ex) {
+ // Don't report the error if it's just a file not existing.
+ if (ex.name != "NotFoundError") {
+ Cu.reportError(ex);
+ }
+ throw ex;
+ }
+ return localState;
+ },
+
+ /**
+ * Get the path of Chrome extension directory.
+ * @param {String} profileId - The user profile's ID.
+ * @returns {String} The path of Chrome extension directory.
+ */
+ getExtensionPath(profileId) {
+ return PathUtils.join(this.getDataPath(), profileId, "Extensions");
+ },
+
+ /**
+ * Get the path of an application data directory.
+ * @param {String} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc.
+ * Defaults to "Chrome".
+ * @returns {String} The path of application data directory.
+ */
+ getDataPath(chromeProjectName = "Chrome") {
+ const SUB_DIRECTORIES = {
+ win: {
+ Chrome: ["Google", "Chrome"],
+ "Chrome Beta": ["Google", "Chrome Beta"],
+ Chromium: ["Chromium"],
+ Canary: ["Google", "Chrome SxS"],
+ Edge: ["Microsoft", "Edge"],
+ "Edge Beta": ["Microsoft", "Edge Beta"],
+ },
+ macosx: {
+ Chrome: ["Google", "Chrome"],
+ Chromium: ["Chromium"],
+ Canary: ["Google", "Chrome Canary"],
+ Edge: ["Microsoft Edge"],
+ "Edge Beta": ["Microsoft Edge Beta"],
+ },
+ linux: {
+ Chrome: ["google-chrome"],
+ "Chrome Beta": ["google-chrome-beta"],
+ "Chrome Dev": ["google-chrome-unstable"],
+ Chromium: ["chromium"],
+ // Canary is not available on Linux.
+ // Edge is not available on Linux.
+ },
+ };
+ let subfolders = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName];
+ if (!subfolders) {
+ return null;
+ }
+
+ let rootDir;
+ if (AppConstants.platform == "win") {
+ rootDir = "LocalAppData";
+ subfolders = subfolders.concat(["User Data"]);
+ } else if (AppConstants.platform == "macosx") {
+ rootDir = "ULibDir";
+ subfolders = ["Application Support"].concat(subfolders);
+ } else {
+ rootDir = "Home";
+ subfolders = [".config"].concat(subfolders);
+ }
+ try {
+ let target = Services.dirsvc.get(rootDir, Ci.nsIFile);
+ for (let subfolder of subfolders) {
+ target.append(subfolder);
+ }
+ return target.path;
+ } catch (ex) {
+ // The path logic here shouldn't error, so log it:
+ Cu.reportError(ex);
+ }
+ return null;
+ },
+
+ /**
+ * Get the directory objects sorted by version number.
+ * @param {String} path - The path to the extension directory.
+ * otherwise return all file/directory object.
+ * @returns {Array} The file/directory object array.
+ */
+ async _getSortedByVersionSubDirectoryNames(path) {
+ if (this._extensionVersionDirectoryNames[path]) {
+ return this._extensionVersionDirectoryNames[path];
+ }
+
+ let iterator = new OS.File.DirectoryIterator(path);
+ let entries = [];
+ await iterator
+ .forEach(async entry => {
+ if (entry.isDir) {
+ entries.push(entry.name);
+ }
+ })
+ .catch(ex => {
+ Cu.reportError(ex);
+ entries = [];
+ });
+ // The directory name is the version number string of the extension.
+ // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2.
+ // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again.
+ // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc
+ entries.sort((a, b) => Services.vc.compare(b, a));
+
+ this._extensionVersionDirectoryNames[path] = entries;
+ return entries;
+ },
+
+ /**
+ * Convert Chrome time format to Date object
+ *
+ * @param aTime
+ * Chrome time
+ * @param aFallbackValue
+ * a date or timestamp (valid argument for the Date constructor)
+ * that will be used if the chrometime value passed is invalid.
+ * @return converted Date object
+ * @note Google Chrome uses FILETIME / 10 as time.
+ * FILETIME is based on same structure of Windows.
+ */
+ chromeTimeToDate(aTime, aFallbackValue) {
+ // The date value may be 0 in some cases. Because of the subtraction below,
+ // that'd generate a date before the unix epoch, which can upset consumers
+ // due to the unix timestamp then being negative. Catch this case:
+ if (!aTime) {
+ return new Date(aFallbackValue);
+ }
+ return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000);
+ },
+
+ /**
+ * Convert Date object to Chrome time format
+ *
+ * @param aDate
+ * Date object or integer equivalent
+ * @return Chrome time
+ * @note For details on Chrome time, see chromeTimeToDate.
+ */
+ dateToChromeTime(aDate) {
+ return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS;
+ },
+
+ /**
+ * Returns an array of chromium browser ids that have importable logins.
+ */
+ _importableLoginsCache: null,
+ async getImportableLogins(formOrigin) {
+ // Only provide importable if we actually support importing.
+ if (!this.supportsLoginsForPlatform) {
+ return undefined;
+ }
+
+ // Lazily fill the cache with all importable login browsers.
+ if (!this._importableLoginsCache) {
+ this._importableLoginsCache = new Map();
+
+ // Just handle these chromium-based browsers for now.
+ for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) {
+ // Skip if there's no profile data.
+ const migrator = await MigrationUtils.getMigrator(browserId);
+ if (!migrator) {
+ continue;
+ }
+
+ // Check each profile for logins.
+ const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists();
+ for (const profile of await migrator.getSourceProfiles()) {
+ const path = OS.Path.join(dataPath, profile.id, "Login Data");
+ // Skip if login data is missing.
+ if (!(await OS.File.exists(path))) {
+ Cu.reportError(`Missing file at ${path}`);
+ continue;
+ }
+
+ try {
+ for (const row of await MigrationUtils.getRowsFromDBWithoutLocks(
+ path,
+ `Importable ${browserId} logins`,
+ `SELECT origin_url
+ FROM logins
+ WHERE blacklisted_by_user = 0`
+ )) {
+ const url = row.getString(0);
+ try {
+ // Initialize an array if it doesn't exist for the origin yet.
+ const origin = LoginHelper.getLoginOrigin(url);
+ const entries = this._importableLoginsCache.get(origin) || [];
+ if (!entries.length) {
+ this._importableLoginsCache.set(origin, entries);
+ }
+
+ // Add the browser if it doesn't exist yet.
+ if (!entries.includes(browserId)) {
+ entries.push(browserId);
+ }
+ } catch (ex) {
+ Cu.reportError(
+ `Failed to process importable url ${url} from ${browserId} ${ex}`
+ );
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(
+ `Failed to get importable logins from ${browserId} ${ex}`
+ );
+ }
+ }
+ }
+ }
+ return this._importableLoginsCache.get(formOrigin);
+ },
+};
diff --git a/browser/components/migration/ChromeProfileMigrator.jsm b/browser/components/migration/ChromeProfileMigrator.jsm
new file mode 100644
index 0000000000..66a1876467
--- /dev/null
+++ b/browser/components/migration/ChromeProfileMigrator.jsm
@@ -0,0 +1,757 @@
+/* -*- 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"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUIUtils",
+ "resource:///modules/PlacesUIUtils.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 internal URIs. Creating an actual URI always reports
+ // messages to the console because Gecko has its own concept of how
+ // chrome:// URIs should be formed, so we avoid doing that.
+ continue;
+ }
+ if (item.url.trim().startsWith("edge:")) {
+ // Don't import internal Microsoft Edge URIs as they won't resolve within Firefox.
+ 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 localePropertySuffix = MigrationUtils._getLocalePropertyForBrowser(
+ this.getBrowserKey()
+ ).replace(/^source-name-/, "");
+ let possibleResourcePromises = [
+ GetBookmarksResource(
+ profileFolder,
+ localePropertySuffix,
+ this.getBrowserKey()
+ ),
+ GetHistoryResource(profileFolder),
+ GetCookiesResource(profileFolder),
+ ];
+ if (ChromeMigrationUtils.supportsLoginsForPlatform) {
+ 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 localState;
+ let profiles = [];
+ try {
+ 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) {
+ // Avoid reporting NotFoundErrors from trying to get local state.
+ if (localState || e.name != "NotFoundError") {
+ Cu.reportError("Error detecting Chrome profiles: " + e);
+ }
+ // If we weren't able to detect any profiles above, fallback to the Default profile.
+ let defaultProfilePath = PathUtils.join(chromeUserDataPath, "Default");
+ if (await IOUtils.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,
+ aLocalePropertySuffix,
+ aBrowserKey
+) {
+ 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;
+ let histogramBookmarkRoots = 0;
+
+ // Importing bookmark bar items
+ if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) {
+ // Toolbar
+ histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ let parentGuid = PlacesUtils.bookmarks.toolbarGuid;
+ let bookmarks = convertBookmarks(
+ roots.bookmark_bar.children,
+ errorGatherer
+ );
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(
+ PlacesUtils.bookmarks.toolbarGuid
+ ) > PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ parentGuid = await MigrationUtils.createImportedBookmarksFolder(
+ aLocalePropertySuffix,
+ parentGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(
+ bookmarks,
+ parentGuid
+ );
+ PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration();
+ }
+
+ // Importing bookmark menu items
+ if (roots.other.children && roots.other.children.length) {
+ // Bookmark menu
+ histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU;
+ let parentGuid = PlacesUtils.bookmarks.menuGuid;
+ let bookmarks = convertBookmarks(roots.other.children, errorGatherer);
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(PlacesUtils.bookmarks.menuGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ parentGuid = await MigrationUtils.createImportedBookmarksFolder(
+ aLocalePropertySuffix,
+ parentGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(
+ bookmarks,
+ parentGuid
+ );
+ }
+ if (gotErrors) {
+ throw new Error("The migration included errors.");
+ }
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS")
+ .add(aBrowserKey, histogramBookmarkRoots);
+ })().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";
+
+ let source_scheme = columns.includes("source_scheme")
+ ? "source_scheme"
+ : `"${Ci.nsICookie.SCHEME_UNSET}" as source_scheme`;
+
+ // 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, ${source_scheme}
+ 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);
+ }
+
+ let schemeType = Ci.nsICookie.SCHEME_UNSET;
+ switch (row.getResultByName("source_scheme")) {
+ case 1:
+ schemeType = Ci.nsICookie.SCHEME_HTTP;
+ break;
+ case 2:
+ schemeType = Ci.nsICookie.SCHEME_HTTPS;
+ break;
+ }
+
+ 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,
+ schemeType
+ );
+ } 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 {
+ _chromeUserDataPathSuffix,
+ _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;
+ }
+
+ // If there are no relevant rows, return before initializing crypto and
+ // thus prompting for Keychain access on macOS.
+ if (!rows.length) {
+ aCallback(true);
+ return;
+ }
+
+ let crypto;
+ try {
+ if (AppConstants.platform == "win") {
+ let { ChromeWindowsLoginCrypto } = ChromeUtils.import(
+ "resource:///modules/ChromeWindowsLoginCrypto.jsm"
+ );
+ crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix);
+ } 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(
+ 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 - Linux only (not available in Mac and Windows)
+ */
+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}"
+);
+
+if (AppConstants.platform != "win" && AppConstants.platform != "macosx") {
+ EXPORTED_SYMBOLS.push("ChromeDevMigrator");
+}
+
+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}"
+);
+
+if (AppConstants.platform != "macosx") {
+ EXPORTED_SYMBOLS.push("ChromeBetaMigrator");
+}
+
+function ChromiumEdgeMigrator() {
+ this._chromeUserDataPathSuffix = "Edge";
+ this._keychainServiceName = "Microsoft Edge Safe Storage";
+ this._keychainAccountName = "Microsoft Edge";
+}
+ChromiumEdgeMigrator.prototype = Object.create(ChromeProfileMigrator.prototype);
+ChromiumEdgeMigrator.prototype.classDescription =
+ "Chromium Edge Profile Migrator";
+ChromiumEdgeMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge";
+ChromiumEdgeMigrator.prototype.classID = Components.ID(
+ "{3c7f6b7c-baa9-4338-acfa-04bf79f1dcf1}"
+);
+
+function ChromiumEdgeBetaMigrator() {
+ this._chromeUserDataPathSuffix = "Edge Beta";
+ this._keychainServiceName = "Microsoft Edge Safe Storage";
+ this._keychainAccountName = "Microsoft Edge";
+}
+ChromiumEdgeBetaMigrator.prototype = Object.create(
+ ChromiumEdgeMigrator.prototype
+);
+ChromiumEdgeBetaMigrator.prototype.classDescription =
+ "Chromium Edge Beta Profile Migrator";
+ChromiumEdgeBetaMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge-beta";
+ChromiumEdgeBetaMigrator.prototype.classID = Components.ID(
+ "{0fc3d48a-c1c3-4871-b58f-a8b47d1555fb}"
+);
+
+if (AppConstants.platform == "macosx" || AppConstants.platform == "win") {
+ EXPORTED_SYMBOLS.push("ChromiumEdgeMigrator", "ChromiumEdgeBetaMigrator");
+}
diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.jsm b/browser/components/migration/ChromeWindowsLoginCrypto.jsm
new file mode 100644
index 0000000000..bbe204132a
--- /dev/null
+++ b/browser/components/migration/ChromeWindowsLoginCrypto.jsm
@@ -0,0 +1,181 @@
+/* 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";
+
+/**
+ * Class to handle encryption and decryption of logins stored in Chrome/Chromium
+ * on Windows.
+ */
+
+var EXPORTED_SYMBOLS = ["ChromeWindowsLoginCrypto"];
+
+Cu.importGlobalProperties(["atob", "crypto"]);
+
+const { ChromeMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/ChromeMigrationUtils.jsm"
+);
+const { OSCrypto } = ChromeUtils.import("resource://gre/modules/OSCrypto.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+/**
+ * These constants should match those from Chromium.
+ * @see https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_win.cc
+ */
+const AEAD_KEY_LENGTH = 256 / 8;
+const ALGORITHM_NAME = "AES-GCM";
+const DPAPI_KEY_PREFIX = "DPAPI";
+const ENCRYPTION_VERSION_PREFIX = "v10";
+const NONCE_LENGTH = 96 / 8;
+
+const gTextDecoder = new TextDecoder();
+const gTextEncoder = new TextEncoder();
+
+/**
+ * Instances of this class have a shape similar to OSCrypto so it can be dropped
+ * into code which uses that. The algorithms here are
+ * specific to what is needed for Chrome login storage on Windows.
+ */
+class ChromeWindowsLoginCrypto {
+ /**
+ * @param {string} userDataPathSuffix
+ */
+ constructor(userDataPathSuffix) {
+ this.osCrypto = new OSCrypto();
+
+ // Lazily decrypt the key from "Chrome"s local state using OSCrypto and save
+ // it as the master key to decrypt or encrypt passwords.
+ XPCOMUtils.defineLazyGetter(this, "_keyPromise", async () => {
+ let keyData;
+ try {
+ // NB: For testing, allow directory service to be faked before getting.
+ const localState = await ChromeMigrationUtils.getLocalState(
+ userDataPathSuffix
+ );
+ const withHeader = atob(localState.os_crypt.encrypted_key);
+ if (!withHeader.startsWith(DPAPI_KEY_PREFIX)) {
+ throw new Error("Invalid key format");
+ }
+ const encryptedKey = withHeader.slice(DPAPI_KEY_PREFIX.length);
+ keyData = this.osCrypto.decryptData(encryptedKey, null, "bytes");
+ } catch (ex) {
+ Cu.reportError(`${userDataPathSuffix} os_crypt key: ${ex}`);
+
+ // Use a generic key that will fail for actually encrypted data, but for
+ // testing it'll be consistent for both encrypting and decrypting.
+ keyData = AEAD_KEY_LENGTH;
+ }
+ return crypto.subtle.importKey(
+ "raw",
+ new Uint8Array(keyData),
+ ALGORITHM_NAME,
+ false,
+ ["decrypt", "encrypt"]
+ );
+ });
+ }
+
+ /**
+ * Must be invoked once after last use of any of the provided helpers.
+ */
+ finalize() {
+ this.osCrypto.finalize();
+ }
+
+ /**
+ * Convert an array containing only two bytes unsigned numbers to a string.
+ * @param {number[]} arr - the array that needs to be converted.
+ * @returns {string} the string representation of the array.
+ */
+ arrayToString(arr) {
+ let str = "";
+ for (let i = 0; i < arr.length; i++) {
+ str += String.fromCharCode(arr[i]);
+ }
+ return str;
+ }
+
+ stringToArray(binary_string) {
+ const len = binary_string.length;
+ const bytes = new Uint8Array(len);
+ for (let i = 0; i < len; i++) {
+ bytes[i] = binary_string.charCodeAt(i);
+ }
+ return bytes;
+ }
+
+ /**
+ * @param {string} ciphertext ciphertext optionally prefixed by the encryption version
+ * (see ENCRYPTION_VERSION_PREFIX).
+ * @returns {string} plaintext password
+ */
+ async decryptData(ciphertext) {
+ const ciphertextString = this.arrayToString(ciphertext);
+ return ciphertextString.startsWith(ENCRYPTION_VERSION_PREFIX)
+ ? this._decryptV10(ciphertext)
+ : this._decryptUnversioned(ciphertextString);
+ }
+
+ async _decryptUnversioned(ciphertext) {
+ return this.osCrypto.decryptData(ciphertext);
+ }
+
+ async _decryptV10(ciphertext) {
+ const key = await this._keyPromise;
+ if (!key) {
+ throw new Error("Cannot decrypt without a key");
+ }
+
+ // Split the nonce/iv from the rest of the encrypted value and decrypt.
+ const nonceIndex = ENCRYPTION_VERSION_PREFIX.length;
+ const cipherIndex = nonceIndex + NONCE_LENGTH;
+ const iv = new Uint8Array(ciphertext.slice(nonceIndex, cipherIndex));
+ const algorithm = {
+ name: ALGORITHM_NAME,
+ iv,
+ };
+ const cipherArray = new Uint8Array(ciphertext.slice(cipherIndex));
+ const plaintext = await crypto.subtle.decrypt(algorithm, key, cipherArray);
+ return gTextDecoder.decode(new Uint8Array(plaintext));
+ }
+
+ /**
+ * @param {USVString} plaintext to encrypt
+ * @param {?string} version to encrypt default unversioned
+ * @returns {string} encrypted string consisting of UTF-16 code units prefixed
+ * by the ENCRYPTION_VERSION_PREFIX.
+ */
+ async encryptData(plaintext, version = undefined) {
+ return version === ENCRYPTION_VERSION_PREFIX
+ ? this._encryptV10(plaintext)
+ : this._encryptUnversioned(plaintext);
+ }
+
+ async _encryptUnversioned(plaintext) {
+ return this.osCrypto.encryptData(plaintext);
+ }
+
+ async _encryptV10(plaintext) {
+ const key = await this._keyPromise;
+ if (!key) {
+ throw new Error("Cannot encrypt without a key");
+ }
+
+ // Encrypt and concatenate the prefix, nonce/iv and encrypted value.
+ const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));
+ const algorithm = {
+ name: ALGORITHM_NAME,
+ iv,
+ };
+ const plainArray = gTextEncoder.encode(plaintext);
+ const ciphertext = await crypto.subtle.encrypt(algorithm, key, plainArray);
+ return (
+ ENCRYPTION_VERSION_PREFIX +
+ this.arrayToString(iv) +
+ this.arrayToString(new Uint8Array(ciphertext))
+ );
+ }
+}
diff --git a/browser/components/migration/ESEDBReader.jsm b/browser/components/migration/ESEDBReader.jsm
new file mode 100644
index 0000000000..acebe5ee82
--- /dev/null
+++ b/browser/components/migration/ESEDBReader.jsm
@@ -0,0 +1,810 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["ESEDBReader"]; /* exported ESEDBReader */
+
+const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyGetter(this, "log", () => {
+ let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {})
+ .ConsoleAPI;
+ let consoleOptions = {
+ maxLogLevelPref: "browser.esedbreader.loglevel",
+ prefix: "ESEDBReader",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+// We have a globally unique identifier for ESE instances. A new one
+// is used for each different database opened.
+let gESEInstanceCounter = 0;
+
+// We limit the length of strings that we read from databases.
+const MAX_STR_LENGTH = 64 * 1024;
+
+// Kernel-related types:
+const KERNEL = {};
+KERNEL.FILETIME = new ctypes.StructType("FILETIME", [
+ { dwLowDateTime: ctypes.uint32_t },
+ { dwHighDateTime: ctypes.uint32_t },
+]);
+KERNEL.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [
+ { wYear: ctypes.uint16_t },
+ { wMonth: ctypes.uint16_t },
+ { wDayOfWeek: ctypes.uint16_t },
+ { wDay: ctypes.uint16_t },
+ { wHour: ctypes.uint16_t },
+ { wMinute: ctypes.uint16_t },
+ { wSecond: ctypes.uint16_t },
+ { wMilliseconds: ctypes.uint16_t },
+]);
+
+// DB column types, cribbed from the ESE header
+var COLUMN_TYPES = {
+ JET_coltypBit: 1 /* True, False, or NULL */,
+ JET_coltypUnsignedByte: 2 /* 1-byte integer, unsigned */,
+ JET_coltypShort: 3 /* 2-byte integer, signed */,
+ JET_coltypLong: 4 /* 4-byte integer, signed */,
+ JET_coltypCurrency: 5 /* 8 byte integer, signed */,
+ JET_coltypIEEESingle: 6 /* 4-byte IEEE single precision */,
+ JET_coltypIEEEDouble: 7 /* 8-byte IEEE double precision */,
+ JET_coltypDateTime: 8 /* Integral date, fractional time */,
+ JET_coltypBinary: 9 /* Binary data, < 255 bytes */,
+ JET_coltypText: 10 /* ANSI text, case insensitive, < 255 bytes */,
+ JET_coltypLongBinary: 11 /* Binary data, long value */,
+ JET_coltypLongText: 12 /* ANSI text, long value */,
+
+ JET_coltypUnsignedLong: 14 /* 4-byte unsigned integer */,
+ JET_coltypLongLong: 15 /* 8-byte signed integer */,
+ JET_coltypGUID: 16 /* 16-byte globally unique identifier */,
+};
+
+// Not very efficient, but only used for error messages
+function getColTypeName(numericValue) {
+ return (
+ Object.keys(COLUMN_TYPES).find(t => COLUMN_TYPES[t] == numericValue) ||
+ "unknown"
+ );
+}
+
+// All type constants and method wrappers go on this object:
+const ESE = {};
+ESE.JET_ERR = ctypes.long;
+ESE.JET_PCWSTR = ctypes.char16_t.ptr;
+// The ESE header calls this JET_API_PTR, but because it isn't ever used as a
+// pointer and because OS.File code implies that the name you give a type
+// matters, I opted for a different name.
+// Note that this is defined differently on 32 vs. 64-bit in the header.
+ESE.JET_API_ITEM =
+ ctypes.voidptr_t.size == 4 ? ctypes.unsigned_long : ctypes.uint64_t;
+ESE.JET_INSTANCE = ESE.JET_API_ITEM;
+ESE.JET_SESID = ESE.JET_API_ITEM;
+ESE.JET_TABLEID = ESE.JET_API_ITEM;
+ESE.JET_COLUMNID = ctypes.unsigned_long;
+ESE.JET_GRBIT = ctypes.unsigned_long;
+ESE.JET_COLTYP = ctypes.unsigned_long;
+ESE.JET_DBID = ctypes.unsigned_long;
+
+ESE.JET_COLUMNDEF = new ctypes.StructType("JET_COLUMNDEF", [
+ { cbStruct: ctypes.unsigned_long },
+ { columnid: ESE.JET_COLUMNID },
+ { coltyp: ESE.JET_COLTYP },
+ { wCountry: ctypes.unsigned_short }, // sepcifies the country/region for the column definition
+ { langid: ctypes.unsigned_short },
+ { cp: ctypes.unsigned_short },
+ { wCollate: ctypes.unsigned_short } /* Must be 0 */,
+ { cbMax: ctypes.unsigned_long },
+ { grbit: ESE.JET_GRBIT },
+]);
+
+// Track open databases
+let gOpenDBs = new Map();
+
+// Track open libraries
+let gLibs = {};
+this.ESE = ESE; // Required for tests.
+this.KERNEL = KERNEL; // ditto
+this.gLibs = gLibs; // ditto
+
+function convertESEError(errorCode) {
+ switch (errorCode) {
+ case -1213 /* JET_errPageSizeMismatch */:
+ case -1002 /* JET_errInvalidName*/:
+ case -1507 /* JET_errColumnNotFound */:
+ // The DB format has changed and we haven't updated this migration code:
+ return "The database format has changed, error code: " + errorCode;
+ case -1032 /* JET_errFileAccessDenied */:
+ case -1207 /* JET_errDatabaseLocked */:
+ case -1302 /* JET_errTableLocked */:
+ return "The database or table is locked, error code: " + errorCode;
+ case -1305 /* JET_errObjectNotFound */:
+ return "The table/object was not found.";
+ case -1809 /* JET_errPermissionDenied*/:
+ case -1907 /* JET_errAccessDenied */:
+ return "Access or permission denied, error code: " + errorCode;
+ case -1044 /* JET_errInvalidFilename */:
+ return "Invalid file name";
+ case -1811 /* JET_errFileNotFound */:
+ return "File not found";
+ case -550 /* JET_errDatabaseDirtyShutdown */:
+ return "Database in dirty shutdown state (without the requisite logs?)";
+ case -514 /* JET_errBadLogVersion */:
+ return "Database log version does not match the version of ESE in use.";
+ default:
+ return "Unknown error: " + errorCode;
+ }
+}
+
+function handleESEError(
+ method,
+ methodName,
+ shouldThrow = true,
+ errorLog = true
+) {
+ return function() {
+ let rv;
+ try {
+ rv = method.apply(null, arguments);
+ } catch (ex) {
+ log.error("Error calling into ctypes method", methodName, ex);
+ throw ex;
+ }
+ let resultCode = parseInt(rv.toString(10), 10);
+ if (resultCode < 0) {
+ if (errorLog) {
+ log.error("Got error " + resultCode + " calling " + methodName);
+ }
+ if (shouldThrow) {
+ throw new Error(convertESEError(rv));
+ }
+ } else if (resultCode > 0 && errorLog) {
+ log.warn("Got warning " + resultCode + " calling " + methodName);
+ }
+ return resultCode;
+ };
+}
+
+function declareESEFunction(methodName, ...args) {
+ let declaration = ["Jet" + methodName, ctypes.winapi_abi, ESE.JET_ERR].concat(
+ args
+ );
+ let ctypeMethod = gLibs.ese.declare.apply(gLibs.ese, declaration);
+ ESE[methodName] = handleESEError(ctypeMethod, methodName);
+ ESE["FailSafe" + methodName] = handleESEError(ctypeMethod, methodName, false);
+ ESE["Manual" + methodName] = handleESEError(
+ ctypeMethod,
+ methodName,
+ false,
+ false
+ );
+}
+
+function declareESEFunctions() {
+ declareESEFunction(
+ "GetDatabaseFileInfoW",
+ ESE.JET_PCWSTR,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ctypes.unsigned_long
+ );
+
+ declareESEFunction(
+ "GetSystemParameterW",
+ ESE.JET_INSTANCE,
+ ESE.JET_SESID,
+ ctypes.unsigned_long,
+ ESE.JET_API_ITEM.ptr,
+ ESE.JET_PCWSTR,
+ ctypes.unsigned_long
+ );
+ declareESEFunction(
+ "SetSystemParameterW",
+ ESE.JET_INSTANCE.ptr,
+ ESE.JET_SESID,
+ ctypes.unsigned_long,
+ ESE.JET_API_ITEM,
+ ESE.JET_PCWSTR
+ );
+ declareESEFunction("CreateInstanceW", ESE.JET_INSTANCE.ptr, ESE.JET_PCWSTR);
+ declareESEFunction("Init", ESE.JET_INSTANCE.ptr);
+
+ declareESEFunction(
+ "BeginSessionW",
+ ESE.JET_INSTANCE,
+ ESE.JET_SESID.ptr,
+ ESE.JET_PCWSTR,
+ ESE.JET_PCWSTR
+ );
+ declareESEFunction(
+ "AttachDatabaseW",
+ ESE.JET_SESID,
+ ESE.JET_PCWSTR,
+ ESE.JET_GRBIT
+ );
+ declareESEFunction("DetachDatabaseW", ESE.JET_SESID, ESE.JET_PCWSTR);
+ declareESEFunction(
+ "OpenDatabaseW",
+ ESE.JET_SESID,
+ ESE.JET_PCWSTR,
+ ESE.JET_PCWSTR,
+ ESE.JET_DBID.ptr,
+ ESE.JET_GRBIT
+ );
+ declareESEFunction(
+ "OpenTableW",
+ ESE.JET_SESID,
+ ESE.JET_DBID,
+ ESE.JET_PCWSTR,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ESE.JET_GRBIT,
+ ESE.JET_TABLEID.ptr
+ );
+
+ declareESEFunction(
+ "GetColumnInfoW",
+ ESE.JET_SESID,
+ ESE.JET_DBID,
+ ESE.JET_PCWSTR,
+ ESE.JET_PCWSTR,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ctypes.unsigned_long
+ );
+
+ declareESEFunction(
+ "Move",
+ ESE.JET_SESID,
+ ESE.JET_TABLEID,
+ ctypes.long,
+ ESE.JET_GRBIT
+ );
+
+ declareESEFunction(
+ "RetrieveColumn",
+ ESE.JET_SESID,
+ ESE.JET_TABLEID,
+ ESE.JET_COLUMNID,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ctypes.unsigned_long.ptr,
+ ESE.JET_GRBIT,
+ ctypes.voidptr_t
+ );
+
+ declareESEFunction("CloseTable", ESE.JET_SESID, ESE.JET_TABLEID);
+ declareESEFunction(
+ "CloseDatabase",
+ ESE.JET_SESID,
+ ESE.JET_DBID,
+ ESE.JET_GRBIT
+ );
+
+ declareESEFunction("EndSession", ESE.JET_SESID, ESE.JET_GRBIT);
+
+ declareESEFunction("Term", ESE.JET_INSTANCE);
+}
+
+function unloadLibraries() {
+ log.debug("Unloading");
+ if (gOpenDBs.size) {
+ log.error("Shouldn't unload libraries before DBs are closed!");
+ for (let db of gOpenDBs.values()) {
+ db._close();
+ }
+ }
+ for (let k of Object.keys(ESE)) {
+ delete ESE[k];
+ }
+ gLibs.ese.close();
+ gLibs.kernel.close();
+ delete gLibs.ese;
+ delete gLibs.kernel;
+}
+
+function loadLibraries() {
+ Services.obs.addObserver(unloadLibraries, "xpcom-shutdown");
+ gLibs.ese = ctypes.open("esent.dll");
+ gLibs.kernel = ctypes.open("kernel32.dll");
+ KERNEL.FileTimeToSystemTime = gLibs.kernel.declare(
+ "FileTimeToSystemTime",
+ ctypes.winapi_abi,
+ ctypes.int,
+ KERNEL.FILETIME.ptr,
+ KERNEL.SYSTEMTIME.ptr
+ );
+
+ declareESEFunctions();
+}
+
+function ESEDB(rootPath, dbPath, logPath) {
+ log.info("Created db");
+ this.rootPath = rootPath;
+ this.dbPath = dbPath;
+ this.logPath = logPath;
+ this._references = 0;
+ this._init();
+}
+
+ESEDB.prototype = {
+ rootPath: null,
+ dbPath: null,
+ logPath: null,
+ _opened: false,
+ _attached: false,
+ _sessionCreated: false,
+ _instanceCreated: false,
+ _dbId: null,
+ _sessionId: null,
+ _instanceId: null,
+
+ _init() {
+ if (!gLibs.ese) {
+ loadLibraries();
+ }
+ this.incrementReferenceCounter();
+ this._internalOpen();
+ },
+
+ _internalOpen() {
+ try {
+ let dbinfo = new ctypes.unsigned_long();
+ ESE.GetDatabaseFileInfoW(
+ this.dbPath,
+ dbinfo.address(),
+ ctypes.unsigned_long.size,
+ 17
+ );
+
+ let pageSize = ctypes.UInt64.lo(dbinfo.value);
+ ESE.SetSystemParameterW(
+ null,
+ 0,
+ 64 /* JET_paramDatabasePageSize*/,
+ pageSize,
+ null
+ );
+
+ this._instanceId = new ESE.JET_INSTANCE();
+ ESE.CreateInstanceW(
+ this._instanceId.address(),
+ "firefox-dbreader-" + gESEInstanceCounter++
+ );
+ this._instanceCreated = true;
+
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 0 /* JET_paramSystemPath*/,
+ 0,
+ this.rootPath
+ );
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 1 /* JET_paramTempPath */,
+ 0,
+ this.rootPath
+ );
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 2 /* JET_paramLogFilePath*/,
+ 0,
+ this.logPath
+ );
+
+ // Shouldn't try to call JetTerm if the following call fails.
+ this._instanceCreated = false;
+ ESE.Init(this._instanceId.address());
+ this._instanceCreated = true;
+ this._sessionId = new ESE.JET_SESID();
+ ESE.BeginSessionW(
+ this._instanceId,
+ this._sessionId.address(),
+ null,
+ null
+ );
+ this._sessionCreated = true;
+
+ const JET_bitDbReadOnly = 1;
+ ESE.AttachDatabaseW(this._sessionId, this.dbPath, JET_bitDbReadOnly);
+ this._attached = true;
+ this._dbId = new ESE.JET_DBID();
+ ESE.OpenDatabaseW(
+ this._sessionId,
+ this.dbPath,
+ null,
+ this._dbId.address(),
+ JET_bitDbReadOnly
+ );
+ this._opened = true;
+ } catch (ex) {
+ try {
+ this._close();
+ } catch (innerException) {
+ Cu.reportError(innerException);
+ }
+ // Make sure caller knows we failed.
+ throw ex;
+ }
+ gOpenDBs.set(this.dbPath, this);
+ },
+
+ checkForColumn(tableName, columnName) {
+ if (!this._opened) {
+ throw new Error("The database was closed!");
+ }
+
+ let columnInfo;
+ try {
+ columnInfo = this._getColumnInfo(tableName, [{ name: columnName }]);
+ } catch (ex) {
+ return null;
+ }
+ return columnInfo[0];
+ },
+
+ tableExists(tableName) {
+ if (!this._opened) {
+ throw new Error("The database was closed!");
+ }
+
+ let tableId = new ESE.JET_TABLEID();
+ let rv = ESE.ManualOpenTableW(
+ this._sessionId,
+ this._dbId,
+ tableName,
+ null,
+ 0,
+ 4 /* JET_bitTableReadOnly */,
+ tableId.address()
+ );
+ if (rv == -1305 /* JET_errObjectNotFound */) {
+ return false;
+ }
+ if (rv < 0) {
+ log.error("Got error " + rv + " calling OpenTableW");
+ throw new Error(convertESEError(rv));
+ }
+
+ if (rv > 0) {
+ log.error("Got warning " + rv + " calling OpenTableW");
+ }
+ ESE.FailSafeCloseTable(this._sessionId, tableId);
+ return true;
+ },
+
+ *tableItems(tableName, columns) {
+ if (!this._opened) {
+ throw new Error("The database was closed!");
+ }
+
+ let tableOpened = false;
+ let tableId;
+ try {
+ tableId = this._openTable(tableName);
+ tableOpened = true;
+
+ let columnInfo = this._getColumnInfo(tableName, columns);
+
+ let rv = ESE.ManualMove(
+ this._sessionId,
+ tableId,
+ -2147483648 /* JET_MoveFirst */,
+ 0
+ );
+ if (rv == -1603 /* JET_errNoCurrentRecord */) {
+ // There are no rows in the table.
+ this._closeTable(tableId);
+ return;
+ }
+ if (rv != 0) {
+ throw new Error(convertESEError(rv));
+ }
+
+ do {
+ let rowContents = {};
+ for (let column of columnInfo) {
+ let [buffer, bufferSize] = this._getBufferForColumn(column);
+ // We handle errors manually so we accurately deal with NULL values.
+ let err = ESE.ManualRetrieveColumn(
+ this._sessionId,
+ tableId,
+ column.id,
+ buffer.address(),
+ bufferSize,
+ null,
+ 0,
+ null
+ );
+ rowContents[column.name] = this._convertResult(column, buffer, err);
+ }
+ yield rowContents;
+ } while (
+ ESE.ManualMove(this._sessionId, tableId, 1 /* JET_MoveNext */, 0) === 0
+ );
+ } catch (ex) {
+ if (tableOpened) {
+ this._closeTable(tableId);
+ }
+ throw ex;
+ }
+ this._closeTable(tableId);
+ },
+
+ _openTable(tableName) {
+ let tableId = new ESE.JET_TABLEID();
+ ESE.OpenTableW(
+ this._sessionId,
+ this._dbId,
+ tableName,
+ null,
+ 0,
+ 4 /* JET_bitTableReadOnly */,
+ tableId.address()
+ );
+ return tableId;
+ },
+
+ _getBufferForColumn(column) {
+ let buffer;
+ if (column.type == "string") {
+ let wchar_tArray = ctypes.ArrayType(ctypes.char16_t);
+ // size on the column is in bytes, 2 bytes to a wchar, so:
+ let charCount = column.dbSize >> 1;
+ buffer = new wchar_tArray(charCount);
+ } else if (column.type == "boolean") {
+ buffer = new ctypes.uint8_t();
+ } else if (column.type == "date") {
+ buffer = new KERNEL.FILETIME();
+ } else if (column.type == "guid") {
+ let byteArray = ctypes.ArrayType(ctypes.uint8_t);
+ buffer = new byteArray(column.dbSize);
+ } else {
+ throw new Error("Unknown type " + column.type);
+ }
+ return [buffer, buffer.constructor.size];
+ },
+
+ _convertResult(column, buffer, err) {
+ if (err != 0) {
+ if (err == 1004) {
+ // Deal with null values:
+ buffer = null;
+ } else {
+ Cu.reportError(
+ "Unexpected JET error: " +
+ err +
+ "; retrieving value for column " +
+ column.name
+ );
+ throw new Error(convertESEError(err));
+ }
+ }
+ if (column.type == "string") {
+ return buffer ? buffer.readString() : "";
+ }
+ if (column.type == "boolean") {
+ return buffer ? buffer.value == 255 : false;
+ }
+ if (column.type == "guid") {
+ if (buffer.length != 16) {
+ Cu.reportError(
+ "Buffer size for guid field " + column.id + " should have been 16!"
+ );
+ return "";
+ }
+ let rv = "{";
+ for (let i = 0; i < 16; i++) {
+ if (i == 4 || i == 6 || i == 8 || i == 10) {
+ rv += "-";
+ }
+ let byteValue = buffer.addressOfElement(i).contents;
+ // Ensure there's a leading 0
+ rv += ("0" + byteValue.toString(16)).substr(-2);
+ }
+ return rv + "}";
+ }
+ if (column.type == "date") {
+ if (!buffer) {
+ return null;
+ }
+ let systemTime = new KERNEL.SYSTEMTIME();
+ let result = KERNEL.FileTimeToSystemTime(
+ buffer.address(),
+ systemTime.address()
+ );
+ if (result == 0) {
+ throw new Error(ctypes.winLastError);
+ }
+
+ // System time is in UTC, so we use Date.UTC to get milliseconds from epoch,
+ // then divide by 1000 to get seconds, and round down:
+ return new Date(
+ Date.UTC(
+ systemTime.wYear,
+ systemTime.wMonth - 1,
+ systemTime.wDay,
+ systemTime.wHour,
+ systemTime.wMinute,
+ systemTime.wSecond,
+ systemTime.wMilliseconds
+ )
+ );
+ }
+ return undefined;
+ },
+
+ _getColumnInfo(tableName, columns) {
+ let rv = [];
+ for (let column of columns) {
+ let columnInfoFromDB = new ESE.JET_COLUMNDEF();
+ ESE.GetColumnInfoW(
+ this._sessionId,
+ this._dbId,
+ tableName,
+ column.name,
+ columnInfoFromDB.address(),
+ ESE.JET_COLUMNDEF.size,
+ 0 /* JET_ColInfo */
+ );
+ let dbType = parseInt(columnInfoFromDB.coltyp.toString(10), 10);
+ let dbSize = parseInt(columnInfoFromDB.cbMax.toString(10), 10);
+ if (column.type == "string") {
+ if (
+ dbType != COLUMN_TYPES.JET_coltypLongText &&
+ dbType != COLUMN_TYPES.JET_coltypText
+ ) {
+ throw new Error(
+ "Invalid column type for column " +
+ column.name +
+ "; expected text type, got type " +
+ getColTypeName(dbType)
+ );
+ }
+ if (dbSize > MAX_STR_LENGTH) {
+ throw new Error(
+ "Column " +
+ column.name +
+ " has more than 64k data in it. This API is not designed to handle data that large."
+ );
+ }
+ } else if (column.type == "boolean") {
+ if (dbType != COLUMN_TYPES.JET_coltypBit) {
+ throw new Error(
+ "Invalid column type for column " +
+ column.name +
+ "; expected bit type, got type " +
+ getColTypeName(dbType)
+ );
+ }
+ } else if (column.type == "date") {
+ if (dbType != COLUMN_TYPES.JET_coltypLongLong) {
+ throw new Error(
+ "Invalid column type for column " +
+ column.name +
+ "; expected long long type, got type " +
+ getColTypeName(dbType)
+ );
+ }
+ } else if (column.type == "guid") {
+ if (dbType != COLUMN_TYPES.JET_coltypGUID) {
+ throw new Error(
+ "Invalid column type for column " +
+ column.name +
+ "; expected guid type, got type " +
+ getColTypeName(dbType)
+ );
+ }
+ } else if (column.type) {
+ throw new Error(
+ "Unknown column type " +
+ column.type +
+ " requested for column " +
+ column.name +
+ ", don't know what to do."
+ );
+ }
+
+ rv.push({
+ name: column.name,
+ id: columnInfoFromDB.columnid,
+ type: column.type,
+ dbSize,
+ dbType,
+ });
+ }
+ return rv;
+ },
+
+ _closeTable(tableId) {
+ ESE.FailSafeCloseTable(this._sessionId, tableId);
+ },
+
+ _close() {
+ this._internalClose();
+ gOpenDBs.delete(this.dbPath);
+ },
+
+ _internalClose() {
+ if (this._opened) {
+ log.debug("close db");
+ ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0);
+ log.debug("finished close db");
+ this._opened = false;
+ }
+ if (this._attached) {
+ log.debug("detach db");
+ ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath);
+ this._attached = false;
+ }
+ if (this._sessionCreated) {
+ log.debug("end session");
+ ESE.FailSafeEndSession(this._sessionId, 0);
+ this._sessionCreated = false;
+ }
+ if (this._instanceCreated) {
+ log.debug("term");
+ ESE.FailSafeTerm(this._instanceId);
+ this._instanceCreated = false;
+ }
+ },
+
+ incrementReferenceCounter() {
+ this._references++;
+ },
+
+ decrementReferenceCounter() {
+ this._references--;
+ if (this._references <= 0) {
+ this._close();
+ }
+ },
+};
+
+let ESEDBReader = {
+ openDB(rootDir, dbFile, logDir) {
+ let dbFilePath = dbFile.path;
+ if (gOpenDBs.has(dbFilePath)) {
+ let db = gOpenDBs.get(dbFilePath);
+ db.incrementReferenceCounter();
+ return db;
+ }
+ // ESE is really picky about the trailing slashes according to the docs,
+ // so we do as we're told and ensure those are there:
+ return new ESEDB(rootDir.path + "\\", dbFilePath, logDir.path + "\\");
+ },
+
+ async dbLocked(dbFile) {
+ let options = { winShare: OS.Constants.Win.FILE_SHARE_READ };
+ let locked = true;
+ await OS.File.open(dbFile.path, { read: true }, options).then(
+ fileHandle => {
+ locked = false;
+ // Return the close promise so we wait for the file to be closed again.
+ // Otherwise the file might still be kept open by this handle by the time
+ // that we try to use the ESE APIs to access it.
+ return fileHandle.close();
+ },
+ () => {
+ Cu.reportError("ESE DB at " + dbFile.path + " is locked.");
+ }
+ );
+ return locked;
+ },
+
+ closeDB(db) {
+ db.decrementReferenceCounter();
+ },
+
+ COLUMN_TYPES,
+};
diff --git a/browser/components/migration/EdgeProfileMigrator.jsm b/browser/components/migration/EdgeProfileMigrator.jsm
new file mode 100644
index 0000000000..25d5408f4a
--- /dev/null
+++ b/browser/components/migration/EdgeProfileMigrator.jsm
@@ -0,0 +1,612 @@
+/* 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 { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+const { MSMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MSMigrationUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUIUtils",
+ "resource:///modules/PlacesUIUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ESEDBReader",
+ "resource:///modules/ESEDBReader.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+const kEdgeRegistryRoot =
+ "SOFTWARE\\Classes\\Local Settings\\Software\\" +
+ "Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\" +
+ "microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge";
+const kEdgeDatabasePath = "AC\\MicrosoftEdge\\User\\Default\\DataStore\\Data\\";
+
+XPCOMUtils.defineLazyGetter(this, "gEdgeDatabase", function() {
+ let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder();
+ if (!edgeDir) {
+ return null;
+ }
+ edgeDir.appendRelativePath(kEdgeDatabasePath);
+ if (!edgeDir.exists() || !edgeDir.isReadable() || !edgeDir.isDirectory()) {
+ return null;
+ }
+ let expectedLocation = edgeDir.clone();
+ expectedLocation.appendRelativePath(
+ "nouser1\\120712-0049\\DBStore\\spartan.edb"
+ );
+ if (
+ expectedLocation.exists() &&
+ expectedLocation.isReadable() &&
+ expectedLocation.isFile()
+ ) {
+ expectedLocation.normalize();
+ return expectedLocation;
+ }
+ // We used to recurse into arbitrary subdirectories here, but that code
+ // went unused, so it likely isn't necessary, even if we don't understand
+ // where the magic folders above come from, they seem to be the same for
+ // everyone. Just return null if they're not there:
+ return null;
+});
+
+/**
+ * Get rows from a table in the Edge DB as an array of JS objects.
+ *
+ * @param {String} tableName the name of the table to read.
+ * @param {String[]|function} columns a list of column specifiers
+ * (see ESEDBReader.jsm) or a function that
+ * generates them based on the database
+ * reference once opened.
+ * @param {nsIFile} dbFile the database file to use. Defaults to
+ * the main Edge database.
+ * @param {function} filterFn Optional. A function that is called for each row.
+ * Only rows for which it returns a truthy
+ * value are included in the result.
+ * @returns {Array} An array of row objects.
+ */
+function readTableFromEdgeDB(
+ tableName,
+ columns,
+ dbFile = gEdgeDatabase,
+ filterFn = null
+) {
+ let database;
+ let rows = [];
+ try {
+ let logFile = dbFile.parent;
+ logFile.append("LogFiles");
+ database = ESEDBReader.openDB(dbFile.parent, dbFile, logFile);
+
+ if (typeof columns == "function") {
+ columns = columns(database);
+ }
+
+ let tableReader = database.tableItems(tableName, columns);
+ for (let row of tableReader) {
+ if (!filterFn || filterFn(row)) {
+ rows.push(row);
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(
+ "Failed to extract items from table " +
+ tableName +
+ " in Edge database at " +
+ dbFile.path +
+ " due to the following error: " +
+ ex
+ );
+ // Deliberately make this fail so we expose failure in the UI:
+ throw ex;
+ } finally {
+ if (database) {
+ ESEDBReader.closeDB(database);
+ }
+ }
+ return rows;
+}
+
+function EdgeTypedURLMigrator() {}
+
+EdgeTypedURLMigrator.prototype = {
+ type: MigrationUtils.resourceTypes.HISTORY,
+
+ get _typedURLs() {
+ if (!this.__typedURLs) {
+ this.__typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot);
+ }
+ return this.__typedURLs;
+ },
+
+ get exists() {
+ return this._typedURLs.size > 0;
+ },
+
+ migrate(aCallback) {
+ let typedURLs = this._typedURLs;
+ let pageInfos = [];
+ for (let [urlString, time] of typedURLs) {
+ let url;
+ try {
+ url = new URL(urlString);
+ if (!["http:", "https:", "ftp:"].includes(url.protocol)) {
+ continue;
+ }
+ } catch (ex) {
+ Cu.reportError(ex);
+ continue;
+ }
+
+ pageInfos.push({
+ url,
+ visits: [
+ {
+ transition: PlacesUtils.history.TRANSITIONS.TYPED,
+ date: time ? PlacesUtils.toDate(time) : new Date(),
+ },
+ ],
+ });
+ }
+
+ if (!pageInfos.length) {
+ aCallback(typedURLs.size == 0);
+ return;
+ }
+
+ MigrationUtils.insertVisitsWrapper(pageInfos).then(
+ () => aCallback(true),
+ () => aCallback(false)
+ );
+ },
+};
+
+function EdgeTypedURLDBMigrator() {}
+
+EdgeTypedURLDBMigrator.prototype = {
+ type: MigrationUtils.resourceTypes.HISTORY,
+
+ get db() {
+ return gEdgeDatabase;
+ },
+
+ get exists() {
+ return !!this.db;
+ },
+
+ migrate(callback) {
+ this._migrateTypedURLsFromDB().then(
+ () => callback(true),
+ ex => {
+ Cu.reportError(ex);
+ callback(false);
+ }
+ );
+ },
+
+ async _migrateTypedURLsFromDB() {
+ if (await ESEDBReader.dbLocked(this.db)) {
+ throw new Error("Edge seems to be running - its database is locked.");
+ }
+ let columns = [
+ { name: "URL", type: "string" },
+ { name: "AccessDateTimeUTC", type: "date" },
+ ];
+
+ let typedUrls = [];
+ try {
+ typedUrls = readTableFromEdgeDB("TypedUrls", columns, this.db);
+ } catch (ex) {
+ // Maybe the table doesn't exist (older versions of Win10).
+ // Just fall through and we'll return because there's no data.
+ // The `readTableFromEdgeDB` helper will report errors to the
+ // console anyway.
+ }
+ if (!typedUrls.length) {
+ return;
+ }
+
+ let pageInfos = [];
+ // Sometimes the values are bogus (e.g. 0 becomes some date in 1600),
+ // and places will throw *everything* away, not just the bogus ones,
+ // so deal with that by having a cutoff date. Also, there's not much
+ // point importing really old entries. The cut-off date is related to
+ // Edge's launch date.
+ const kDateCutOff = new Date("2016", 0, 1);
+ for (let typedUrlInfo of typedUrls) {
+ try {
+ let url = new URL(typedUrlInfo.URL);
+ if (!["http:", "https:", "ftp:"].includes(url.protocol)) {
+ continue;
+ }
+
+ let date = typedUrlInfo.AccessDateTimeUTC;
+ if (!date) {
+ date = kDateCutOff;
+ } else if (date < kDateCutOff) {
+ continue;
+ }
+
+ pageInfos.push({
+ url,
+ visits: [
+ {
+ transition: PlacesUtils.history.TRANSITIONS.TYPED,
+ date,
+ },
+ ],
+ });
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ await MigrationUtils.insertVisitsWrapper(pageInfos);
+ },
+};
+
+function EdgeReadingListMigrator(dbOverride) {
+ this.dbOverride = dbOverride;
+}
+
+EdgeReadingListMigrator.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ get db() {
+ return this.dbOverride || gEdgeDatabase;
+ },
+
+ get exists() {
+ return !!this.db;
+ },
+
+ migrate(callback) {
+ this._migrateReadingList(PlacesUtils.bookmarks.menuGuid).then(
+ () => callback(true),
+ ex => {
+ Cu.reportError(ex);
+ callback(false);
+ }
+ );
+ },
+
+ async _migrateReadingList(parentGuid) {
+ if (await ESEDBReader.dbLocked(this.db)) {
+ throw new Error("Edge seems to be running - its database is locked.");
+ }
+ let columnFn = db => {
+ let columns = [
+ { name: "URL", type: "string" },
+ { name: "Title", type: "string" },
+ { name: "AddedDate", type: "date" },
+ ];
+
+ // Later versions have an IsDeleted column:
+ let isDeletedColumn = db.checkForColumn("ReadingList", "IsDeleted");
+ if (
+ isDeletedColumn &&
+ isDeletedColumn.dbType == ESEDBReader.COLUMN_TYPES.JET_coltypBit
+ ) {
+ columns.push({ name: "IsDeleted", type: "boolean" });
+ }
+ return columns;
+ };
+
+ let filterFn = row => {
+ return !row.IsDeleted;
+ };
+
+ let readingListItems = readTableFromEdgeDB(
+ "ReadingList",
+ columnFn,
+ this.db,
+ filterFn
+ );
+ if (!readingListItems.length) {
+ return;
+ }
+
+ let destFolderGuid = await this._ensureReadingListFolder(parentGuid);
+ let bookmarks = [];
+ for (let item of readingListItems) {
+ let dateAdded = item.AddedDate || new Date();
+ // Avoid including broken URLs:
+ try {
+ new URL(item.URL);
+ } catch (ex) {
+ continue;
+ }
+ bookmarks.push({ url: item.URL, title: item.Title, dateAdded });
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(bookmarks, destFolderGuid);
+ },
+
+ async _ensureReadingListFolder(parentGuid) {
+ if (!this.__readingListFolderGuid) {
+ let folderTitle = await MigrationUtils.getLocalizedString(
+ "imported-edge-reading-list"
+ );
+ let folderSpec = {
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid,
+ title: folderTitle,
+ };
+ this.__readingListFolderGuid = (
+ await MigrationUtils.insertBookmarkWrapper(folderSpec)
+ ).guid;
+ }
+ return this.__readingListFolderGuid;
+ },
+};
+
+function EdgeBookmarksMigrator(dbOverride) {
+ this.dbOverride = dbOverride;
+}
+
+EdgeBookmarksMigrator.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ get db() {
+ return this.dbOverride || gEdgeDatabase;
+ },
+
+ get TABLE_NAME() {
+ return "Favorites";
+ },
+
+ get exists() {
+ if (!("_exists" in this)) {
+ this._exists = !!this.db;
+ }
+ return this._exists;
+ },
+
+ migrate(callback) {
+ this._migrateBookmarks().then(
+ () => callback(true),
+ ex => {
+ Cu.reportError(ex);
+ callback(false);
+ }
+ );
+ },
+
+ async _migrateBookmarks() {
+ if (await ESEDBReader.dbLocked(this.db)) {
+ throw new Error("Edge seems to be running - its database is locked.");
+ }
+ let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB();
+ let histogramBookmarkRoots = 0;
+ if (toplevelBMs.length) {
+ histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU;
+ let parentGuid = PlacesUtils.bookmarks.menuGuid;
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(parentGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ parentGuid = await MigrationUtils.createImportedBookmarksFolder(
+ "Edge",
+ parentGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid);
+ }
+ if (toolbarBMs.length) {
+ histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ let parentGuid = PlacesUtils.bookmarks.toolbarGuid;
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(parentGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ parentGuid = await MigrationUtils.createImportedBookmarksFolder(
+ "Edge",
+ parentGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid);
+ PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration();
+ }
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS")
+ .add("edge", histogramBookmarkRoots);
+ },
+
+ _fetchBookmarksFromDB() {
+ let folderMap = new Map();
+ let columns = [
+ { name: "URL", type: "string" },
+ { name: "Title", type: "string" },
+ { name: "DateUpdated", type: "date" },
+ { name: "IsFolder", type: "boolean" },
+ { name: "IsDeleted", type: "boolean" },
+ { name: "ParentId", type: "guid" },
+ { name: "ItemId", type: "guid" },
+ ];
+ let filterFn = row => {
+ if (row.IsDeleted) {
+ return false;
+ }
+ if (row.IsFolder) {
+ folderMap.set(row.ItemId, row);
+ }
+ return true;
+ };
+ let bookmarks = readTableFromEdgeDB(
+ this.TABLE_NAME,
+ columns,
+ this.db,
+ filterFn
+ );
+ let toplevelBMs = [],
+ toolbarBMs = [];
+ for (let bookmark of bookmarks) {
+ let bmToInsert;
+ // Ignore invalid URLs:
+ if (!bookmark.IsFolder) {
+ try {
+ new URL(bookmark.URL);
+ } catch (ex) {
+ Cu.reportError(
+ `Ignoring ${bookmark.URL} when importing from Edge because of exception: ${ex}`
+ );
+ continue;
+ }
+ bmToInsert = {
+ dateAdded: bookmark.DateUpdated || new Date(),
+ title: bookmark.Title,
+ url: bookmark.URL,
+ };
+ } /* bookmark.IsFolder */ else {
+ // Ignore the favorites bar bookmark itself.
+ if (bookmark.Title == "_Favorites_Bar_") {
+ continue;
+ }
+ if (!bookmark._childrenRef) {
+ bookmark._childrenRef = [];
+ }
+ bmToInsert = {
+ title: bookmark.Title,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ dateAdded: bookmark.DateUpdated || new Date(),
+ children: bookmark._childrenRef,
+ };
+ }
+
+ if (!folderMap.has(bookmark.ParentId)) {
+ toplevelBMs.push(bmToInsert);
+ } else {
+ let parent = folderMap.get(bookmark.ParentId);
+ if (parent.Title == "_Favorites_Bar_") {
+ toolbarBMs.push(bmToInsert);
+ continue;
+ }
+ if (!parent._childrenRef) {
+ parent._childrenRef = [];
+ }
+ parent._childrenRef.push(bmToInsert);
+ }
+ }
+ return { toplevelBMs, toolbarBMs };
+ },
+};
+
+function EdgeProfileMigrator() {
+ this.wrappedJSObject = this;
+}
+
+EdgeProfileMigrator.prototype = Object.create(MigratorPrototype);
+
+EdgeProfileMigrator.prototype.getBookmarksMigratorForTesting = function(
+ dbOverride
+) {
+ return new EdgeBookmarksMigrator(dbOverride);
+};
+
+EdgeProfileMigrator.prototype.getReadingListMigratorForTesting = function(
+ dbOverride
+) {
+ return new EdgeReadingListMigrator(dbOverride);
+};
+
+EdgeProfileMigrator.prototype.getResources = function() {
+ let resources = [
+ new EdgeBookmarksMigrator(),
+ MSMigrationUtils.getCookiesMigrator(MSMigrationUtils.MIGRATION_TYPE_EDGE),
+ new EdgeTypedURLMigrator(),
+ new EdgeTypedURLDBMigrator(),
+ new EdgeReadingListMigrator(),
+ ];
+ let windowsVaultFormPasswordsMigrator = MSMigrationUtils.getWindowsVaultFormPasswordsMigrator();
+ windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords";
+ resources.push(windowsVaultFormPasswordsMigrator);
+ return resources.filter(r => r.exists);
+};
+
+EdgeProfileMigrator.prototype.getLastUsedDate = async function() {
+ // Don't do this if we don't have a single profile (see the comment for
+ // sourceProfiles) or if we can't find the database file:
+ let sourceProfiles = await this.getSourceProfiles();
+ if (sourceProfiles !== null || !gEdgeDatabase) {
+ return Promise.resolve(new Date(0));
+ }
+ let logFilePath = OS.Path.join(
+ gEdgeDatabase.parent.path,
+ "LogFiles",
+ "edb.log"
+ );
+ let dbPath = gEdgeDatabase.path;
+ let cookieMigrator = MSMigrationUtils.getCookiesMigrator(
+ MSMigrationUtils.MIGRATION_TYPE_EDGE
+ );
+ let cookiePaths = cookieMigrator._cookiesFolders.map(f => f.path);
+ let datePromises = [logFilePath, dbPath, ...cookiePaths].map(path => {
+ return OS.File.stat(path)
+ .catch(() => null)
+ .then(info => {
+ return info ? info.lastModificationDate : 0;
+ });
+ });
+ datePromises.push(
+ new Promise(resolve => {
+ let typedURLs = new Map();
+ try {
+ typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot);
+ } catch (ex) {}
+ let times = [0, ...typedURLs.values()];
+ // dates is an array of PRTimes, which are in microseconds - convert to milliseconds
+ resolve(Math.max.apply(Math, times) / 1000);
+ })
+ );
+ return Promise.all(datePromises).then(dates => {
+ return new Date(Math.max.apply(Math, dates));
+ });
+};
+
+/* Somewhat counterintuitively, this returns:
+ * - |null| to indicate "There is only 1 (default) profile" (on win10+)
+ * - |[]| to indicate "There are no profiles" (on <=win8.1) which will avoid using this migrator.
+ * See MigrationUtils.jsm for slightly more info on how sourceProfiles is used.
+ */
+EdgeProfileMigrator.prototype.getSourceProfiles = function() {
+ let isWin10OrHigher = AppConstants.isPlatformAndVersionAtLeast("win", "10");
+ return isWin10OrHigher ? null : [];
+};
+
+EdgeProfileMigrator.prototype.__defineGetter__("sourceLocked", function() {
+ // There is an exclusive lock on some databases. Assume they are locked for now.
+ return true;
+});
+
+EdgeProfileMigrator.prototype.classDescription = "Edge Profile Migrator";
+EdgeProfileMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=edge";
+EdgeProfileMigrator.prototype.classID = Components.ID(
+ "{62e8834b-2d17-49f5-96ff-56344903a2ae}"
+);
+
+var EXPORTED_SYMBOLS = ["EdgeProfileMigrator"];
diff --git a/browser/components/migration/FirefoxProfileMigrator.jsm b/browser/components/migration/FirefoxProfileMigrator.jsm
new file mode 100644
index 0000000000..bba617374f
--- /dev/null
+++ b/browser/components/migration/FirefoxProfileMigrator.jsm
@@ -0,0 +1,383 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set 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";
+
+/*
+ * Migrates from a Firefox profile in a lossy manner in order to clean up a
+ * user's profile. Data is only migrated where the benefits outweigh the
+ * potential problems caused by importing undesired/invalid configurations
+ * from the source profile.
+ */
+
+const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesBackups",
+ "resource://gre/modules/PlacesBackups.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "SessionMigration",
+ "resource:///modules/sessionstore/SessionMigration.jsm"
+);
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ProfileAge",
+ "resource://gre/modules/ProfileAge.jsm"
+);
+
+function FirefoxProfileMigrator() {
+ this.wrappedJSObject = this; // for testing...
+}
+
+FirefoxProfileMigrator.prototype = Object.create(MigratorPrototype);
+
+FirefoxProfileMigrator.prototype._getAllProfiles = function() {
+ let allProfiles = new Map();
+ let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService(
+ Ci.nsIToolkitProfileService
+ );
+ for (let profile of profileService.profiles) {
+ let rootDir = profile.rootDir;
+
+ if (
+ rootDir.exists() &&
+ rootDir.isReadable() &&
+ !rootDir.equals(MigrationUtils.profileStartup.directory)
+ ) {
+ allProfiles.set(profile.name, rootDir);
+ }
+ }
+ return allProfiles;
+};
+
+function sorter(a, b) {
+ return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase());
+}
+
+FirefoxProfileMigrator.prototype.getSourceProfiles = function() {
+ return [...this._getAllProfiles().keys()]
+ .map(x => ({ id: x, name: x }))
+ .sort(sorter);
+};
+
+FirefoxProfileMigrator.prototype._getFileObject = function(dir, fileName) {
+ let file = dir.clone();
+ file.append(fileName);
+
+ // File resources are monolithic. We don't make partial copies since
+ // they are not expected to work alone. Return null to avoid trying to
+ // copy non-existing files.
+ return file.exists() ? file : null;
+};
+
+FirefoxProfileMigrator.prototype.getResources = function(aProfile) {
+ let sourceProfileDir = aProfile
+ ? this._getAllProfiles().get(aProfile.id)
+ : Cc["@mozilla.org/toolkit/profile-service;1"].getService(
+ Ci.nsIToolkitProfileService
+ ).defaultProfile.rootDir;
+ if (
+ !sourceProfileDir ||
+ !sourceProfileDir.exists() ||
+ !sourceProfileDir.isReadable()
+ ) {
+ return null;
+ }
+
+ // Being a startup-only migrator, we can rely on
+ // MigrationUtils.profileStartup being set.
+ let currentProfileDir = MigrationUtils.profileStartup.directory;
+
+ // Surely data cannot be imported from the current profile.
+ if (sourceProfileDir.equals(currentProfileDir)) {
+ return null;
+ }
+
+ return this._getResourcesInternal(sourceProfileDir, currentProfileDir);
+};
+
+FirefoxProfileMigrator.prototype.getLastUsedDate = function() {
+ // We always pretend we're really old, so that we don't mess
+ // up the determination of which browser is the most 'recent'
+ // to import from.
+ return Promise.resolve(new Date(0));
+};
+
+FirefoxProfileMigrator.prototype._getResourcesInternal = function(
+ sourceProfileDir,
+ currentProfileDir
+) {
+ let getFileResource = (aMigrationType, aFileNames) => {
+ let files = [];
+ for (let fileName of aFileNames) {
+ let file = this._getFileObject(sourceProfileDir, fileName);
+ if (file) {
+ files.push(file);
+ }
+ }
+ if (!files.length) {
+ return null;
+ }
+ return {
+ type: aMigrationType,
+ migrate(aCallback) {
+ for (let file of files) {
+ file.copyTo(currentProfileDir, "");
+ }
+ aCallback(true);
+ },
+ };
+ };
+
+ function savePrefs() {
+ // If we've used the pref service to write prefs for the new profile, it's too
+ // early in startup for the service to have a profile directory, so we have to
+ // manually tell it where to save the prefs file.
+ let newPrefsFile = currentProfileDir.clone();
+ newPrefsFile.append("prefs.js");
+ Services.prefs.savePrefFile(newPrefsFile);
+ }
+
+ let types = MigrationUtils.resourceTypes;
+ let places = getFileResource(types.HISTORY, [
+ "places.sqlite",
+ "places.sqlite-wal",
+ ]);
+ let favicons = getFileResource(types.HISTORY, [
+ "favicons.sqlite",
+ "favicons.sqlite-wal",
+ ]);
+ let cookies = getFileResource(types.COOKIES, [
+ "cookies.sqlite",
+ "cookies.sqlite-wal",
+ ]);
+ let passwords = getFileResource(types.PASSWORDS, [
+ "signons.sqlite",
+ "logins.json",
+ "key3.db",
+ "key4.db",
+ ]);
+ let formData = getFileResource(types.FORMDATA, [
+ "formhistory.sqlite",
+ "autofill-profiles.json",
+ ]);
+ let bookmarksBackups = getFileResource(types.OTHERDATA, [
+ PlacesBackups.profileRelativeFolderPath,
+ ]);
+ let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]);
+
+ let session;
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ if (env.get("MOZ_RESET_PROFILE_MIGRATE_SESSION")) {
+ // We only want to restore the previous firefox session if the profile refresh was
+ // triggered by user. The MOZ_RESET_PROFILE_MIGRATE_SESSION would be set when a user-triggered
+ // profile refresh happened in nsAppRunner.cpp. Hence, we detect the MOZ_RESET_PROFILE_MIGRATE_SESSION
+ // to see if session data migration is required.
+ env.set("MOZ_RESET_PROFILE_MIGRATE_SESSION", "");
+ let sessionCheckpoints = this._getFileObject(
+ sourceProfileDir,
+ "sessionCheckpoints.json"
+ );
+ let sessionFile = this._getFileObject(
+ sourceProfileDir,
+ "sessionstore.jsonlz4"
+ );
+ if (sessionFile) {
+ session = {
+ type: types.SESSION,
+ migrate(aCallback) {
+ sessionCheckpoints.copyTo(
+ currentProfileDir,
+ "sessionCheckpoints.json"
+ );
+ let newSessionFile = currentProfileDir.clone();
+ newSessionFile.append("sessionstore.jsonlz4");
+ let migrationPromise = SessionMigration.migrate(
+ sessionFile.path,
+ newSessionFile.path
+ );
+ migrationPromise.then(
+ function() {
+ let buildID = Services.appinfo.platformBuildID;
+ let mstone = Services.appinfo.platformVersion;
+ // Force the browser to one-off resume the session that we give it:
+ Services.prefs.setBoolPref(
+ "browser.sessionstore.resume_session_once",
+ true
+ );
+ // Reset the homepage_override prefs so that the browser doesn't override our
+ // session with the "what's new" page:
+ Services.prefs.setCharPref(
+ "browser.startup.homepage_override.mstone",
+ mstone
+ );
+ Services.prefs.setCharPref(
+ "browser.startup.homepage_override.buildID",
+ buildID
+ );
+ savePrefs();
+ aCallback(true);
+ },
+ function() {
+ aCallback(false);
+ }
+ );
+ },
+ };
+ }
+ }
+
+ // Sync/FxA related data
+ let sync = {
+ name: "sync", // name is used only by tests.
+ type: types.OTHERDATA,
+ migrate: async aCallback => {
+ // Try and parse a signedInUser.json file from the source directory and
+ // if we can, copy it to the new profile and set sync's username pref
+ // (which acts as a de-facto flag to indicate if sync is configured)
+ try {
+ let oldPath = OS.Path.join(sourceProfileDir.path, "signedInUser.json");
+ let exists = await OS.File.exists(oldPath);
+ if (exists) {
+ let raw = await OS.File.read(oldPath, { encoding: "utf-8" });
+ let data = JSON.parse(raw);
+ if (data && data.accountData && data.accountData.email) {
+ let username = data.accountData.email;
+ // copy the file itself.
+ await OS.File.copy(
+ oldPath,
+ OS.Path.join(currentProfileDir.path, "signedInUser.json")
+ );
+ // Now we need to know whether Sync is actually configured for this
+ // user. The only way we know is by looking at the prefs file from
+ // the old profile. We avoid trying to do a full parse of the prefs
+ // file and even avoid parsing the single string value we care
+ // about.
+ let prefsPath = OS.Path.join(sourceProfileDir.path, "prefs.js");
+ if (await OS.File.exists(oldPath)) {
+ let rawPrefs = await OS.File.read(prefsPath, {
+ encoding: "utf-8",
+ });
+ if (/^user_pref\("services\.sync\.username"/m.test(rawPrefs)) {
+ // sync's configured in the source profile - ensure it is in the
+ // new profile too.
+ // Write it to prefs.js and flush the file.
+ Services.prefs.setStringPref(
+ "services.sync.username",
+ username
+ );
+ savePrefs();
+ }
+ }
+ }
+ }
+ } catch (ex) {
+ aCallback(false);
+ return;
+ }
+ aCallback(true);
+ },
+ };
+
+ // Telemetry related migrations.
+ let times = {
+ name: "times", // name is used only by tests.
+ type: types.OTHERDATA,
+ migrate: aCallback => {
+ let file = this._getFileObject(sourceProfileDir, "times.json");
+ if (file) {
+ file.copyTo(currentProfileDir, "");
+ }
+ // And record the fact a migration (ie, a reset) happened.
+ let recordMigration = async () => {
+ try {
+ let profileTimes = await ProfileAge(currentProfileDir.path);
+ await profileTimes.recordProfileReset();
+ aCallback(true);
+ } catch (e) {
+ aCallback(false);
+ }
+ };
+
+ recordMigration();
+ },
+ };
+ let telemetry = {
+ name: "telemetry", // name is used only by tests...
+ type: types.OTHERDATA,
+ migrate: aCallback => {
+ let createSubDir = name => {
+ let dir = currentProfileDir.clone();
+ dir.append(name);
+ dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ return dir;
+ };
+
+ // If the 'datareporting' directory exists we migrate files from it.
+ let dataReportingDir = this._getFileObject(
+ sourceProfileDir,
+ "datareporting"
+ );
+ if (dataReportingDir && dataReportingDir.isDirectory()) {
+ // Copy only specific files.
+ let toCopy = ["state.json", "session-state.json"];
+
+ let dest = createSubDir("datareporting");
+ let enumerator = dataReportingDir.directoryEntries;
+ while (enumerator.hasMoreElements()) {
+ let file = enumerator.nextFile;
+ if (file.isDirectory() || !toCopy.includes(file.leafName)) {
+ continue;
+ }
+ file.copyTo(dest, "");
+ }
+ }
+
+ aCallback(true);
+ },
+ };
+
+ return [
+ places,
+ cookies,
+ passwords,
+ formData,
+ dictionary,
+ bookmarksBackups,
+ session,
+ sync,
+ times,
+ telemetry,
+ favicons,
+ ].filter(r => r);
+};
+
+Object.defineProperty(FirefoxProfileMigrator.prototype, "startupOnlyMigrator", {
+ get: () => true,
+});
+
+FirefoxProfileMigrator.prototype.classDescription = "Firefox Profile Migrator";
+FirefoxProfileMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=firefox";
+FirefoxProfileMigrator.prototype.classID = Components.ID(
+ "{91185366-ba97-4438-acba-48deaca63386}"
+);
+
+var EXPORTED_SYMBOLS = ["FirefoxProfileMigrator"];
diff --git a/browser/components/migration/IEProfileMigrator.jsm b/browser/components/migration/IEProfileMigrator.jsm
new file mode 100644
index 0000000000..ca02b40acf
--- /dev/null
+++ b/browser/components/migration/IEProfileMigrator.jsm
@@ -0,0 +1,416 @@
+/* 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 kLoginsKey =
+ "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+const { MSMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MSMigrationUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ctypes",
+ "resource://gre/modules/ctypes.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "OSCrypto",
+ "resource://gre/modules/OSCrypto.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+// Resources
+
+function History() {}
+
+History.prototype = {
+ type: MigrationUtils.resourceTypes.HISTORY,
+
+ get exists() {
+ return true;
+ },
+
+ migrate: function H_migrate(aCallback) {
+ let pageInfos = [];
+ let typedURLs = MSMigrationUtils.getTypedURLs(
+ "Software\\Microsoft\\Internet Explorer"
+ );
+ for (let entry of Cc[
+ "@mozilla.org/profile/migrator/iehistoryenumerator;1"
+ ].createInstance(Ci.nsISimpleEnumerator)) {
+ let url = entry.get("uri").QueryInterface(Ci.nsIURI);
+ // MSIE stores some types of URLs in its history that we don't handle,
+ // like HTMLHelp and others. Since we don't properly map handling for
+ // all of them we just avoid importing them.
+ if (!["http", "https", "ftp", "file"].includes(url.scheme)) {
+ continue;
+ }
+
+ let title = entry.get("title");
+ // Embed visits have no title and don't need to be imported.
+ if (!title.length) {
+ continue;
+ }
+
+ // The typed urls are already fixed-up, so we can use them for comparison.
+ let transition = typedURLs.has(url.spec)
+ ? PlacesUtils.history.TRANSITIONS.LINK
+ : PlacesUtils.history.TRANSITIONS.TYPED;
+ // use the current date if we have no visits for this entry.
+ let time = entry.get("time");
+
+ pageInfos.push({
+ url,
+ title,
+ visits: [
+ {
+ transition,
+ date: time ? PlacesUtils.toDate(entry.get("time")) : new Date(),
+ },
+ ],
+ });
+ }
+
+ // Check whether there is any history to import.
+ if (!pageInfos.length) {
+ aCallback(true);
+ return;
+ }
+
+ MigrationUtils.insertVisitsWrapper(pageInfos).then(
+ () => aCallback(true),
+ () => aCallback(false)
+ );
+ },
+};
+
+// IE form password migrator supporting windows from XP until 7 and IE from 7 until 11
+function IE7FormPasswords() {
+ // used to distinguish between this migrator and other passwords migrators in tests.
+ this.name = "IE7FormPasswords";
+}
+
+IE7FormPasswords.prototype = {
+ type: MigrationUtils.resourceTypes.PASSWORDS,
+
+ get exists() {
+ // work only on windows until 7
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return false;
+ }
+
+ try {
+ let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kLoginsKey,
+ nsIWindowsRegKey.ACCESS_READ
+ );
+ let count = key.valueCount;
+ key.close();
+ return count > 0;
+ } catch (e) {
+ return false;
+ }
+ },
+
+ async migrate(aCallback) {
+ let uris = []; // the uris of the websites that are going to be migrated
+ for (let entry of Cc[
+ "@mozilla.org/profile/migrator/iehistoryenumerator;1"
+ ].createInstance(Ci.nsISimpleEnumerator)) {
+ let uri = entry.get("uri").QueryInterface(Ci.nsIURI);
+ // MSIE stores some types of URLs in its history that we don't handle, like HTMLHelp
+ // and others. Since we are not going to import the logins that are performed in these URLs
+ // we can just skip them.
+ if (!["http", "https", "ftp"].includes(uri.scheme)) {
+ continue;
+ }
+
+ uris.push(uri);
+ }
+ await this._migrateURIs(uris);
+ aCallback(true);
+ },
+
+ /**
+ * Migrate the logins that were saved for the uris arguments.
+ * @param {nsIURI[]} uris - the uris that are going to be migrated.
+ */
+ async _migrateURIs(uris) {
+ this.ctypesKernelHelpers = new MSMigrationUtils.CtypesKernelHelpers();
+ this._crypto = new OSCrypto();
+ let nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+ let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kLoginsKey,
+ nsIWindowsRegKey.ACCESS_READ
+ );
+
+ let urlsSet = new Set(); // set of the already processed urls.
+ // number of the successfully decrypted registry values
+ let successfullyDecryptedValues = 0;
+ /* The logins are stored in the registry, where the key is a hashed URL and its
+ * value contains the encrypted details for all logins for that URL.
+ *
+ * First iterate through IE history, hashing each URL and looking for a match. If
+ * found, decrypt the value, using the URL as a salt. Finally add any found logins
+ * to the Firefox password manager.
+ */
+
+ let logins = [];
+ for (let uri of uris) {
+ try {
+ // remove the query and the ref parts of the URL
+ let urlObject = new URL(uri.spec);
+ let url = urlObject.origin + urlObject.pathname;
+ // if the current url is already processed, it should be skipped
+ if (urlsSet.has(url)) {
+ continue;
+ }
+ urlsSet.add(url);
+ // hash value of the current uri
+ let hashStr = this._crypto.getIELoginHash(url);
+ if (!key.hasValue(hashStr)) {
+ continue;
+ }
+ let value = key.readBinaryValue(hashStr);
+ // if no value was found, the uri is skipped
+ if (value == null) {
+ continue;
+ }
+ let data;
+ try {
+ // the url is used as salt to decrypt the registry value
+ data = this._crypto.decryptData(value, url);
+ } catch (e) {
+ continue;
+ }
+ // extract the login details from the decrypted data
+ let ieLogins = this._extractDetails(data, uri);
+ // if at least a credential was found in the current data, successfullyDecryptedValues should
+ // be incremented by one
+ if (ieLogins.length) {
+ successfullyDecryptedValues++;
+ }
+ for (let ieLogin of ieLogins) {
+ logins.push({
+ username: ieLogin.username,
+ password: ieLogin.password,
+ origin: ieLogin.url,
+ timeCreated: ieLogin.creation,
+ });
+ }
+ } catch (e) {
+ Cu.reportError(
+ "Error while importing logins for " + uri.spec + ": " + e
+ );
+ }
+ }
+
+ if (logins.length) {
+ await MigrationUtils.insertLoginsWrapper(logins);
+ }
+
+ // if the number of the imported values is less than the number of values in the key, it means
+ // that not all the values were imported and an error should be reported
+ if (successfullyDecryptedValues < key.valueCount) {
+ Cu.reportError(
+ "We failed to decrypt and import some logins. " +
+ "This is likely because we didn't find the URLs where these " +
+ "passwords were submitted in the IE history and which are needed to be used " +
+ "as keys in the decryption."
+ );
+ }
+
+ key.close();
+ this._crypto.finalize();
+ this.ctypesKernelHelpers.finalize();
+ },
+
+ _crypto: null,
+
+ /**
+ * Extract the details of one or more logins from the raw decrypted data.
+ * @param {string} data - the decrypted data containing raw information.
+ * @param {nsURI} uri - the nsURI of page where the login has occur.
+ * @returns {Object[]} array of objects where each of them contains the username, password, URL,
+ * and creation time representing all the logins found in the data arguments.
+ */
+ _extractDetails(data, uri) {
+ // the structure of the header of the IE7 decrypted data for all the logins sharing the same URL
+ let loginData = new ctypes.StructType("loginData", [
+ // Bytes 0-3 are not needed and not documented
+ { unknown1: ctypes.uint32_t },
+ // Bytes 4-7 are the header size
+ { headerSize: ctypes.uint32_t },
+ // Bytes 8-11 are the data size
+ { dataSize: ctypes.uint32_t },
+ // Bytes 12-19 are not needed and not documented
+ { unknown2: ctypes.uint32_t },
+ { unknown3: ctypes.uint32_t },
+ // Bytes 20-23 are the data count: each username and password is considered as a data
+ { dataMax: ctypes.uint32_t },
+ // Bytes 24-35 are not needed and not documented
+ { unknown4: ctypes.uint32_t },
+ { unknown5: ctypes.uint32_t },
+ { unknown6: ctypes.uint32_t },
+ ]);
+
+ // the structure of a IE7 decrypted login item
+ let loginItem = new ctypes.StructType("loginItem", [
+ // Bytes 0-3 are the offset of the username
+ { usernameOffset: ctypes.uint32_t },
+ // Bytes 4-11 are the date
+ { loDateTime: ctypes.uint32_t },
+ { hiDateTime: ctypes.uint32_t },
+ // Bytes 12-15 are not needed and not documented
+ { foo: ctypes.uint32_t },
+ // Bytes 16-19 are the offset of the password
+ { passwordOffset: ctypes.uint32_t },
+ // Bytes 20-31 are not needed and not documented
+ { unknown1: ctypes.uint32_t },
+ { unknown2: ctypes.uint32_t },
+ { unknown3: ctypes.uint32_t },
+ ]);
+
+ let url = uri.prePath;
+ let results = [];
+ let arr = this._crypto.stringToArray(data);
+ // convert data to ctypes.unsigned_char.array(arr.length)
+ let cdata = ctypes.unsigned_char.array(arr.length)(arr);
+ // Bytes 0-35 contain the loginData data structure for all the logins sharing the same URL
+ let currentLoginData = ctypes.cast(cdata, loginData);
+ let headerSize = currentLoginData.headerSize;
+ let currentInfoIndex = loginData.size;
+ // pointer to the current login item
+ let currentLoginItemPointer = ctypes.cast(
+ cdata.addressOfElement(currentInfoIndex),
+ loginItem.ptr
+ );
+ // currentLoginData.dataMax is the data count: each username and password is considered as
+ // a data. So, the number of logins is the number of data dived by 2
+ let numLogins = currentLoginData.dataMax / 2;
+ for (let n = 0; n < numLogins; n++) {
+ // Bytes 0-31 starting from currentInfoIndex contain the loginItem data structure for the
+ // current login
+ let currentLoginItem = currentLoginItemPointer.contents;
+ let creation =
+ this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch(
+ currentLoginItem.hiDateTime,
+ currentLoginItem.loDateTime
+ ) * 1000;
+ let currentResult = {
+ creation,
+ url,
+ };
+ // The username is UTF-16 and null-terminated.
+ currentResult.username = ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.usernameOffset
+ ),
+ ctypes.char16_t.ptr
+ )
+ .readString();
+ // The password is UTF-16 and null-terminated.
+ currentResult.password = ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.passwordOffset
+ ),
+ ctypes.char16_t.ptr
+ )
+ .readString();
+ results.push(currentResult);
+ // move to the next login item
+ currentLoginItemPointer = currentLoginItemPointer.increment();
+ }
+ return results;
+ },
+};
+
+function IEProfileMigrator() {
+ this.wrappedJSObject = this; // export this to be able to use it in the unittest.
+}
+
+IEProfileMigrator.prototype = Object.create(MigratorPrototype);
+
+IEProfileMigrator.prototype.getResources = function IE_getResources() {
+ let resources = [
+ MSMigrationUtils.getBookmarksMigrator(),
+ new History(),
+ MSMigrationUtils.getCookiesMigrator(),
+ ];
+ // Only support the form password migrator for Windows XP to 7.
+ if (AppConstants.isPlatformAndVersionAtMost("win", "6.1")) {
+ resources.push(new IE7FormPasswords());
+ }
+ let windowsVaultFormPasswordsMigrator = MSMigrationUtils.getWindowsVaultFormPasswordsMigrator();
+ windowsVaultFormPasswordsMigrator.name = "IEVaultFormPasswords";
+ resources.push(windowsVaultFormPasswordsMigrator);
+ return resources.filter(r => r.exists);
+};
+
+IEProfileMigrator.prototype.getLastUsedDate = function IE_getLastUsedDate() {
+ let datePromises = ["Favs", "CookD"].map(dirId => {
+ let { path } = Services.dirsvc.get(dirId, Ci.nsIFile);
+ return OS.File.stat(path)
+ .catch(() => null)
+ .then(info => {
+ return info ? info.lastModificationDate : 0;
+ });
+ });
+ datePromises.push(
+ new Promise(resolve => {
+ let typedURLs = new Map();
+ try {
+ typedURLs = MSMigrationUtils.getTypedURLs(
+ "Software\\Microsoft\\Internet Explorer"
+ );
+ } catch (ex) {}
+ let dates = [0, ...typedURLs.values()];
+ // dates is an array of PRTimes, which are in microseconds - convert to milliseconds
+ resolve(Math.max.apply(Math, dates) / 1000);
+ })
+ );
+ return Promise.all(datePromises).then(dates => {
+ return new Date(Math.max.apply(Math, dates));
+ });
+};
+
+IEProfileMigrator.prototype.classDescription = "IE Profile Migrator";
+IEProfileMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=ie";
+IEProfileMigrator.prototype.classID = Components.ID(
+ "{3d2532e3-4932-4774-b7ba-968f5899d3a4}"
+);
+
+var EXPORTED_SYMBOLS = ["IEProfileMigrator"];
diff --git a/browser/components/migration/MSMigrationUtils.jsm b/browser/components/migration/MSMigrationUtils.jsm
new file mode 100644
index 0000000000..8826b2ea25
--- /dev/null
+++ b/browser/components/migration/MSMigrationUtils.jsm
@@ -0,0 +1,1041 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["MSMigrationUtils"];
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { MigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["FileReader"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUIUtils",
+ "resource:///modules/PlacesUIUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "WindowsRegistry",
+ "resource://gre/modules/WindowsRegistry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ctypes",
+ "resource://gre/modules/ctypes.jsm"
+);
+
+const EDGE_COOKIE_PATH_OPTIONS = ["", "#!001\\", "#!002\\"];
+const EDGE_COOKIES_SUFFIX = "MicrosoftEdge\\Cookies";
+const EDGE_FAVORITES = "AC\\MicrosoftEdge\\User\\Default\\Favorites";
+const FREE_CLOSE_FAILED = 0;
+const INTERNET_EXPLORER_EDGE_GUID = [
+ 0x3ccd5499,
+ 0x4b1087a8,
+ 0x886015a2,
+ 0x553bdd88,
+];
+const RESULT_SUCCESS = 0;
+const VAULT_ENUMERATE_ALL_ITEMS = 512;
+const WEB_CREDENTIALS_VAULT_ID = [
+ 0x4bf4c442,
+ 0x41a09b8a,
+ 0x4add80b3,
+ 0x28db4d70,
+];
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["File"]);
+
+const wintypes = {
+ BOOL: ctypes.int,
+ DWORD: ctypes.uint32_t,
+ DWORDLONG: ctypes.uint64_t,
+ CHAR: ctypes.char,
+ PCHAR: ctypes.char.ptr,
+ LPCWSTR: ctypes.char16_t.ptr,
+ PDWORD: ctypes.uint32_t.ptr,
+ VOIDP: ctypes.voidptr_t,
+ WORD: ctypes.uint16_t,
+};
+
+// TODO: Bug 1202978 - Refactor MSMigrationUtils ctypes helpers
+function CtypesKernelHelpers() {
+ this._structs = {};
+ this._functions = {};
+ this._libs = {};
+
+ this._structs.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [
+ { wYear: wintypes.WORD },
+ { wMonth: wintypes.WORD },
+ { wDayOfWeek: wintypes.WORD },
+ { wDay: wintypes.WORD },
+ { wHour: wintypes.WORD },
+ { wMinute: wintypes.WORD },
+ { wSecond: wintypes.WORD },
+ { wMilliseconds: wintypes.WORD },
+ ]);
+
+ this._structs.FILETIME = new ctypes.StructType("FILETIME", [
+ { dwLowDateTime: wintypes.DWORD },
+ { dwHighDateTime: wintypes.DWORD },
+ ]);
+
+ try {
+ this._libs.kernel32 = ctypes.open("Kernel32");
+
+ this._functions.FileTimeToSystemTime = this._libs.kernel32.declare(
+ "FileTimeToSystemTime",
+ ctypes.winapi_abi,
+ wintypes.BOOL,
+ this._structs.FILETIME.ptr,
+ this._structs.SYSTEMTIME.ptr
+ );
+ } catch (ex) {
+ this.finalize();
+ }
+}
+
+CtypesKernelHelpers.prototype = {
+ /**
+ * Must be invoked once after last use of any of the provided helpers.
+ */
+ finalize() {
+ this._structs = {};
+ this._functions = {};
+ for (let key in this._libs) {
+ let lib = this._libs[key];
+ try {
+ lib.close();
+ } catch (ex) {}
+ }
+ this._libs = {};
+ },
+
+ /**
+ * Converts a FILETIME struct (2 DWORDS), to a SYSTEMTIME struct,
+ * and then deduces the number of seconds since the epoch (which
+ * is the data we want for the cookie expiry date).
+ *
+ * @param aTimeHi
+ * Least significant DWORD.
+ * @param aTimeLo
+ * Most significant DWORD.
+ * @return the number of seconds since the epoch
+ */
+ fileTimeToSecondsSinceEpoch(aTimeHi, aTimeLo) {
+ let fileTime = this._structs.FILETIME();
+ fileTime.dwLowDateTime = aTimeLo;
+ fileTime.dwHighDateTime = aTimeHi;
+ let systemTime = this._structs.SYSTEMTIME();
+ let result = this._functions.FileTimeToSystemTime(
+ fileTime.address(),
+ systemTime.address()
+ );
+ if (result == 0) {
+ throw new Error(ctypes.winLastError);
+ }
+
+ // System time is in UTC, so we use Date.UTC to get milliseconds from epoch,
+ // then divide by 1000 to get seconds, and round down:
+ return Math.floor(
+ Date.UTC(
+ systemTime.wYear,
+ systemTime.wMonth - 1,
+ systemTime.wDay,
+ systemTime.wHour,
+ systemTime.wMinute,
+ systemTime.wSecond,
+ systemTime.wMilliseconds
+ ) / 1000
+ );
+ },
+};
+
+function CtypesVaultHelpers() {
+ this._structs = {};
+ this._functions = {};
+
+ this._structs.GUID = new ctypes.StructType("GUID", [
+ { id: wintypes.DWORD.array(4) },
+ ]);
+
+ this._structs.VAULT_ITEM_ELEMENT = new ctypes.StructType(
+ "VAULT_ITEM_ELEMENT",
+ [
+ // not documented
+ { schemaElementId: wintypes.DWORD },
+ // not documented
+ { unknown1: wintypes.DWORD },
+ // vault type
+ { type: wintypes.DWORD },
+ // not documented
+ { unknown2: wintypes.DWORD },
+ // value of the item
+ { itemValue: wintypes.LPCWSTR },
+ // not documented
+ { unknown3: wintypes.CHAR.array(12) },
+ ]
+ );
+
+ this._structs.VAULT_ELEMENT = new ctypes.StructType("VAULT_ELEMENT", [
+ // vault item schemaId
+ { schemaId: this._structs.GUID },
+ // a pointer to the name of the browser VAULT_ITEM_ELEMENT
+ { pszCredentialFriendlyName: wintypes.LPCWSTR },
+ // a pointer to the url VAULT_ITEM_ELEMENT
+ { pResourceElement: this._structs.VAULT_ITEM_ELEMENT.ptr },
+ // a pointer to the username VAULT_ITEM_ELEMENT
+ { pIdentityElement: this._structs.VAULT_ITEM_ELEMENT.ptr },
+ // not documented
+ { pAuthenticatorElement: this._structs.VAULT_ITEM_ELEMENT.ptr },
+ // not documented
+ { pPackageSid: this._structs.VAULT_ITEM_ELEMENT.ptr },
+ // time stamp in local format
+ { lowLastModified: wintypes.DWORD },
+ { highLastModified: wintypes.DWORD },
+ // not documented
+ { flags: wintypes.DWORD },
+ // not documented
+ { dwPropertiesCount: wintypes.DWORD },
+ // not documented
+ { pPropertyElements: this._structs.VAULT_ITEM_ELEMENT.ptr },
+ ]);
+
+ try {
+ this._vaultcliLib = ctypes.open("vaultcli.dll");
+
+ this._functions.VaultOpenVault = this._vaultcliLib.declare(
+ "VaultOpenVault",
+ ctypes.winapi_abi,
+ wintypes.DWORD,
+ // GUID
+ this._structs.GUID.ptr,
+ // Flags
+ wintypes.DWORD,
+ // Vault Handle
+ wintypes.VOIDP.ptr
+ );
+ this._functions.VaultEnumerateItems = this._vaultcliLib.declare(
+ "VaultEnumerateItems",
+ ctypes.winapi_abi,
+ wintypes.DWORD,
+ // Vault Handle
+ wintypes.VOIDP,
+ // Flags
+ wintypes.DWORD,
+ // Items Count
+ wintypes.PDWORD,
+ // Items
+ ctypes.voidptr_t
+ );
+ this._functions.VaultCloseVault = this._vaultcliLib.declare(
+ "VaultCloseVault",
+ ctypes.winapi_abi,
+ wintypes.DWORD,
+ // Vault Handle
+ wintypes.VOIDP
+ );
+ this._functions.VaultGetItem = this._vaultcliLib.declare(
+ "VaultGetItem",
+ ctypes.winapi_abi,
+ wintypes.DWORD,
+ // Vault Handle
+ wintypes.VOIDP,
+ // Schema Id
+ this._structs.GUID.ptr,
+ // Resource
+ this._structs.VAULT_ITEM_ELEMENT.ptr,
+ // Identity
+ this._structs.VAULT_ITEM_ELEMENT.ptr,
+ // Package Sid
+ this._structs.VAULT_ITEM_ELEMENT.ptr,
+ // HWND Owner
+ wintypes.DWORD,
+ // Flags
+ wintypes.DWORD,
+ // Items
+ this._structs.VAULT_ELEMENT.ptr.ptr
+ );
+ this._functions.VaultFree = this._vaultcliLib.declare(
+ "VaultFree",
+ ctypes.winapi_abi,
+ wintypes.DWORD,
+ // Memory
+ this._structs.VAULT_ELEMENT.ptr
+ );
+ } catch (ex) {
+ this.finalize();
+ }
+}
+
+CtypesVaultHelpers.prototype = {
+ /**
+ * Must be invoked once after last use of any of the provided helpers.
+ */
+ finalize() {
+ this._structs = {};
+ this._functions = {};
+ try {
+ this._vaultcliLib.close();
+ } catch (ex) {}
+ this._vaultcliLib = null;
+ },
+};
+
+/**
+ * Checks whether an host is an IP (v4 or v6) address.
+ *
+ * @param aHost
+ * The host to check.
+ * @return whether aHost is an IP address.
+ */
+function hostIsIPAddress(aHost) {
+ try {
+ Services.eTLD.getBaseDomainFromHost(aHost);
+ } catch (e) {
+ return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS;
+ }
+ return false;
+}
+
+var gEdgeDir;
+function getEdgeLocalDataFolder() {
+ if (gEdgeDir) {
+ return gEdgeDir.clone();
+ }
+ let packages = Services.dirsvc.get("LocalAppData", Ci.nsIFile);
+ packages.append("Packages");
+ let edgeDir = packages.clone();
+ edgeDir.append("Microsoft.MicrosoftEdge_8wekyb3d8bbwe");
+ try {
+ if (edgeDir.exists() && edgeDir.isReadable() && edgeDir.isDirectory()) {
+ gEdgeDir = edgeDir;
+ return edgeDir.clone();
+ }
+
+ // Let's try the long way:
+ let dirEntries = packages.directoryEntries;
+ while (dirEntries.hasMoreElements()) {
+ let subDir = dirEntries.nextFile;
+ if (
+ subDir.leafName.startsWith("Microsoft.MicrosoftEdge") &&
+ subDir.isReadable() &&
+ subDir.isDirectory()
+ ) {
+ gEdgeDir = subDir;
+ return subDir.clone();
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(
+ "Exception trying to find the Edge favorites directory: " + ex
+ );
+ }
+ return null;
+}
+
+function Bookmarks(migrationType) {
+ this._migrationType = migrationType;
+}
+
+Bookmarks.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ get exists() {
+ return !!this._favoritesFolder;
+ },
+
+ get importedAppLabel() {
+ return this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE
+ ? "IE"
+ : "Edge";
+ },
+
+ __favoritesFolder: null,
+ get _favoritesFolder() {
+ if (!this.__favoritesFolder) {
+ if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) {
+ let favoritesFolder = Services.dirsvc.get("Favs", Ci.nsIFile);
+ if (favoritesFolder.exists() && favoritesFolder.isReadable()) {
+ this.__favoritesFolder = favoritesFolder;
+ }
+ } else if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE) {
+ let edgeDir = getEdgeLocalDataFolder();
+ if (edgeDir) {
+ edgeDir.appendRelativePath(EDGE_FAVORITES);
+ if (
+ edgeDir.exists() &&
+ edgeDir.isReadable() &&
+ edgeDir.isDirectory()
+ ) {
+ this.__favoritesFolder = edgeDir;
+ }
+ }
+ }
+ }
+ return this.__favoritesFolder;
+ },
+
+ __toolbarFolderName: null,
+ get _toolbarFolderName() {
+ if (!this.__toolbarFolderName) {
+ if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) {
+ // Retrieve the name of IE's favorites subfolder that holds the bookmarks
+ // in the toolbar. This was previously stored in the registry and changed
+ // in IE7 to always be called "Links".
+ let folderName = WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Microsoft\\Internet Explorer\\Toolbar",
+ "LinksFolderName"
+ );
+ this.__toolbarFolderName = folderName || "Links";
+ } else {
+ this.__toolbarFolderName = "Links";
+ }
+ }
+ return this.__toolbarFolderName;
+ },
+
+ _histogramBookmarkRoots: 0,
+ migrate: function B_migrate(aCallback) {
+ return (async () => {
+ // Import to the bookmarks menu.
+ let folderGuid = PlacesUtils.bookmarks.menuGuid;
+ await this._migrateFolder(this._favoritesFolder, folderGuid);
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS")
+ .add(
+ this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE
+ ? "ie"
+ : "edge",
+ this._histogramBookmarkRoots
+ );
+ })().then(
+ () => aCallback(true),
+ e => {
+ Cu.reportError(e);
+ aCallback(false);
+ }
+ );
+ },
+
+ async _migrateFolder(aSourceFolder, aDestFolderGuid) {
+ let bookmarks = await this._getBookmarksInFolder(aSourceFolder);
+ if (!bookmarks.length) {
+ return;
+ }
+
+ if (aDestFolderGuid == PlacesUtils.bookmarks.menuGuid) {
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU;
+ } else if (aDestFolderGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ }
+
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(aDestFolderGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ aDestFolderGuid = await MigrationUtils.createImportedBookmarksFolder(
+ this.importedAppLabel,
+ aDestFolderGuid
+ );
+ }
+ await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid);
+ },
+
+ async _getBookmarksInFolder(aSourceFolder) {
+ // TODO (bug 741993): the favorites order is stored in the Registry, at
+ // HCU\Software\Microsoft\Windows\CurrentVersion\Explorer\MenuOrder\Favorites
+ // for IE, and in a similar location for Edge.
+ // Until we support it, bookmarks are imported in alphabetical order.
+ let entries = aSourceFolder.directoryEntries;
+ let rv = [];
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ try {
+ // Make sure that entry.path == entry.target to not follow .lnk folder
+ // shortcuts which could lead to infinite cycles.
+ // Don't use isSymlink(), since it would throw for invalid
+ // lnk files pointing to URLs or to unresolvable paths.
+ if (entry.path == entry.target && entry.isDirectory()) {
+ let isBookmarksFolder =
+ entry.leafName == this._toolbarFolderName &&
+ entry.parent.equals(this._favoritesFolder);
+ if (isBookmarksFolder && entry.isReadable()) {
+ // Import to the bookmarks toolbar.
+ let folderGuid = PlacesUtils.bookmarks.toolbarGuid;
+ await this._migrateFolder(entry, folderGuid);
+ PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration();
+ } else if (entry.isReadable()) {
+ let childBookmarks = await this._getBookmarksInFolder(entry);
+ rv.push({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: entry.leafName,
+ children: childBookmarks,
+ });
+ }
+ } else {
+ // Strip the .url extension, to both check this is a valid link file,
+ // and get the associated title.
+ let matches = entry.leafName.match(/(.+)\.url$/i);
+ if (matches) {
+ let fileHandler = Cc[
+ "@mozilla.org/network/protocol;1?name=file"
+ ].getService(Ci.nsIFileProtocolHandler);
+ let uri = fileHandler.readURLFile(entry);
+ rv.push({ url: uri, title: matches[1] });
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(
+ "Unable to import " +
+ this.importedAppLabel +
+ " favorite (" +
+ entry.leafName +
+ "): " +
+ ex
+ );
+ }
+ }
+ return rv;
+ },
+};
+
+function Cookies(migrationType) {
+ this._migrationType = migrationType;
+}
+
+Cookies.prototype = {
+ type: MigrationUtils.resourceTypes.COOKIES,
+
+ get exists() {
+ if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) {
+ return !!this._cookiesFolder;
+ }
+ return !!this._cookiesFolders;
+ },
+
+ __cookiesFolder: null,
+ get _cookiesFolder() {
+ // Edge stores cookies in a number of places, and this shouldn't get called:
+ if (this._migrationType != MSMigrationUtils.MIGRATION_TYPE_IE) {
+ throw new Error(
+ "Shouldn't be looking for a single cookie folder unless we're migrating IE"
+ );
+ }
+
+ // Cookies are stored in txt files, in a Cookies folder whose path varies
+ // across the different OS versions. CookD takes care of most of these
+ // cases, though, in Windows Vista/7, UAC makes a difference.
+ // If UAC is enabled, the most common destination is CookD/Low. Though,
+ // if the user runs the application in administrator mode or disables UAC,
+ // cookies are stored in the original CookD destination. Cause running the
+ // browser in administrator mode is unsafe and discouraged, we just care
+ // about the UAC state.
+ if (!this.__cookiesFolder) {
+ let cookiesFolder = Services.dirsvc.get("CookD", Ci.nsIFile);
+ if (cookiesFolder.exists() && cookiesFolder.isReadable()) {
+ // In versions up to Windows 7, check if UAC is enabled.
+ if (
+ AppConstants.isPlatformAndVersionAtMost("win", "6.1") &&
+ Services.appinfo.QueryInterface(Ci.nsIWinAppHelper).userCanElevate
+ ) {
+ cookiesFolder.append("Low");
+ }
+ this.__cookiesFolder = cookiesFolder;
+ }
+ }
+ return this.__cookiesFolder;
+ },
+
+ __cookiesFolders: null,
+ get _cookiesFolders() {
+ if (this._migrationType != MSMigrationUtils.MIGRATION_TYPE_EDGE) {
+ throw new Error(
+ "Shouldn't be looking for multiple cookie folders unless we're migrating Edge"
+ );
+ }
+
+ let folders = [];
+ let edgeDir = getEdgeLocalDataFolder();
+ if (edgeDir) {
+ edgeDir.append("AC");
+ for (let path of EDGE_COOKIE_PATH_OPTIONS) {
+ let folder = edgeDir.clone();
+ let fullPath = path + EDGE_COOKIES_SUFFIX;
+ folder.appendRelativePath(fullPath);
+ if (folder.exists() && folder.isReadable() && folder.isDirectory()) {
+ folders.push(folder);
+ }
+ }
+ }
+ this.__cookiesFolders = folders.length ? folders : null;
+ return this.__cookiesFolders;
+ },
+
+ migrate(aCallback) {
+ this.ctypesKernelHelpers = new CtypesKernelHelpers();
+
+ let cookiesGenerator = function* genCookie() {
+ let success = false;
+ let folders =
+ this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE
+ ? this.__cookiesFolders
+ : [this.__cookiesFolder];
+ for (let folder of folders) {
+ let entries = folder.directoryEntries;
+ while (entries.hasMoreElements()) {
+ let entry = entries.nextFile;
+ // Skip eventual bogus entries.
+ if (!entry.isFile() || !/\.(cookie|txt)$/.test(entry.leafName)) {
+ continue;
+ }
+
+ this._readCookieFile(entry, function(aSuccess) {
+ // Importing even a single cookie file is considered a success.
+ if (aSuccess) {
+ success = true;
+ }
+ try {
+ cookiesGenerator.next();
+ } catch (ex) {}
+ });
+
+ yield undefined;
+ }
+ }
+
+ this.ctypesKernelHelpers.finalize();
+
+ aCallback(success);
+ }.apply(this);
+ cookiesGenerator.next();
+ },
+
+ _readCookieFile(aFile, aCallback) {
+ File.createFromNsIFile(aFile).then(
+ file => {
+ let fileReader = new FileReader();
+ let onLoadEnd = () => {
+ fileReader.removeEventListener("loadend", onLoadEnd);
+
+ if (fileReader.readyState != fileReader.DONE) {
+ Cu.reportError(
+ "Could not read cookie contents: " + fileReader.error
+ );
+ aCallback(false);
+ return;
+ }
+
+ let success = true;
+ try {
+ this._parseCookieBuffer(fileReader.result);
+ } catch (ex) {
+ Cu.reportError("Unable to migrate cookie: " + ex);
+ success = false;
+ } finally {
+ aCallback(success);
+ }
+ };
+ fileReader.addEventListener("loadend", onLoadEnd);
+ fileReader.readAsText(file);
+ },
+ () => {
+ aCallback(false);
+ }
+ );
+ },
+
+ /**
+ * Parses a cookie file buffer and returns an array of the contained cookies.
+ *
+ * The cookie file format is a newline-separated-values with a "*" used as
+ * delimeter between multiple records.
+ * Each cookie has the following fields:
+ * - name
+ * - value
+ * - host/path
+ * - flags
+ * - Expiration time most significant integer
+ * - Expiration time least significant integer
+ * - Creation time most significant integer
+ * - Creation time least significant integer
+ * - Record delimiter "*"
+ *
+ * Unfortunately, "*" can also occur inside the value of the cookie, so we
+ * can't rely exclusively on it as a record separator.
+ *
+ * @note All the times are in FILETIME format.
+ */
+ _parseCookieBuffer(aTextBuffer) {
+ // Note the last record is an empty string...
+ let records = [];
+ let lines = aTextBuffer.split("\n");
+ while (lines.length) {
+ let record = lines.splice(0, 9);
+ // ... which means this is going to be a 1-element array for that record
+ if (record.length > 1) {
+ records.push(record);
+ }
+ }
+ for (let record of records) {
+ let [name, value, hostpath, flags, expireTimeLo, expireTimeHi] = record;
+
+ // IE stores deleted cookies with a zero-length value, skip them.
+ if (!value.length) {
+ continue;
+ }
+
+ // IE sometimes has cookies created by apps that use "~~local~~/local/file/path"
+ // as the hostpath, ignore those:
+ if (hostpath.startsWith("~~local~~")) {
+ continue;
+ }
+
+ let hostLen = hostpath.indexOf("/");
+ let host = hostpath.substr(0, hostLen);
+ let path = hostpath.substr(hostLen);
+
+ // For a non-null domain, assume it's what Mozilla considers
+ // a domain cookie. See bug 222343.
+ if (host.length) {
+ // Fist delete any possible extant matching host cookie.
+ Services.cookies.remove(host, name, path, {});
+ // Now make it a domain cookie.
+ if (host[0] != "." && !hostIsIPAddress(host)) {
+ host = "." + host;
+ }
+ }
+
+ // Fallback: expire in 1h (NB: time is in seconds since epoch, so we have
+ // to divide the result of Date.now() (which is in milliseconds) by 1000).
+ let expireTime = Math.floor(Date.now() / 1000) + 3600;
+ try {
+ expireTime = this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch(
+ Number(expireTimeHi),
+ Number(expireTimeLo)
+ );
+ } catch (ex) {
+ Cu.reportError("Failed to get expiry time for cookie for " + host);
+ }
+
+ Services.cookies.add(
+ host,
+ path,
+ name,
+ value,
+ Number(flags) & 0x1, // secure
+ false, // httpOnly
+ false, // session
+ expireTime,
+ {},
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_UNSET
+ );
+ }
+ },
+};
+
+function getTypedURLs(registryKeyPath) {
+ // The list of typed URLs is a sort of annotation stored in the registry.
+ // The number of entries stored is not UI-configurable, but has changed
+ // between different Windows versions. We just keep reading up to the first
+ // non-existing entry to support different limits / states of the registry.
+ let typedURLs = new Map();
+ let typedURLKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ let typedURLTimeKey = Cc[
+ "@mozilla.org/windows-registry-key;1"
+ ].createInstance(Ci.nsIWindowsRegKey);
+ let cTypes = new CtypesKernelHelpers();
+ try {
+ try {
+ typedURLKey.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ registryKeyPath + "\\TypedURLs",
+ Ci.nsIWindowsRegKey.ACCESS_READ
+ );
+ } catch (ex) {
+ // Ignore errors opening this registry key - if it doesn't work, there's
+ // no way we can get useful info here.
+ return typedURLs;
+ }
+ try {
+ typedURLTimeKey.open(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ registryKeyPath + "\\TypedURLsTime",
+ Ci.nsIWindowsRegKey.ACCESS_READ
+ );
+ } catch (ex) {
+ typedURLTimeKey = null;
+ }
+ let entryName;
+ for (
+ let entry = 1;
+ typedURLKey.hasValue((entryName = "url" + entry));
+ entry++
+ ) {
+ let url = typedURLKey.readStringValue(entryName);
+ // If we can't get a date for whatever reason, default to 6 months ago
+ let timeTyped = Date.now() - 31536000 / 2;
+ if (typedURLTimeKey && typedURLTimeKey.hasValue(entryName)) {
+ let urlTime = "";
+ try {
+ urlTime = typedURLTimeKey.readBinaryValue(entryName);
+ } catch (ex) {
+ Cu.reportError("Couldn't read url time for " + entryName);
+ }
+ if (urlTime.length == 8) {
+ let urlTimeHex = [];
+ for (let i = 0; i < 8; i++) {
+ let c = urlTime.charCodeAt(i).toString(16);
+ if (c.length == 1) {
+ c = "0" + c;
+ }
+ urlTimeHex.unshift(c);
+ }
+ try {
+ let hi = parseInt(urlTimeHex.slice(0, 4).join(""), 16);
+ let lo = parseInt(urlTimeHex.slice(4, 8).join(""), 16);
+ // Convert to seconds since epoch:
+ let secondsSinceEpoch = cTypes.fileTimeToSecondsSinceEpoch(hi, lo);
+
+ // If the date is very far in the past, just use the default
+ if (secondsSinceEpoch > Date.now() / 1000000) {
+ // Callers expect PRTime, which is microseconds since epoch:
+ timeTyped = secondsSinceEpoch * 1000;
+ }
+ } catch (ex) {
+ // Ignore conversion exceptions. Callers will have to deal
+ // with the fallback value.
+ }
+ }
+ }
+ typedURLs.set(url, timeTyped * 1000);
+ }
+ } catch (ex) {
+ Cu.reportError("Error reading typed URL history: " + ex);
+ } finally {
+ if (typedURLKey) {
+ typedURLKey.close();
+ }
+ if (typedURLTimeKey) {
+ typedURLTimeKey.close();
+ }
+ cTypes.finalize();
+ }
+ return typedURLs;
+}
+
+// Migrator for form passwords on Windows 8 and higher.
+function WindowsVaultFormPasswords() {}
+
+WindowsVaultFormPasswords.prototype = {
+ type: MigrationUtils.resourceTypes.PASSWORDS,
+
+ get exists() {
+ // work only on windows 8+
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ // check if there are passwords available for migration.
+ return this.migrate(() => {}, true);
+ }
+ return false;
+ },
+
+ /**
+ * If aOnlyCheckExists is false, import the form passwords on Windows 8 and higher from the vault
+ * and then call the aCallback.
+ * Otherwise, check if there are passwords in the vault.
+ * @param {function} aCallback - a callback called when the migration is done.
+ * @param {boolean} [aOnlyCheckExists=false] - if aOnlyCheckExists is true, just check if there are some
+ * passwords to migrate. Import the passwords from the vault and call aCallback otherwise.
+ * @return true if there are passwords in the vault and aOnlyCheckExists is set to true,
+ * false if there is no password in the vault and aOnlyCheckExists is set to true, undefined if
+ * aOnlyCheckExists is set to false.
+ */
+ async migrate(aCallback, aOnlyCheckExists = false) {
+ // check if the vault item is an IE/Edge one
+ function _isIEOrEdgePassword(id) {
+ return (
+ id[0] == INTERNET_EXPLORER_EDGE_GUID[0] &&
+ id[1] == INTERNET_EXPLORER_EDGE_GUID[1] &&
+ id[2] == INTERNET_EXPLORER_EDGE_GUID[2] &&
+ id[3] == INTERNET_EXPLORER_EDGE_GUID[3]
+ );
+ }
+
+ let ctypesVaultHelpers = new CtypesVaultHelpers();
+ let ctypesKernelHelpers = new CtypesKernelHelpers();
+ let migrationSucceeded = true;
+ let successfulVaultOpen = false;
+ let error, vault;
+ try {
+ // web credentials vault id
+ let vaultGuid = new ctypesVaultHelpers._structs.GUID(
+ WEB_CREDENTIALS_VAULT_ID
+ );
+ error = new wintypes.DWORD();
+ // web credentials vault
+ vault = new wintypes.VOIDP();
+ // open the current vault using the vaultGuid
+ error = ctypesVaultHelpers._functions.VaultOpenVault(
+ vaultGuid.address(),
+ 0,
+ vault.address()
+ );
+ if (error != RESULT_SUCCESS) {
+ throw new Error("Unable to open Vault: " + error);
+ }
+ successfulVaultOpen = true;
+
+ let item = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr();
+ let itemCount = new wintypes.DWORD();
+ // enumerate all the available items. This api is going to return a table of all the
+ // available items and item is going to point to the first element of this table.
+ error = ctypesVaultHelpers._functions.VaultEnumerateItems(
+ vault,
+ VAULT_ENUMERATE_ALL_ITEMS,
+ itemCount.address(),
+ item.address()
+ );
+ if (error != RESULT_SUCCESS) {
+ throw new Error("Unable to enumerate Vault items: " + error);
+ }
+
+ let logins = [];
+ for (let j = 0; j < itemCount.value; j++) {
+ try {
+ // if it's not an ie/edge password, skip it
+ if (!_isIEOrEdgePassword(item.contents.schemaId.id)) {
+ continue;
+ }
+ let url = item.contents.pResourceElement.contents.itemValue.readString();
+ let realURL;
+ try {
+ realURL = Services.io.newURI(url);
+ } catch (ex) {
+ /* leave realURL as null */
+ }
+ if (!realURL || !["http", "https", "ftp"].includes(realURL.scheme)) {
+ // Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP
+ continue;
+ }
+
+ // if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at
+ // least a password which is true in this case because a password was by now already found
+ if (aOnlyCheckExists) {
+ return true;
+ }
+ let username = item.contents.pIdentityElement.contents.itemValue.readString();
+ // the current login credential object
+ let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr();
+ error = ctypesVaultHelpers._functions.VaultGetItem(
+ vault,
+ item.contents.schemaId.address(),
+ item.contents.pResourceElement,
+ item.contents.pIdentityElement,
+ null,
+ 0,
+ 0,
+ credential.address()
+ );
+ if (error != RESULT_SUCCESS) {
+ throw new Error("Unable to get item: " + error);
+ }
+
+ let password = credential.contents.pAuthenticatorElement.contents.itemValue.readString();
+ let creation = Date.now();
+ try {
+ // login manager wants time in milliseconds since epoch, so convert
+ // to seconds since epoch and multiply to get milliseconds:
+ creation =
+ ctypesKernelHelpers.fileTimeToSecondsSinceEpoch(
+ item.contents.highLastModified,
+ item.contents.lowLastModified
+ ) * 1000;
+ } catch (ex) {
+ // Ignore exceptions in the dates and just create the login for right now.
+ }
+ // create a new login
+ logins.push({
+ username,
+ password,
+ origin: realURL.prePath,
+ timeCreated: creation,
+ });
+
+ // close current item
+ error = ctypesVaultHelpers._functions.VaultFree(credential);
+ if (error == FREE_CLOSE_FAILED) {
+ throw new Error("Unable to free item: " + error);
+ }
+ } catch (e) {
+ migrationSucceeded = false;
+ Cu.reportError(e);
+ } finally {
+ // move to next item in the table returned by VaultEnumerateItems
+ item = item.increment();
+ }
+ }
+
+ if (logins.length) {
+ await MigrationUtils.insertLoginsWrapper(logins);
+ }
+ } catch (e) {
+ Cu.reportError(e);
+ migrationSucceeded = false;
+ } finally {
+ if (successfulVaultOpen) {
+ // close current vault
+ error = ctypesVaultHelpers._functions.VaultCloseVault(vault);
+ if (error == FREE_CLOSE_FAILED) {
+ Cu.reportError("Unable to close vault: " + error);
+ }
+ }
+ ctypesKernelHelpers.finalize();
+ ctypesVaultHelpers.finalize();
+ aCallback(migrationSucceeded);
+ }
+ if (aOnlyCheckExists) {
+ return false;
+ }
+ return undefined;
+ },
+};
+
+var MSMigrationUtils = {
+ MIGRATION_TYPE_IE: 1,
+ MIGRATION_TYPE_EDGE: 2,
+ CtypesKernelHelpers,
+ getBookmarksMigrator(migrationType = this.MIGRATION_TYPE_IE) {
+ return new Bookmarks(migrationType);
+ },
+ getCookiesMigrator(migrationType = this.MIGRATION_TYPE_IE) {
+ return new Cookies(migrationType);
+ },
+ getWindowsVaultFormPasswordsMigrator() {
+ return new WindowsVaultFormPasswords();
+ },
+ getTypedURLs,
+ getEdgeLocalDataFolder,
+};
diff --git a/browser/components/migration/MigrationUtils.jsm b/browser/components/migration/MigrationUtils.jsm
new file mode 100644
index 0000000000..8d695f9001
--- /dev/null
+++ b/browser/components/migration/MigrationUtils.jsm
@@ -0,0 +1,1326 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["MigrationUtils", "MigratorPrototype"];
+
+const TOPIC_WILL_IMPORT_BOOKMARKS =
+ "initial-migration-will-import-default-bookmarks";
+const TOPIC_DID_IMPORT_BOOKMARKS =
+ "initial-migration-did-import-default-bookmarks";
+const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BookmarkHTMLUtils",
+ "resource://gre/modules/BookmarkHTMLUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PromiseUtils",
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "ResponsivenessMonitor",
+ "resource://gre/modules/ResponsivenessMonitor.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Sqlite",
+ "resource://gre/modules/Sqlite.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "WindowsRegistry",
+ "resource://gre/modules/WindowsRegistry.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "setTimeout",
+ "resource://gre/modules/Timer.jsm"
+);
+
+var gMigrators = null;
+var gProfileStartup = null;
+var gL10n = null;
+var gPreviousDefaultBrowserKey = "";
+
+let gForceExitSpinResolve = false;
+let gKeepUndoData = false;
+let gUndoData = null;
+
+XPCOMUtils.defineLazyGetter(this, "gAvailableMigratorKeys", function() {
+ if (AppConstants.platform == "win") {
+ return [
+ "firefox",
+ "edge",
+ "ie",
+ "chrome",
+ "chromium-edge",
+ "chromium-edge-beta",
+ "chrome-beta",
+ "chromium",
+ "360se",
+ "canary",
+ ];
+ }
+ if (AppConstants.platform == "macosx") {
+ return [
+ "firefox",
+ "safari",
+ "chrome",
+ "chromium-edge",
+ "chromium-edge-beta",
+ "chromium",
+ "canary",
+ ];
+ }
+ if (AppConstants.XP_UNIX) {
+ return ["firefox", "chrome", "chrome-beta", "chrome-dev", "chromium"];
+ }
+ return [];
+});
+
+function getL10n() {
+ if (!gL10n) {
+ gL10n = new Localization(["browser/migration.ftl"]);
+ }
+ return gL10n;
+}
+
+/**
+ * Shared prototype for migrators, implementing nsIBrowserProfileMigrator.
+ *
+ * To implement a migrator:
+ * 1. Import this module.
+ * 2. Create the prototype for the migrator, extending MigratorPrototype.
+ * Namely: MosaicMigrator.prototype = Object.create(MigratorPrototype);
+ * 3. Set classDescription, contractID and classID for your migrator, and set
+ * NSGetFactory appropriately.
+ * 4. If the migrator supports multiple profiles, override the sourceProfiles
+ * Here we default for single-profile migrator.
+ * 5. Implement getResources(aProfile) (see below).
+ * 6. For startup-only migrators, override |startupOnlyMigrator|.
+ */
+var MigratorPrototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserProfileMigrator"]),
+
+ /**
+ * OVERRIDE IF AND ONLY IF the source supports multiple profiles.
+ *
+ * Returns array of profile objects from which data may be imported. The object
+ * should have the following keys:
+ * id - a unique string identifier for the profile
+ * name - a pretty name to display to the user in the UI
+ *
+ * Only profiles from which data can be imported should be listed. Otherwise
+ * the behavior of the migration wizard isn't well-defined.
+ *
+ * For a single-profile source (e.g. safari, ie), this returns null,
+ * and not an empty array. That is the default implementation.
+ */
+ getSourceProfiles() {
+ return null;
+ },
+
+ /**
+ * MUST BE OVERRIDDEN.
+ *
+ * Returns an array of "migration resources" objects for the given profile,
+ * or for the "default" profile, if the migrator does not support multiple
+ * profiles.
+ *
+ * Each migration resource should provide:
+ * - a |type| getter, returning any of the migration types (see
+ * nsIBrowserProfileMigrator).
+ *
+ * - a |migrate| method, taking a single argument, aCallback(bool success),
+ * for migrating the data for this resource. It may do its job
+ * synchronously or asynchronously. Either way, it must call
+ * aCallback(bool aSuccess) when it's done. In the case of an exception
+ * thrown from |migrate|, it's taken as if aCallback(false) is called.
+ *
+ * Note: In the case of a simple asynchronous implementation, you may find
+ * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily.
+ *
+ * For each migration type listed in nsIBrowserProfileMigrator, multiple
+ * migration resources may be provided. This practice is useful when the
+ * data for a certain migration type is independently stored in few
+ * locations. For example, the mac version of Safari stores its "reading list"
+ * bookmarks in a separate property list.
+ *
+ * Note that the importation of a particular migration type is reported as
+ * successful if _any_ of its resources succeeded to import (that is, called,
+ * |aCallback(true)|). However, completion-status for a particular migration
+ * type is reported to the UI only once all of its migrators have called
+ * aCallback.
+ *
+ * @note The returned array should only include resources from which data
+ * can be imported. So, for example, before adding a resource for the
+ * BOOKMARKS migration type, you should check if you should check that the
+ * bookmarks file exists.
+ *
+ * @param aProfile
+ * The profile from which data may be imported, or an empty string
+ * in the case of a single-profile migrator.
+ * In the case of multiple-profiles migrator, it is guaranteed that
+ * aProfile is a value returned by the sourceProfiles getter (see
+ * above).
+ */
+ getResources: function MP_getResources(/* aProfile */) {
+ throw new Error("getResources must be overridden");
+ },
+
+ /**
+ * OVERRIDE in order to provide an estimate of when the last time was
+ * that somebody used the browser. It is OK that this is somewhat fuzzy -
+ * history may not be available (or be wiped or not present due to e.g.
+ * incognito mode).
+ *
+ * @return a Promise that resolves to the last used date.
+ *
+ * @note If not overridden, the promise will resolve to the unix epoch.
+ */
+ getLastUsedDate() {
+ return Promise.resolve(new Date(0));
+ },
+
+ /**
+ * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now,
+ * that is just the Firefox migrator, see bug 737381). Default: false.
+ *
+ * Startup-only migrators are different in two ways:
+ * - they may only be used during startup.
+ * - the user-profile is half baked during migration. The folder exists,
+ * but it's only accessible through MigrationUtils.profileStartup.
+ * The migrator can call MigrationUtils.profileStartup.doStartup
+ * at any point in order to initialize the profile.
+ */
+ get startupOnlyMigrator() {
+ return false;
+ },
+
+ /**
+ * Override if the data to migrate is locked/in-use and the user should
+ * probably shutdown the source browser.
+ */
+ get sourceLocked() {
+ return false;
+ },
+
+ /**
+ * DO NOT OVERRIDE - After deCOMing migration, the UI will just call
+ * getResources.
+ *
+ * @see nsIBrowserProfileMigrator
+ */
+ getMigrateData: async function MP_getMigrateData(aProfile) {
+ let resources = await this._getMaybeCachedResources(aProfile);
+ if (!resources) {
+ return 0;
+ }
+ let types = resources.map(r => r.type);
+ return types.reduce((a, b) => {
+ a |= b;
+ return a;
+ }, 0);
+ },
+
+ getBrowserKey: function MP_getBrowserKey() {
+ return this.contractID.match(/\=([^\=]+)$/)[1];
+ },
+
+ /**
+ * DO NOT OVERRIDE - After deCOMing migration, the UI will just call
+ * migrate for each resource.
+ *
+ * @see nsIBrowserProfileMigrator
+ */
+ migrate: async function MP_migrate(aItems, aStartup, aProfile) {
+ let resources = await this._getMaybeCachedResources(aProfile);
+ if (!resources.length) {
+ throw new Error("migrate called for a non-existent source");
+ }
+
+ if (aItems != Ci.nsIBrowserProfileMigrator.ALL) {
+ resources = resources.filter(r => aItems & r.type);
+ }
+
+ // Used to periodically give back control to the main-thread loop.
+ let unblockMainThread = function() {
+ return new Promise(resolve => {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+ };
+
+ let getHistogramIdForResourceType = (resourceType, template) => {
+ if (resourceType == MigrationUtils.resourceTypes.HISTORY) {
+ return template.replace("*", "HISTORY");
+ }
+ if (resourceType == MigrationUtils.resourceTypes.BOOKMARKS) {
+ return template.replace("*", "BOOKMARKS");
+ }
+ if (resourceType == MigrationUtils.resourceTypes.PASSWORDS) {
+ return template.replace("*", "LOGINS");
+ }
+ return null;
+ };
+
+ let browserKey = this.getBrowserKey();
+
+ let maybeStartTelemetryStopwatch = resourceType => {
+ let histogramId = getHistogramIdForResourceType(
+ resourceType,
+ "FX_MIGRATION_*_IMPORT_MS"
+ );
+ if (histogramId) {
+ TelemetryStopwatch.startKeyed(histogramId, browserKey);
+ }
+ return histogramId;
+ };
+
+ let maybeStartResponsivenessMonitor = resourceType => {
+ let responsivenessMonitor;
+ let responsivenessHistogramId = getHistogramIdForResourceType(
+ resourceType,
+ "FX_MIGRATION_*_JANK_MS"
+ );
+ if (responsivenessHistogramId) {
+ responsivenessMonitor = new ResponsivenessMonitor();
+ }
+ return { responsivenessMonitor, responsivenessHistogramId };
+ };
+
+ let maybeFinishResponsivenessMonitor = (
+ responsivenessMonitor,
+ histogramId
+ ) => {
+ if (responsivenessMonitor) {
+ let accumulatedDelay = responsivenessMonitor.finish();
+ if (histogramId) {
+ try {
+ Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .add(browserKey, accumulatedDelay);
+ } catch (ex) {
+ Cu.reportError(histogramId + ": " + ex);
+ }
+ }
+ }
+ };
+
+ let collectQuantityTelemetry = () => {
+ for (let resourceType of Object.keys(MigrationUtils._importQuantities)) {
+ let histogramId =
+ "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY";
+ try {
+ Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .add(browserKey, MigrationUtils._importQuantities[resourceType]);
+ } catch (ex) {
+ Cu.reportError(histogramId + ": " + ex);
+ }
+ }
+ };
+
+ // Called either directly or through the bookmarks import callback.
+ let doMigrate = async function() {
+ let resourcesGroupedByItems = new Map();
+ resources.forEach(function(resource) {
+ if (!resourcesGroupedByItems.has(resource.type)) {
+ resourcesGroupedByItems.set(resource.type, new Set());
+ }
+ resourcesGroupedByItems.get(resource.type).add(resource);
+ });
+
+ if (resourcesGroupedByItems.size == 0) {
+ throw new Error("No items to import");
+ }
+
+ let notify = function(aMsg, aItemType) {
+ Services.obs.notifyObservers(null, aMsg, aItemType);
+ };
+
+ for (let resourceType of Object.keys(MigrationUtils._importQuantities)) {
+ MigrationUtils._importQuantities[resourceType] = 0;
+ }
+ notify("Migration:Started");
+ for (let [migrationType, itemResources] of resourcesGroupedByItems) {
+ notify("Migration:ItemBeforeMigrate", migrationType);
+
+ let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType);
+
+ let {
+ responsivenessMonitor,
+ responsivenessHistogramId,
+ } = maybeStartResponsivenessMonitor(migrationType);
+
+ let itemSuccess = false;
+ for (let res of itemResources) {
+ let completeDeferred = PromiseUtils.defer();
+ let resourceDone = function(aSuccess) {
+ itemResources.delete(res);
+ itemSuccess |= aSuccess;
+ if (itemResources.size == 0) {
+ notify(
+ itemSuccess
+ ? "Migration:ItemAfterMigrate"
+ : "Migration:ItemError",
+ migrationType
+ );
+ resourcesGroupedByItems.delete(migrationType);
+
+ if (stopwatchHistogramId) {
+ TelemetryStopwatch.finishKeyed(
+ stopwatchHistogramId,
+ browserKey
+ );
+ }
+
+ maybeFinishResponsivenessMonitor(
+ responsivenessMonitor,
+ responsivenessHistogramId
+ );
+
+ if (resourcesGroupedByItems.size == 0) {
+ collectQuantityTelemetry();
+ notify("Migration:Ended");
+ }
+ }
+ completeDeferred.resolve();
+ };
+
+ // If migrate throws, an error occurred, and the callback
+ // (itemMayBeDone) might haven't been called.
+ try {
+ res.migrate(resourceDone);
+ } catch (ex) {
+ Cu.reportError(ex);
+ resourceDone(false);
+ }
+
+ await completeDeferred.promise;
+ await unblockMainThread();
+ }
+ }
+ };
+
+ if (
+ MigrationUtils.isStartupMigration &&
+ !this.startupOnlyMigrator &&
+ Services.policies.isAllowed("defaultBookmarks")
+ ) {
+ MigrationUtils.profileStartup.doStartup();
+ // First import the default bookmarks.
+ // Note: We do not need to do so for the Firefox migrator
+ // (=startupOnlyMigrator), as it just copies over the places database
+ // from another profile.
+ (async function() {
+ // Tell nsBrowserGlue we're importing default bookmarks.
+ let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
+ Ci.nsIObserver
+ );
+ browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, "");
+
+ // Import the default bookmarks. We ignore whether or not we succeed.
+ await BookmarkHTMLUtils.importFromURL(
+ "chrome://browser/locale/bookmarks.html",
+ {
+ replace: true,
+ source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP,
+ }
+ ).catch(Cu.reportError);
+
+ // We'll tell nsBrowserGlue we've imported bookmarks, but before that
+ // we need to make sure we're going to know when it's finished
+ // initializing places:
+ let placesInitedPromise = new Promise(resolve => {
+ let onPlacesInited = function() {
+ Services.obs.removeObserver(
+ onPlacesInited,
+ TOPIC_PLACES_DEFAULTS_FINISHED
+ );
+ resolve();
+ };
+ Services.obs.addObserver(
+ onPlacesInited,
+ TOPIC_PLACES_DEFAULTS_FINISHED
+ );
+ });
+ browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, "");
+ await placesInitedPromise;
+ doMigrate();
+ })();
+ return;
+ }
+ doMigrate();
+ },
+
+ /**
+ * DO NOT OVERRIDE - After deCOMing migration, this code
+ * won't be part of the migrator itself.
+ *
+ * @see nsIBrowserProfileMigrator
+ */
+ async isSourceAvailable() {
+ if (this.startupOnlyMigrator && !MigrationUtils.isStartupMigration) {
+ return false;
+ }
+
+ // For a single-profile source, check if any data is available.
+ // For multiple-profiles source, make sure that at least one
+ // profile is available.
+ let exists = false;
+ try {
+ let profiles = await this.getSourceProfiles();
+ if (!profiles) {
+ let resources = await this._getMaybeCachedResources("");
+ if (resources && resources.length) {
+ exists = true;
+ }
+ } else {
+ exists = !!profiles.length;
+ }
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ return exists;
+ },
+
+ /** * PRIVATE STUFF - DO NOT OVERRIDE ***/
+ _getMaybeCachedResources: async function PMB__getMaybeCachedResources(
+ aProfile
+ ) {
+ let profileKey = aProfile ? aProfile.id : "";
+ if (this._resourcesByProfile) {
+ if (profileKey in this._resourcesByProfile) {
+ return this._resourcesByProfile[profileKey];
+ }
+ } else {
+ this._resourcesByProfile = {};
+ }
+ this._resourcesByProfile[profileKey] = await this.getResources(aProfile);
+ return this._resourcesByProfile[profileKey];
+ },
+};
+
+var MigrationUtils = Object.seal({
+ resourceTypes: {
+ COOKIES: Ci.nsIBrowserProfileMigrator.COOKIES,
+ HISTORY: Ci.nsIBrowserProfileMigrator.HISTORY,
+ FORMDATA: Ci.nsIBrowserProfileMigrator.FORMDATA,
+ PASSWORDS: Ci.nsIBrowserProfileMigrator.PASSWORDS,
+ BOOKMARKS: Ci.nsIBrowserProfileMigrator.BOOKMARKS,
+ OTHERDATA: Ci.nsIBrowserProfileMigrator.OTHERDATA,
+ SESSION: Ci.nsIBrowserProfileMigrator.SESSION,
+ },
+
+ /**
+ * Helper for implementing simple asynchronous cases of migration resources'
+ * |migrate(aCallback)| (see MigratorPrototype). If your |migrate| method
+ * just waits for some file to be read, for example, and then migrates
+ * everything right away, you can wrap the async-function with this helper
+ * and not worry about notifying the callback.
+ *
+ * For example, instead of writing:
+ * setTimeout(function() {
+ * try {
+ * ....
+ * aCallback(true);
+ * }
+ * catch() {
+ * aCallback(false);
+ * }
+ * }, 0);
+ *
+ * You may write:
+ * setTimeout(MigrationUtils.wrapMigrateFunction(function() {
+ * if (importingFromMosaic)
+ * throw Cr.NS_ERROR_UNEXPECTED;
+ * }, aCallback), 0);
+ *
+ * ... and aCallback will be called with aSuccess=false when importing
+ * from Mosaic, or with aSuccess=true otherwise.
+ *
+ * @param aFunction
+ * the function that will be called sometime later. If aFunction
+ * throws when it's called, aCallback(false) is called, otherwise
+ * aCallback(true) is called.
+ * @param aCallback
+ * the callback function passed to |migrate|.
+ * @return the wrapped function.
+ */
+ wrapMigrateFunction: function MU_wrapMigrateFunction(aFunction, aCallback) {
+ return function() {
+ let success = false;
+ try {
+ aFunction.apply(null, arguments);
+ success = true;
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ // Do not change this to call aCallback directly in try try & catch
+ // blocks, because if aCallback throws, we may end up calling aCallback
+ // twice.
+ aCallback(success);
+ };
+ },
+
+ /**
+ * Gets localized string corresponding to l10n-id
+ *
+ * @param aKey
+ * The key of the id of the localization to retrieve.
+ * @param aArgs
+ * [optional] map of arguments to the id.
+ * @return A promise that resolves to the retrieved localization.
+ */
+ getLocalizedString: function MU_getLocalizedString(aKey, aArgs) {
+ let l10n = getL10n();
+ return l10n.formatValue(aKey, aArgs);
+ },
+
+ _getLocalePropertyForBrowser(browserId) {
+ switch (browserId) {
+ case "chromium-edge":
+ case "edge":
+ return "source-name-edge";
+ case "ie":
+ return "source-name-ie";
+ case "safari":
+ return "source-name-safari";
+ case "canary":
+ return "source-name-canary";
+ case "chrome":
+ return "source-name-chrome";
+ case "chrome-beta":
+ return "source-name-chrome-beta";
+ case "chrome-dev":
+ return "source-name-chrome-dev";
+ case "chromium":
+ return "source-name-chromium";
+ case "chromium-edge-beta":
+ return "source-name-chromium-edge-beta";
+ case "firefox":
+ return "source-name-firefox";
+ case "360se":
+ return "source-name-360se";
+ }
+ return null;
+ },
+
+ /**
+ * Helper for creating a folder for imported bookmarks from a particular
+ * migration source. The folder is created at the end of the given folder.
+ *
+ * @param sourceNameStr
+ * the source name (first letter capitalized). This is used
+ * for reading the localized source name from the migration
+ * bundle (e.g. if aSourceNameStr is Mosaic, this will try to read
+ * sourceNameMosaic from the migration bundle).
+ * @param parentGuid
+ * the GUID of the folder in which the new folder should be created.
+ * @return the GUID of the new folder.
+ */
+ async createImportedBookmarksFolder(sourceNameStr, parentGuid) {
+ let source = await this.getLocalizedString(
+ "source-name-" + sourceNameStr.toLowerCase()
+ );
+ let title = await this.getLocalizedString("imported-bookmarks-source", {
+ source,
+ });
+ return (
+ await PlacesUtils.bookmarks.insert({
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ parentGuid,
+ title,
+ })
+ ).guid;
+ },
+
+ /**
+ * Get all the rows corresponding to a select query from a database, without
+ * requiring a lock on the database. If fetching data fails (because someone
+ * else tried to write to the DB at the same time, for example), we will
+ * retry the fetch after a 100ms timeout, up to 10 times.
+ *
+ * @param path
+ * the file path to the database we want to open.
+ * @param description
+ * a developer-readable string identifying what kind of database we're
+ * trying to open.
+ * @param selectQuery
+ * the SELECT query to use to fetch the rows.
+ *
+ * @return a promise that resolves to an array of rows. The promise will be
+ * rejected if the read/fetch failed even after retrying.
+ */
+ getRowsFromDBWithoutLocks(path, description, selectQuery) {
+ let dbOptions = {
+ readOnly: true,
+ ignoreLockingMode: true,
+ path,
+ };
+
+ const RETRYLIMIT = 10;
+ const RETRYINTERVAL = 100;
+ return (async function innerGetRows() {
+ let rows = null;
+ for (let retryCount = RETRYLIMIT; retryCount && !rows; retryCount--) {
+ // Attempt to get the rows. If this succeeds, we will bail out of the loop,
+ // close the database in a failsafe way, and pass the rows back.
+ // If fetching the rows throws, we will wait RETRYINTERVAL ms
+ // and try again. This will repeat a maximum of RETRYLIMIT times.
+ let db;
+ let didOpen = false;
+ let exceptionSeen;
+ try {
+ db = await Sqlite.openConnection(dbOptions);
+ didOpen = true;
+ rows = await db.execute(selectQuery);
+ } catch (ex) {
+ if (!exceptionSeen) {
+ Cu.reportError(ex);
+ }
+ exceptionSeen = ex;
+ } finally {
+ try {
+ if (didOpen) {
+ await db.close();
+ }
+ } catch (ex) {}
+ }
+ if (exceptionSeen) {
+ await new Promise(resolve => setTimeout(resolve, RETRYINTERVAL));
+ }
+ }
+ if (!rows) {
+ throw new Error(
+ "Couldn't get rows from the " + description + " database."
+ );
+ }
+ return rows;
+ })();
+ },
+
+ get _migrators() {
+ if (!gMigrators) {
+ gMigrators = new Map();
+ }
+ return gMigrators;
+ },
+
+ forceExitSpinResolve: function MU_forceExitSpinResolve() {
+ gForceExitSpinResolve = true;
+ },
+
+ spinResolve: function MU_spinResolve(promise) {
+ if (!(promise instanceof Promise)) {
+ return promise;
+ }
+ let done = false;
+ let result = null;
+ let error = null;
+ gForceExitSpinResolve = false;
+ promise
+ .catch(e => {
+ error = e;
+ })
+ .then(r => {
+ result = r;
+ done = true;
+ });
+
+ Services.tm.spinEventLoopUntil(() => done || gForceExitSpinResolve);
+ if (!done) {
+ throw new Error("Forcefully exited event loop.");
+ } else if (error) {
+ throw error;
+ } else {
+ return result;
+ }
+ },
+
+ /*
+ * Returns the migrator for the given source, if any data is available
+ * for this source, or null otherwise.
+ *
+ * @param aKey internal name of the migration source.
+ * See `gAvailableMigratorKeys` for supported values by OS.
+ *
+ * If null is returned, either no data can be imported
+ * for the given migrator, or aMigratorKey is invalid (e.g. ie on mac,
+ * or mosaic everywhere). This method should be used rather than direct
+ * getService for future compatibility (see bug 718280).
+ *
+ * @return profile migrator implementing nsIBrowserProfileMigrator, if it can
+ * import any data, null otherwise.
+ */
+ getMigrator: async function MU_getMigrator(aKey) {
+ let migrator = null;
+ if (this._migrators.has(aKey)) {
+ migrator = this._migrators.get(aKey);
+ } else {
+ try {
+ migrator = Cc[
+ "@mozilla.org/profile/migrator;1?app=browser&type=" + aKey
+ ].createInstance(Ci.nsIBrowserProfileMigrator);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ this._migrators.set(aKey, migrator);
+ }
+
+ try {
+ return migrator && (await migrator.isSourceAvailable()) ? migrator : null;
+ } catch (ex) {
+ Cu.reportError(ex);
+ return null;
+ }
+ },
+
+ /**
+ * Figure out what is the default browser, and if there is a migrator
+ * for it, return that migrator's internal name.
+ * For the time being, the "internal name" of a migrator is its contract-id
+ * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie),
+ * but it will soon be exposed properly.
+ */
+ getMigratorKeyForDefaultBrowser() {
+ // Canary uses the same description as Chrome so we can't distinguish them.
+ // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication.
+ const APP_DESC_TO_KEY = {
+ "Internet Explorer": "ie",
+ "Microsoft Edge": "edge",
+ Safari: "safari",
+ Firefox: "firefox",
+ Nightly: "firefox",
+ "Google Chrome": "chrome", // Windows, Linux
+ Chrome: "chrome", // OS X
+ Chromium: "chromium", // Windows, OS X
+ "Chromium Web Browser": "chromium", // Linux
+ "360\u5b89\u5168\u6d4f\u89c8\u5668": "360se",
+ };
+
+ let key = "";
+ try {
+ let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"]
+ .getService(Ci.nsIExternalProtocolService)
+ .getApplicationDescription("http");
+ key = APP_DESC_TO_KEY[browserDesc] || "";
+ // Handle devedition, as well as "FirefoxNightly" on OS X.
+ if (!key && browserDesc.startsWith("Firefox")) {
+ key = "firefox";
+ }
+ } catch (ex) {
+ Cu.reportError("Could not detect default browser: " + ex);
+ }
+
+ // "firefox" is the least useful entry here, and might just be because we've set
+ // ourselves as the default (on Windows 7 and below). In that case, check if we
+ // have a registry key that tells us where to go:
+ if (
+ key == "firefox" &&
+ AppConstants.isPlatformAndVersionAtMost("win", "6.2")
+ ) {
+ // Because we remove the registry key, reading the registry key only works once.
+ // We save the value for subsequent calls to avoid hard-to-trace bugs when multiple
+ // consumers ask for this key.
+ if (gPreviousDefaultBrowserKey) {
+ key = gPreviousDefaultBrowserKey;
+ } else {
+ // We didn't have a saved value, so check the registry.
+ const kRegPath = "Software\\Mozilla\\Firefox";
+ let oldDefault = WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kRegPath,
+ "OldDefaultBrowserCommand"
+ );
+ if (oldDefault) {
+ // Remove the key:
+ WindowsRegistry.removeRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kRegPath,
+ "OldDefaultBrowserCommand"
+ );
+ try {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(
+ Ci.nsILocalFileWin
+ );
+ file.initWithCommandLine(oldDefault);
+ key =
+ APP_DESC_TO_KEY[file.getVersionInfoField("FileDescription")] ||
+ key;
+ // Save the value for future callers.
+ gPreviousDefaultBrowserKey = key;
+ } catch (ex) {
+ Cu.reportError(
+ "Could not convert old default browser value to description."
+ );
+ }
+ }
+ }
+ }
+ return key;
+ },
+
+ // Whether or not we're in the process of startup migration
+ get isStartupMigration() {
+ return gProfileStartup != null;
+ },
+
+ /**
+ * In the case of startup migration, this is set to the nsIProfileStartup
+ * instance passed to ProfileMigrator's migrate.
+ *
+ * @see showMigrationWizard
+ */
+ get profileStartup() {
+ return gProfileStartup;
+ },
+
+ /**
+ * Show the migration wizard. On mac, this may just focus the wizard if it's
+ * already running, in which case aOpener and aParams are ignored.
+ *
+ * @param {Window} [aOpener]
+ * optional; the window that asks to open the wizard.
+ * @param {Array} [aParams]
+ * optional arguments for the migration wizard, in the form of an array
+ * This is passed as-is for the params argument of
+ * nsIWindowWatcher.openWindow. The array elements we expect are, in
+ * order:
+ * - {Number} migration entry point constant (see below)
+ * - {String} source browser identifier
+ * - {nsIBrowserProfileMigrator} actual migrator object
+ * - {Boolean} whether this is a startup migration
+ * - {Boolean} whether to skip the 'source' page
+ * - {String} an identifier for the profile to use when migrating
+ * NB: If you add new consumers, please add a migration entry point
+ * constant below, and specify at least the first element of the array
+ * (the migration entry point for purposes of telemetry).
+ */
+ showMigrationWizard: function MU_showMigrationWizard(aOpener, aParams) {
+ let features = "chrome,dialog,modal,centerscreen,titlebar,resizable=no";
+ if (AppConstants.platform == "macosx" && !this.isStartupMigration) {
+ let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard");
+ if (win) {
+ win.focus();
+ return;
+ }
+ // On mac, the migration wiazrd should only be modal in the case of
+ // startup-migration.
+ features = "centerscreen,chrome,resizable=no";
+ }
+
+ // nsIWindowWatcher doesn't deal with raw arrays, so we convert the input
+ let params;
+ if (Array.isArray(aParams)) {
+ params = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray);
+ for (let item of aParams) {
+ let comtaminatedVal;
+ if (item && item instanceof Ci.nsISupports) {
+ comtaminatedVal = item;
+ } else {
+ switch (typeof item) {
+ case "boolean":
+ comtaminatedVal = Cc[
+ "@mozilla.org/supports-PRBool;1"
+ ].createInstance(Ci.nsISupportsPRBool);
+ comtaminatedVal.data = item;
+ break;
+ case "number":
+ comtaminatedVal = Cc[
+ "@mozilla.org/supports-PRUint32;1"
+ ].createInstance(Ci.nsISupportsPRUint32);
+ comtaminatedVal.data = item;
+ break;
+ case "string":
+ comtaminatedVal = Cc[
+ "@mozilla.org/supports-cstring;1"
+ ].createInstance(Ci.nsISupportsCString);
+ comtaminatedVal.data = item;
+ break;
+
+ case "undefined":
+ case "object":
+ if (!item) {
+ comtaminatedVal = null;
+ break;
+ }
+ /* intentionally falling through to error out here for
+ non-null/undefined things: */
+ default:
+ throw new Error(
+ "Unexpected parameter type " + typeof item + ": " + item
+ );
+ }
+ }
+ params.appendElement(comtaminatedVal);
+ }
+ } else {
+ params = aParams;
+ }
+
+ Services.ww.openWindow(
+ aOpener,
+ "chrome://browser/content/migration/migration.xhtml",
+ "_blank",
+ features,
+ params
+ );
+ },
+
+ /**
+ * Show the migration wizard for startup-migration. This should only be
+ * called by ProfileMigrator (see ProfileMigrator.js), which implements
+ * nsIProfileMigrator. This runs asynchronously if we are running an
+ * automigration.
+ *
+ * @param aProfileStartup
+ * the nsIProfileStartup instance provided to ProfileMigrator.migrate.
+ * @param [optional] aMigratorKey
+ * If set, the migration wizard will import from the corresponding
+ * migrator, bypassing the source-selection page. Otherwise, the
+ * source-selection page will be displayed, either with the default
+ * browser selected, if it could be detected and if there is a
+ * migrator for it, or with the first option selected as a fallback
+ * (The first option is hardcoded to be the most common browser for
+ * the OS we run on. See migration.xhtml).
+ * @param [optional] aProfileToMigrate
+ * If set, the migration wizard will import from the profile indicated.
+ * @throws if aMigratorKey is invalid or if it points to a non-existent
+ * source.
+ */
+ startupMigration: function MU_startupMigrator(
+ aProfileStartup,
+ aMigratorKey,
+ aProfileToMigrate
+ ) {
+ this.spinResolve(
+ this.asyncStartupMigration(
+ aProfileStartup,
+ aMigratorKey,
+ aProfileToMigrate
+ )
+ );
+ },
+
+ asyncStartupMigration: async function MU_asyncStartupMigrator(
+ aProfileStartup,
+ aMigratorKey,
+ aProfileToMigrate
+ ) {
+ if (!aProfileStartup) {
+ throw new Error(
+ "an profile-startup instance is required for startup-migration"
+ );
+ }
+ gProfileStartup = aProfileStartup;
+
+ let skipSourcePage = false,
+ migrator = null,
+ migratorKey = "";
+ if (aMigratorKey) {
+ migrator = await this.getMigrator(aMigratorKey);
+ if (!migrator) {
+ // aMigratorKey must point to a valid source, so, if it doesn't
+ // cleanup and throw.
+ this.finishMigration();
+ throw new Error(
+ "startMigration was asked to open auto-migrate from " +
+ "a non-existent source: " +
+ aMigratorKey
+ );
+ }
+ migratorKey = aMigratorKey;
+ skipSourcePage = true;
+ } else {
+ let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser();
+ if (defaultBrowserKey) {
+ migrator = await this.getMigrator(defaultBrowserKey);
+ if (migrator) {
+ migratorKey = defaultBrowserKey;
+ }
+ }
+ }
+
+ if (!migrator) {
+ let migrators = await Promise.all(
+ gAvailableMigratorKeys.map(key => this.getMigrator(key))
+ );
+ // If there's no migrator set so far, ensure that there is at least one
+ // migrator available before opening the wizard.
+ // Note that we don't need to check the default browser first, because
+ // if that one existed we would have used it in the block above this one.
+ if (!migrators.some(m => m)) {
+ // None of the keys produced a usable migrator, so finish up here:
+ this.finishMigration();
+ return;
+ }
+ }
+
+ let isRefresh =
+ migrator && skipSourcePage && migratorKey == AppConstants.MOZ_APP_NAME;
+
+ let migrationEntryPoint = this.MIGRATION_ENTRYPOINT_FIRSTRUN;
+ if (isRefresh) {
+ migrationEntryPoint = this.MIGRATION_ENTRYPOINT_FXREFRESH;
+ }
+
+ let params = [
+ migrationEntryPoint,
+ migratorKey,
+ migrator,
+ aProfileStartup,
+ skipSourcePage,
+ aProfileToMigrate,
+ ];
+ this.showMigrationWizard(null, params);
+ },
+
+ _importQuantities: {
+ bookmarks: 0,
+ logins: 0,
+ history: 0,
+ },
+
+ getImportedCount(type) {
+ if (!this._importQuantities.hasOwnProperty(type)) {
+ throw new Error(
+ `Unknown import data type "${type}" passed to getImportedCount`
+ );
+ }
+ return this._importQuantities[type];
+ },
+
+ insertBookmarkWrapper(bookmark) {
+ this._importQuantities.bookmarks++;
+ let insertionPromise = PlacesUtils.bookmarks.insert(bookmark);
+ if (!gKeepUndoData) {
+ return insertionPromise;
+ }
+ // If we keep undo data, add a promise handler that stores the undo data once
+ // the bookmark has been inserted in the DB, and then returns the bookmark.
+ let { parentGuid } = bookmark;
+ return insertionPromise.then(bm => {
+ let { guid, lastModified, type } = bm;
+ gUndoData.get("bookmarks").push({
+ parentGuid,
+ guid,
+ lastModified,
+ type,
+ });
+ return bm;
+ });
+ },
+
+ insertManyBookmarksWrapper(bookmarks, parent) {
+ let insertionPromise = PlacesUtils.bookmarks.insertTree({
+ guid: parent,
+ children: bookmarks,
+ });
+ return insertionPromise.then(
+ insertedItems => {
+ this._importQuantities.bookmarks += insertedItems.length;
+ if (gKeepUndoData) {
+ let bmData = gUndoData.get("bookmarks");
+ for (let bm of insertedItems) {
+ let { parentGuid, guid, lastModified, type } = bm;
+ bmData.push({ parentGuid, guid, lastModified, type });
+ }
+ }
+ },
+ ex => Cu.reportError(ex)
+ );
+ },
+
+ insertVisitsWrapper(pageInfos) {
+ let now = new Date();
+ // Ensure that none of the dates are in the future. If they are, rewrite
+ // them to be now. This means we don't loose history entries, but they will
+ // be valid for the history store.
+ for (let pageInfo of pageInfos) {
+ for (let visit of pageInfo.visits) {
+ if (visit.date && visit.date > now) {
+ visit.date = now;
+ }
+ }
+ }
+ this._importQuantities.history += pageInfos.length;
+ if (gKeepUndoData) {
+ this._updateHistoryUndo(pageInfos);
+ }
+ return PlacesUtils.history.insertMany(pageInfos);
+ },
+
+ async insertLoginsWrapper(logins) {
+ this._importQuantities.logins += logins.length;
+ let inserted = await LoginHelper.maybeImportLogins(logins);
+ // Note that this means that if we import a login that has a newer password
+ // than we know about, we will update the login, and an undo of the import
+ // will not revert this. This seems preferable over removing the login
+ // outright or storing the old password in the undo file.
+ if (gKeepUndoData) {
+ for (let { guid, timePasswordChanged } of inserted) {
+ gUndoData.get("logins").push({ guid, timePasswordChanged });
+ }
+ }
+ },
+
+ initializeUndoData() {
+ gKeepUndoData = true;
+ gUndoData = new Map([
+ ["bookmarks", []],
+ ["visits", []],
+ ["logins", []],
+ ]);
+ },
+
+ async _postProcessUndoData(state) {
+ if (!state) {
+ return state;
+ }
+ let bookmarkFolders = state
+ .get("bookmarks")
+ .filter(b => b.type == PlacesUtils.bookmarks.TYPE_FOLDER);
+
+ let bookmarkFolderData = [];
+ let bmPromises = bookmarkFolders.map(({ guid }) => {
+ // Ignore bookmarks where the promise doesn't resolve (ie that are missing)
+ // Also check that the bookmark fetch returns isn't null before adding it.
+ return PlacesUtils.bookmarks.fetch(guid).then(
+ bm => bm && bookmarkFolderData.push(bm),
+ () => {}
+ );
+ });
+
+ await Promise.all(bmPromises);
+ let folderLMMap = new Map(
+ bookmarkFolderData.map(b => [b.guid, b.lastModified])
+ );
+ for (let bookmark of bookmarkFolders) {
+ let lastModified = folderLMMap.get(bookmark.guid);
+ // If the bookmark was deleted, the map will be returning null, so check:
+ if (lastModified) {
+ bookmark.lastModified = lastModified;
+ }
+ }
+ return state;
+ },
+
+ stopAndRetrieveUndoData() {
+ let undoData = gUndoData;
+ gUndoData = null;
+ gKeepUndoData = false;
+ return this._postProcessUndoData(undoData);
+ },
+
+ _updateHistoryUndo(pageInfos) {
+ let visits = gUndoData.get("visits");
+ let visitMap = new Map(visits.map(v => [v.url, v]));
+ for (let pageInfo of pageInfos) {
+ let visitCount = pageInfo.visits.length;
+ let first, last;
+ if (visitCount > 1) {
+ let dates = pageInfo.visits.map(v => v.date);
+ first = Math.min.apply(Math, dates);
+ last = Math.max.apply(Math, dates);
+ } else {
+ first = last = pageInfo.visits[0].date;
+ }
+ let url = pageInfo.url;
+ if (url instanceof Ci.nsIURI) {
+ url = pageInfo.url.spec;
+ } else if (typeof url != "string") {
+ pageInfo.url.href;
+ }
+
+ try {
+ new URL(url);
+ } catch (ex) {
+ // This won't save and we won't need to 'undo' it, so ignore this URL.
+ continue;
+ }
+ if (!visitMap.has(url)) {
+ visitMap.set(url, { url, visitCount, first, last });
+ } else {
+ let currentData = visitMap.get(url);
+ currentData.visitCount += visitCount;
+ currentData.first = Math.min(currentData.first, first);
+ currentData.last = Math.max(currentData.last, last);
+ }
+ }
+ gUndoData.set("visits", Array.from(visitMap.values()));
+ },
+
+ /**
+ * Cleans up references to migrators and nsIProfileInstance instances.
+ */
+ finishMigration: function MU_finishMigration() {
+ gMigrators = null;
+ gProfileStartup = null;
+ gL10n = null;
+ },
+
+ gAvailableMigratorKeys,
+
+ MIGRATION_ENTRYPOINT_UNKNOWN: 0,
+ MIGRATION_ENTRYPOINT_FIRSTRUN: 1,
+ MIGRATION_ENTRYPOINT_FXREFRESH: 2,
+ MIGRATION_ENTRYPOINT_PLACES: 3,
+ MIGRATION_ENTRYPOINT_PASSWORDS: 4,
+ MIGRATION_ENTRYPOINT_NEWTAB: 5,
+ MIGRATION_ENTRYPOINT_FILE_MENU: 6,
+ MIGRATION_ENTRYPOINT_HELP_MENU: 7,
+ MIGRATION_ENTRYPOINT_BOOKMARKS_TOOLBAR: 8,
+
+ _sourceNameToIdMapping: {
+ nothing: 1,
+ firefox: 2,
+ edge: 3,
+ ie: 4,
+ chrome: 5,
+ "chrome-beta": 5,
+ "chrome-dev": 5,
+ chromium: 6,
+ canary: 7,
+ safari: 8,
+ "360se": 9,
+ "chromium-edge": 10,
+ "chromium-edge-beta": 10,
+ },
+ getSourceIdForTelemetry(sourceName) {
+ return this._sourceNameToIdMapping[sourceName] || 0;
+ },
+
+ /* Enum of locations where bookmarks were found in the
+ source browser that we import from */
+ SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR: 1,
+ SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU: 2,
+ SOURCE_BOOKMARK_ROOTS_READING_LIST: 4,
+ SOURCE_BOOKMARK_ROOTS_UNFILED: 8,
+});
diff --git a/browser/components/migration/ProfileMigrator.jsm b/browser/components/migration/ProfileMigrator.jsm
new file mode 100644
index 0000000000..ab35a8f021
--- /dev/null
+++ b/browser/components/migration/ProfileMigrator.jsm
@@ -0,0 +1,21 @@
+/* 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 { MigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+function ProfileMigrator() {}
+
+ProfileMigrator.prototype = {
+ migrate: MigrationUtils.startupMigration.bind(MigrationUtils),
+ QueryInterface: ChromeUtils.generateQI(["nsIProfileMigrator"]),
+ classDescription: "Profile Migrator",
+ contractID: "@mozilla.org/toolkit/profile-migrator;1",
+ classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE"),
+};
+
+var EXPORTED_SYMBOLS = ["ProfileMigrator"];
diff --git a/browser/components/migration/SafariProfileMigrator.jsm b/browser/components/migration/SafariProfileMigrator.jsm
new file mode 100644
index 0000000000..184713ad82
--- /dev/null
+++ b/browser/components/migration/SafariProfileMigrator.jsm
@@ -0,0 +1,535 @@
+/* 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 { FileUtils } = ChromeUtils.import(
+ "resource://gre/modules/FileUtils.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+const { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "PropertyListUtils",
+ "resource://gre/modules/PropertyListUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUtils",
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "PlacesUIUtils",
+ "resource:///modules/PlacesUIUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "FormHistory",
+ "resource://gre/modules/FormHistory.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+function Bookmarks(aBookmarksFile) {
+ this._file = aBookmarksFile;
+ this._histogramBookmarkRoots = 0;
+}
+Bookmarks.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ migrate: function B_migrate(aCallback) {
+ return (async () => {
+ let dict = await new Promise(resolve =>
+ PropertyListUtils.read(this._file, resolve)
+ );
+ if (!dict) {
+ throw new Error("Could not read Bookmarks.plist");
+ }
+ let children = dict.get("Children");
+ if (!children) {
+ throw new Error("Invalid Bookmarks.plist format");
+ }
+
+ let collection =
+ dict.get("Title") == "com.apple.ReadingList"
+ ? this.READING_LIST_COLLECTION
+ : this.ROOT_COLLECTION;
+ await this._migrateCollection(children, collection);
+ if (
+ this._histogramBookmarkRoots &
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR
+ ) {
+ PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration();
+ }
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS")
+ .add("safari", this._histogramBookmarkRoots);
+ })().then(
+ () => aCallback(true),
+ e => {
+ Cu.reportError(e);
+ aCallback(false);
+ }
+ );
+ },
+
+ // Bookmarks collections in Safari. Constants for migrateCollection.
+ ROOT_COLLECTION: 0,
+ MENU_COLLECTION: 1,
+ TOOLBAR_COLLECTION: 2,
+ READING_LIST_COLLECTION: 3,
+
+ /**
+ * Recursively migrate a Safari collection of bookmarks.
+ *
+ * @param aEntries
+ * the collection's children
+ * @param aCollection
+ * one of the values above.
+ */
+ async _migrateCollection(aEntries, aCollection) {
+ // A collection of bookmarks in Safari resembles places roots. In the
+ // property list files (Bookmarks.plist, ReadingList.plist) they are
+ // stored as regular bookmarks folders, and thus can only be distinguished
+ // from by their names and places in the hierarchy.
+
+ let entriesFiltered = [];
+ if (aCollection == this.ROOT_COLLECTION) {
+ for (let entry of aEntries) {
+ let type = entry.get("WebBookmarkType");
+ if (type == "WebBookmarkTypeList" && entry.has("Children")) {
+ let title = entry.get("Title");
+ let children = entry.get("Children");
+ if (title == "BookmarksBar") {
+ await this._migrateCollection(children, this.TOOLBAR_COLLECTION);
+ } else if (title == "BookmarksMenu") {
+ await this._migrateCollection(children, this.MENU_COLLECTION);
+ } else if (title == "com.apple.ReadingList") {
+ await this._migrateCollection(
+ children,
+ this.READING_LIST_COLLECTION
+ );
+ } else if (entry.get("ShouldOmitFromUI") !== true) {
+ entriesFiltered.push(entry);
+ }
+ } else if (type == "WebBookmarkTypeLeaf") {
+ entriesFiltered.push(entry);
+ }
+ }
+ } else {
+ entriesFiltered = aEntries;
+ }
+
+ if (!entriesFiltered.length) {
+ return;
+ }
+
+ let folderGuid = -1;
+ switch (aCollection) {
+ case this.ROOT_COLLECTION: {
+ // In Safari, it is possible (though quite cumbersome) to move
+ // bookmarks to the bookmarks root, which is the parent folder of
+ // all bookmarks "collections". That is somewhat in parallel with
+ // both the places root and the unfiled-bookmarks root.
+ // Because the former is only an implementation detail in our UI,
+ // the unfiled root seems to be the best choice.
+ folderGuid = PlacesUtils.bookmarks.unfiledGuid;
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_UNFILED;
+ break;
+ }
+ case this.MENU_COLLECTION: {
+ folderGuid = PlacesUtils.bookmarks.menuGuid;
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(folderGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ folderGuid = await MigrationUtils.createImportedBookmarksFolder(
+ "Safari",
+ folderGuid
+ );
+ }
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU;
+ break;
+ }
+ case this.TOOLBAR_COLLECTION: {
+ folderGuid = PlacesUtils.bookmarks.toolbarGuid;
+ if (
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") &&
+ !MigrationUtils.isStartupMigration &&
+ PlacesUtils.getChildCountForFolder(folderGuid) >
+ PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE
+ ) {
+ folderGuid = await MigrationUtils.createImportedBookmarksFolder(
+ "Safari",
+ folderGuid
+ );
+ }
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ break;
+ }
+ case this.READING_LIST_COLLECTION: {
+ // Reading list items are imported as regular bookmarks.
+ // They are imported under their own folder, created either under the
+ // bookmarks menu (in the case of startup migration).
+ let readingListTitle = await MigrationUtils.getLocalizedString(
+ "imported-safari-reading-list"
+ );
+ folderGuid = (
+ await MigrationUtils.insertBookmarkWrapper({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: readingListTitle,
+ })
+ ).guid;
+ this._histogramBookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_READING_LIST;
+ break;
+ }
+ default:
+ throw new Error("Unexpected value for aCollection!");
+ }
+ if (folderGuid == -1) {
+ throw new Error("Invalid folder GUID");
+ }
+
+ await this._migrateEntries(entriesFiltered, folderGuid);
+ },
+
+ // migrate the given array of safari bookmarks to the given places
+ // folder.
+ _migrateEntries(entries, parentGuid) {
+ let convertedEntries = this._convertEntries(entries);
+ return MigrationUtils.insertManyBookmarksWrapper(
+ convertedEntries,
+ parentGuid
+ );
+ },
+
+ _convertEntries(entries) {
+ return entries
+ .map(function(entry) {
+ let type = entry.get("WebBookmarkType");
+ if (type == "WebBookmarkTypeList" && entry.has("Children")) {
+ return {
+ title: entry.get("Title"),
+ type: PlacesUtils.bookmarks.TYPE_FOLDER,
+ children: this._convertEntries(entry.get("Children")),
+ };
+ }
+ if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) {
+ // Check we understand this URL before adding it:
+ let url = entry.get("URLString");
+ try {
+ new URL(url);
+ } catch (ex) {
+ Cu.reportError(
+ `Ignoring ${url} when importing from Safari because of exception: ${ex}`
+ );
+ return null;
+ }
+ let title;
+ if (entry.has("URIDictionary")) {
+ title = entry.get("URIDictionary").get("title");
+ }
+ return { url, title };
+ }
+ return null;
+ }, this)
+ .filter(e => !!e);
+ },
+};
+
+function History(aHistoryFile) {
+ this._file = aHistoryFile;
+}
+History.prototype = {
+ type: MigrationUtils.resourceTypes.HISTORY,
+
+ // Helper method for converting the visit date property to a PRTime value.
+ // The visit date is stored as a string, so it's not read as a Date
+ // object by PropertyListUtils.
+ _parseCocoaDate: function H___parseCocoaDate(aCocoaDateStr) {
+ let asDouble = parseFloat(aCocoaDateStr);
+ if (!isNaN(asDouble)) {
+ // reference date of NSDate.
+ let date = new Date("1 January 2001, GMT");
+ date.setMilliseconds(asDouble * 1000);
+ return date;
+ }
+ return new Date();
+ },
+
+ migrate: function H_migrate(aCallback) {
+ PropertyListUtils.read(this._file, aDict => {
+ try {
+ if (!aDict) {
+ throw new Error("Could not read history property list");
+ }
+ if (!aDict.has("WebHistoryDates")) {
+ throw new Error("Unexpected history-property list format");
+ }
+
+ let pageInfos = [];
+ let entries = aDict.get("WebHistoryDates");
+ let failedOnce = false;
+ for (let entry of entries) {
+ if (entry.has("lastVisitedDate")) {
+ let date = this._parseCocoaDate(entry.get("lastVisitedDate"));
+ try {
+ pageInfos.push({
+ url: new URL(entry.get("")),
+ title: entry.get("title"),
+ visits: [
+ {
+ // Safari's History file contains only top-level urls. It does not
+ // distinguish between typed urls and linked urls.
+ transition: PlacesUtils.history.TRANSITIONS.LINK,
+ date,
+ },
+ ],
+ });
+ } catch (ex) {
+ // Safari's History file may contain malformed URIs which
+ // will be ignored.
+ Cu.reportError(ex);
+ failedOnce = true;
+ }
+ }
+ }
+ if (!pageInfos.length) {
+ // If we failed at least once, then we didn't succeed in importing,
+ // otherwise we didn't actually have anything to import, so we'll
+ // report it as a success.
+ aCallback(!failedOnce);
+ return;
+ }
+
+ MigrationUtils.insertVisitsWrapper(pageInfos).then(
+ () => aCallback(true),
+ () => aCallback(false)
+ );
+ } catch (ex) {
+ Cu.reportError(ex);
+ aCallback(false);
+ }
+ });
+ },
+};
+
+/**
+ * Safari's preferences property list is independently used for three purposes:
+ * (a) importation of preferences
+ * (b) importation of search strings
+ * (c) retrieving the home page.
+ *
+ * So, rather than reading it three times, it's cached and managed here.
+ */
+function MainPreferencesPropertyList(aPreferencesFile) {
+ this._file = aPreferencesFile;
+ this._callbacks = [];
+}
+MainPreferencesPropertyList.prototype = {
+ /**
+ * @see PropertyListUtils.read
+ */
+ read: function MPPL_read(aCallback) {
+ if ("_dict" in this) {
+ aCallback(this._dict);
+ return;
+ }
+
+ let alreadyReading = !!this._callbacks.length;
+ this._callbacks.push(aCallback);
+ if (!alreadyReading) {
+ PropertyListUtils.read(this._file, aDict => {
+ this._dict = aDict;
+ for (let callback of this._callbacks) {
+ try {
+ callback(aDict);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ this._callbacks.splice(0);
+ });
+ }
+ },
+};
+
+function SearchStrings(aMainPreferencesPropertyListInstance) {
+ this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance;
+}
+SearchStrings.prototype = {
+ type: MigrationUtils.resourceTypes.OTHERDATA,
+
+ migrate: function SS_migrate(aCallback) {
+ this._mainPreferencesPropertyList.read(
+ MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) {
+ if (!aDict) {
+ throw new Error("Could not get preferences dictionary");
+ }
+
+ if (aDict.has("RecentSearchStrings")) {
+ let recentSearchStrings = aDict.get("RecentSearchStrings");
+ if (recentSearchStrings && recentSearchStrings.length) {
+ let changes = recentSearchStrings.map(searchString => ({
+ op: "add",
+ fieldname: "searchbar-history",
+ value: searchString,
+ }));
+ FormHistory.update(changes);
+ }
+ }
+ }, aCallback)
+ );
+ },
+};
+
+function SafariProfileMigrator() {}
+
+SafariProfileMigrator.prototype = Object.create(MigratorPrototype);
+
+SafariProfileMigrator.prototype.getResources = function SM_getResources() {
+ let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false);
+ if (!profileDir.exists()) {
+ return null;
+ }
+
+ let resources = [];
+ let pushProfileFileResource = function(aFileName, aConstructor) {
+ let file = profileDir.clone();
+ file.append(aFileName);
+ if (file.exists()) {
+ resources.push(new aConstructor(file));
+ }
+ };
+
+ pushProfileFileResource("History.plist", History);
+ pushProfileFileResource("Bookmarks.plist", Bookmarks);
+
+ // The Reading List feature was introduced at the same time in Windows and
+ // Mac versions of Safari. Not surprisingly, they are stored in the same
+ // format in both versions. Surpsingly, only on Windows there is a
+ // separate property list for it. This code is used on mac too, because
+ // Apple may fix this at some point.
+ pushProfileFileResource("ReadingList.plist", Bookmarks);
+
+ let prefs = this.mainPreferencesPropertyList;
+ if (prefs) {
+ resources.push(new SearchStrings(prefs));
+ }
+
+ return resources;
+};
+
+SafariProfileMigrator.prototype.getLastUsedDate = function SM_getLastUsedDate() {
+ let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false);
+ let datePromises = ["Bookmarks.plist", "History.plist"].map(file => {
+ let path = OS.Path.join(profileDir.path, file);
+ return OS.File.stat(path)
+ .catch(() => null)
+ .then(info => {
+ return info ? info.lastModificationDate : 0;
+ });
+ });
+ return Promise.all(datePromises).then(dates => {
+ return new Date(Math.max.apply(Math, dates));
+ });
+};
+
+SafariProfileMigrator.prototype.hasPermissions = async function SM_hasPermissions() {
+ if (this._hasPermissions) {
+ return true;
+ }
+ // Check if we have access:
+ let target = FileUtils.getDir(
+ "ULibDir",
+ ["Safari", "Bookmarks.plist"],
+ false
+ );
+ try {
+ // 'stat' is always allowed, but reading is somehow not, if the user hasn't
+ // allowed it:
+ await IOUtils.read(target.path, { maxBytes: 1 });
+ this._hasPermissions = true;
+ return true;
+ } catch (ex) {
+ return false;
+ }
+};
+
+SafariProfileMigrator.prototype.getPermissions = async function SM_getPermissions(
+ win
+) {
+ // Keep prompting the user until they pick a file that grants us access,
+ // or they cancel out of the file open panel.
+ while (!(await this.hasPermissions())) {
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker);
+ // The title (second arg) is not displayed on macOS, so leave it blank.
+ fp.init(win, "", Ci.nsIFilePicker.modeOpen);
+ // This is a little weird. You'd expect that it matters which file
+ // the user picks, but it doesn't really, as long as it's in this
+ // directory. Anyway, let's not confuse the user: the sensible idea
+ // here is to ask for permissions for Bookmarks.plist, and we'll
+ // silently accept whatever input as long as we can then read the plist.
+ fp.appendFilter("plist", "*.plist");
+ fp.filterIndex = 1;
+ fp.displayDirectory = FileUtils.getDir("ULibDir", ["Safari"], false);
+ // Now wait for the filepicker to open and close. If the user picks
+ // any file in this directory, macOS will grant us read access, so
+ // we don't need to check or do anything else with the file returned
+ // by the filepicker.
+ let result = await new Promise(resolve => fp.open(resolve));
+ // Bail if the user cancels the dialog:
+ if (result == Ci.nsIFilePicker.returnCancel) {
+ return false;
+ }
+ }
+};
+
+Object.defineProperty(
+ SafariProfileMigrator.prototype,
+ "mainPreferencesPropertyList",
+ {
+ get: function get_mainPreferencesPropertyList() {
+ if (this._mainPreferencesPropertyList === undefined) {
+ let file = FileUtils.getDir("UsrPrfs", [], false);
+ if (file.exists()) {
+ file.append("com.apple.Safari.plist");
+ if (file.exists()) {
+ this._mainPreferencesPropertyList = new MainPreferencesPropertyList(
+ file
+ );
+ return this._mainPreferencesPropertyList;
+ }
+ }
+ this._mainPreferencesPropertyList = null;
+ return this._mainPreferencesPropertyList;
+ }
+ return this._mainPreferencesPropertyList;
+ },
+ }
+);
+
+SafariProfileMigrator.prototype.classDescription = "Safari Profile Migrator";
+SafariProfileMigrator.prototype.contractID =
+ "@mozilla.org/profile/migrator;1?app=browser&type=safari";
+SafariProfileMigrator.prototype.classID = Components.ID(
+ "{4b609ecf-60b2-4655-9df4-dc149e474da1}"
+);
+
+var EXPORTED_SYMBOLS = ["SafariProfileMigrator"];
diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf
new file mode 100644
index 0000000000..aef59b486c
--- /dev/null
+++ b/browser/components/migration/components.conf
@@ -0,0 +1,114 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XP_WIN = buildconfig.substs['OS_ARCH'] == 'WINNT'
+XP_MACOSX = buildconfig.substs['MOZ_WIDGET_TOOLKIT'] == 'cocoa'
+
+Classes = [
+ {
+ 'cid': '{6F8BB968-C14F-4D6F-9733-6C6737B35DCE}',
+ 'contract_ids': ['@mozilla.org/toolkit/profile-migrator;1'],
+ 'jsm': 'resource:///modules/ProfileMigrator.jsm',
+ 'constructor': 'ProfileMigrator',
+ },
+ {
+ 'cid': '{4cec1de4-1671-4fc3-a53e-6c539dc77a26}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromeProfileMigrator',
+ },
+ {
+ 'cid': '{8cece922-9720-42de-b7db-7cef88cb07ca}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromiumProfileMigrator',
+ },
+ {
+ 'cid': '{91185366-ba97-4438-acba-48deaca63386}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=firefox'],
+ 'jsm': 'resource:///modules/FirefoxProfileMigrator.jsm',
+ 'constructor': 'FirefoxProfileMigrator',
+ },
+]
+
+if not XP_MACOSX:
+ Classes += [
+ {
+ 'cid': '{47f75963-840b-4950-a1f0-d9c1864f8b8e}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome-beta'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromeBetaMigrator',
+ },
+ ]
+
+if XP_WIN or XP_MACOSX:
+ Classes += [
+ {
+ 'cid': '{4bf85aa5-4e21-46ca-825f-f9c51a5e8c76}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=canary'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'CanaryProfileMigrator',
+ },
+ {
+ 'cid': '{3c7f6b7c-baa9-4338-acfa-04bf79f1dcf1}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromiumEdgeMigrator',
+ },
+ {
+ 'cid': '{0fc3d48a-c1c3-4871-b58f-a8b47d1555fb}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge-beta'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromiumEdgeBetaMigrator',
+ },
+ ]
+else:
+ Classes += [
+ {
+ 'cid': '{7370a02a-4886-42c3-a4ec-d48c726ec30a}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome-dev'],
+ 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm',
+ 'constructor': 'ChromeDevMigrator',
+ },
+ ]
+
+if XP_WIN:
+ Classes += [
+ {
+ 'cid': '{3d2532e3-4932-4774-b7ba-968f5899d3a4}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=ie'],
+ 'jsm': 'resource:///modules/IEProfileMigrator.jsm',
+ 'constructor': 'IEProfileMigrator',
+ },
+ {
+ 'cid': '{62e8834b-2d17-49f5-96ff-56344903a2ae}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=edge'],
+ 'jsm': 'resource:///modules/EdgeProfileMigrator.jsm',
+ 'constructor': 'EdgeProfileMigrator',
+ },
+ {
+ 'cid': '{d0037b95-296a-4a4e-94b2-c3d075d20ab1}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=360se'],
+ 'jsm': 'resource:///modules/360seProfileMigrator.jsm',
+ 'constructor': 'Qihoo360seProfileMigrator',
+ },
+ ]
+
+if XP_MACOSX:
+ Classes += [
+ {
+ 'cid': '{4b609ecf-60b2-4655-9df4-dc149e474da1}',
+ 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=safari'],
+ 'jsm': 'resource:///modules/SafariProfileMigrator.jsm',
+ 'constructor': 'SafariProfileMigrator',
+ },
+ {
+ 'cid': '{647bf80c-cd35-4ce6-b904-fd586b97ae48}',
+ 'contract_ids': ['@mozilla.org/profile/migrator/keychainmigrationutils;1'],
+ 'type': 'nsKeychainMigrationUtils',
+ 'headers': ['nsKeychainMigrationUtils.h'],
+ },
+ ]
diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml
new file mode 100644
index 0000000000..e4c02d23d3
--- /dev/null
+++ b/browser/components/migration/content/aboutWelcomeBack.xhtml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+# 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/.
+-->
+<!DOCTYPE html [
+ <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd">
+ %htmlDTD;
+]>
+
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <head>
+ <meta http-equiv="Content-Security-Policy" content="default-src chrome: resource:; img-src chrome: resource: data:; object-src 'none'" />
+ <title data-l10n-id="welcome-back-tab-title"></title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css" media="all"/>
+ <link rel="stylesheet" href="chrome://browser/skin/aboutWelcomeBack.css" type="text/css" media="all"/>
+ <link rel="icon" type="image/png" href="chrome://browser/skin/info.svg"/>
+ <link rel="localization" href="browser/aboutSessionRestore.ftl"/>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <script src="chrome://browser/content/aboutSessionRestore.js"/>
+ </head>
+
+ <body>
+
+ <div class="container">
+
+ <div class="title">
+ <h1 class="title-text" data-l10n-id="welcome-back-page-title"></h1>
+ </div>
+
+ <div class="description">
+
+ <p data-l10n-id="welcome-back-page-info"></p>
+ <!-- Note a href in the anchor below is added by JS -->
+ <p data-l10n-id="welcome-back-page-info-link"><a id="linkMoreTroubleshooting" target="_blank" data-l10n-name="link-more"></a></p>
+
+ <div>
+ <div class="radioRestoreContainer">
+ <input class="radioRestoreButton" id="radioRestoreAll" type="radio"
+ name="restore" checked="checked"/>
+ <label class="radioRestoreLabel" for="radioRestoreAll" data-l10n-id="welcome-back-restore-all-label"></label>
+ </div>
+
+ <div class="radioRestoreContainer">
+ <input class="radioRestoreButton" id="radioRestoreChoose" type="radio"
+ name="restore"/>
+ <label class="radioRestoreLabel" for="radioRestoreChoose" data-l10n-id="welcome-back-restore-some-label"></label>
+ </div>
+ </div>
+ </div>
+
+ <div class="tree-container">
+ <xul:tree id="tabList" flex="1" seltype="single" hidecolumnpicker="true">
+ <xul:treecols>
+ <xul:treecol cycler="true" id="restore" type="checkbox" data-l10n-id="restore-page-restore-header"/>
+ <xul:splitter class="tree-splitter"/>
+ <xul:treecol primary="true" id="title" data-l10n-id="restore-page-list-header" flex="1"/>
+ </xul:treecols>
+ <xul:treechildren flex="1"/>
+ </xul:tree>
+ </div>
+
+ <div class="button-container">
+ <xul:button class="primary"
+ id="errorTryAgain"
+ data-l10n-id="welcome-back-restore-button"/>
+ </div>
+
+ <input type="text" id="sessionData" hidden="true"/>
+
+ </div>
+ </body>
+</html>
diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js
new file mode 100644
index 0000000000..e9cc1df645
--- /dev/null
+++ b/browser/components/migration/content/migration.js
@@ -0,0 +1,658 @@
+/* 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 kIMig = Ci.nsIBrowserProfileMigrator;
+const kIPStartup = Ci.nsIProfileStartup;
+
+const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { MigrationUtils } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+
+/**
+ * Map from data types that match Ci.nsIBrowserProfileMigrator's types to
+ * prefixes for strings used to label these data types in the migration
+ * dialog. We use these strings with -checkbox and -label suffixes for the
+ * checkboxes on the "importItems" page, and for the labels on the "migrating"
+ * and "done" pages, respectively.
+ */
+const kDataToStringMap = new Map([
+ ["cookies", "browser-data-cookies"],
+ ["history", "browser-data-history"],
+ ["formdata", "browser-data-formdata"],
+ ["passwords", "browser-data-passwords"],
+ ["bookmarks", "browser-data-bookmarks"],
+ ["otherdata", "browser-data-otherdata"],
+ ["session", "browser-data-session"],
+]);
+
+var MigrationWizard = {
+ /* exported MigrationWizard */
+ _source: "", // Source Profile Migrator ContractID suffix
+ _itemsFlags: kIMig.ALL, // Selected Import Data Sources (16-bit bitfield)
+ _selectedProfile: null, // Selected Profile name to import from
+ _wiz: null,
+ _migrator: null,
+ _autoMigrate: null,
+ _receivedPermissions: new Set(),
+
+ init() {
+ let os = Services.obs;
+ os.addObserver(this, "Migration:Started");
+ os.addObserver(this, "Migration:ItemBeforeMigrate");
+ os.addObserver(this, "Migration:ItemAfterMigrate");
+ os.addObserver(this, "Migration:ItemError");
+ os.addObserver(this, "Migration:Ended");
+
+ this._wiz = document.querySelector("wizard");
+
+ let args = window.arguments;
+ let entryPointId = args[0] || MigrationUtils.MIGRATION_ENTRYPOINT_UNKNOWN;
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_ENTRY_POINT")
+ .add(entryPointId);
+ this.isInitialMigration =
+ entryPointId == MigrationUtils.MIGRATION_ENTRYPOINT_FIRSTRUN;
+
+ {
+ // Record that the uninstaller requested a profile refresh
+ let env = Cc["@mozilla.org/process/environment;1"].getService(
+ Ci.nsIEnvironment
+ );
+ if (env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) {
+ env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", "");
+ Services.telemetry.scalarSet(
+ "migration.uninstaller_profile_refresh",
+ true
+ );
+ }
+ }
+
+ if (args.length == 2) {
+ this._source = args[1];
+ } else if (args.length > 2) {
+ this._source = args[1];
+ this._migrator = args[2] instanceof kIMig ? args[2] : null;
+ this._autoMigrate = args[3].QueryInterface(kIPStartup);
+ this._skipImportSourcePage = args[4];
+ if (this._migrator && args[5]) {
+ let sourceProfiles = this.spinResolve(
+ this._migrator.getSourceProfiles()
+ );
+ this._selectedProfile = sourceProfiles.find(
+ profile => profile.id == args[5]
+ );
+ }
+
+ if (this._autoMigrate) {
+ // Show the "nothing" option in the automigrate case to provide an
+ // easily identifiable way to avoid migration and create a new profile.
+ document.getElementById("nothing").hidden = false;
+ }
+ }
+ this._setSourceForDataLocalization();
+
+ document.addEventListener("wizardcancel", function() {
+ MigrationWizard.onWizardCancel();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pageshow", function() {
+ MigrationWizard.onSelectProfilePageShow();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pageshow", function() {
+ MigrationWizard.onImportItemsPageShow();
+ });
+ document
+ .getElementById("migrating")
+ .addEventListener("pageshow", function() {
+ MigrationWizard.onMigratingPageShow();
+ });
+ document.getElementById("done").addEventListener("pageshow", function() {
+ MigrationWizard.onDonePageShow();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pagerewound", function() {
+ MigrationWizard.onSelectProfilePageRewound();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pagerewound", function() {
+ MigrationWizard.onImportItemsPageRewound();
+ });
+
+ document
+ .getElementById("selectProfile")
+ .addEventListener("pageadvanced", function() {
+ MigrationWizard.onSelectProfilePageAdvanced();
+ });
+ document
+ .getElementById("importItems")
+ .addEventListener("pageadvanced", function() {
+ MigrationWizard.onImportItemsPageAdvanced();
+ });
+ document
+ .getElementById("importPermissions")
+ .addEventListener("pageadvanced", function(e) {
+ MigrationWizard.onImportPermissionsPageAdvanced(e);
+ });
+ document
+ .getElementById("importSource")
+ .addEventListener("pageadvanced", function(e) {
+ MigrationWizard.onImportSourcePageAdvanced(e);
+ });
+
+ this.onImportSourcePageShow();
+ },
+
+ uninit() {
+ var os = Services.obs;
+ os.removeObserver(this, "Migration:Started");
+ os.removeObserver(this, "Migration:ItemBeforeMigrate");
+ os.removeObserver(this, "Migration:ItemAfterMigrate");
+ os.removeObserver(this, "Migration:ItemError");
+ os.removeObserver(this, "Migration:Ended");
+ MigrationUtils.finishMigration();
+ },
+
+ spinResolve(promise) {
+ let canAdvance = this._wiz.canAdvance;
+ let canRewind = this._wiz.canRewind;
+ this._wiz.canAdvance = false;
+ this._wiz.canRewind = false;
+ let result = MigrationUtils.spinResolve(promise);
+ this._wiz.canAdvance = canAdvance;
+ this._wiz.canRewind = canRewind;
+ return result;
+ },
+
+ _setSourceForDataLocalization() {
+ this._sourceForDataLocalization = this._source;
+ // Ensure consistency for various channels, brandings and versions of
+ // Chromium and MS Edge.
+ if (this._sourceForDataLocalization) {
+ this._sourceForDataLocalization = this._sourceForDataLocalization
+ .replace(/^(chromium-edge-beta|chromium-edge)$/, "edge")
+ .replace(/^(canary|chromium|chrome-beta|chrome-dev)$/, "chrome");
+ }
+ },
+
+ onWizardCancel() {
+ MigrationUtils.forceExitSpinResolve();
+ return true;
+ },
+
+ // 1 - Import Source
+ onImportSourcePageShow() {
+ // Show warning message to close the selected browser when needed
+ let toggleCloseBrowserWarning = () => {
+ let visibility = "hidden";
+ if (group.selectedItem.id != "nothing") {
+ let migrator = this.spinResolve(
+ MigrationUtils.getMigrator(group.selectedItem.id)
+ );
+ visibility = migrator.sourceLocked ? "visible" : "hidden";
+ }
+ document.getElementById(
+ "closeSourceBrowser"
+ ).style.visibility = visibility;
+ };
+ this._wiz.canRewind = false;
+
+ var selectedMigrator = null;
+ this._availableMigrators = [];
+
+ // Figure out what source apps are are available to import from:
+ var group = document.getElementById("importSourceGroup");
+ for (var i = 0; i < group.childNodes.length; ++i) {
+ var migratorKey = group.childNodes[i].id;
+ if (migratorKey != "nothing") {
+ var migrator = this.spinResolve(
+ MigrationUtils.getMigrator(migratorKey)
+ );
+ if (migrator) {
+ // Save this as the first selectable item, if we don't already have
+ // one, or if it is the migrator that was passed to us.
+ if (!selectedMigrator || this._source == migratorKey) {
+ selectedMigrator = group.childNodes[i];
+ }
+ this._availableMigrators.push([migratorKey, migrator]);
+ } else {
+ // Hide this option
+ group.childNodes[i].hidden = true;
+ }
+ }
+ }
+ if (this.isInitialMigration) {
+ Services.telemetry
+ .getHistogramById("FX_STARTUP_MIGRATION_BROWSER_COUNT")
+ .add(this._availableMigrators.length);
+ let defaultBrowser = MigrationUtils.getMigratorKeyForDefaultBrowser();
+ // This will record 0 for unknown default browser IDs.
+ defaultBrowser = MigrationUtils.getSourceIdForTelemetry(defaultBrowser);
+ Services.telemetry
+ .getHistogramById("FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER")
+ .add(defaultBrowser);
+ }
+
+ group.addEventListener("command", toggleCloseBrowserWarning);
+
+ if (selectedMigrator) {
+ group.selectedItem = selectedMigrator;
+ toggleCloseBrowserWarning();
+ } else {
+ // We didn't find a migrator, notify the user
+ document.getElementById("noSources").hidden = false;
+
+ this._wiz.canAdvance = false;
+
+ document.getElementById("importAll").hidden = true;
+ }
+
+ // Advance to the next page if the caller told us to.
+ if (this._migrator && this._skipImportSourcePage) {
+ this._wiz.advance();
+ this._wiz.canRewind = false;
+ }
+ },
+
+ onImportSourcePageAdvanced(event) {
+ var newSource = document.getElementById("importSourceGroup").selectedItem
+ .id;
+
+ if (newSource == "nothing") {
+ // Need to do telemetry here because we're closing the dialog before we get to
+ // do actual migration. For actual migration, this doesn't happen until after
+ // migration takes place.
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_SOURCE_BROWSER")
+ .add(MigrationUtils.getSourceIdForTelemetry("nothing"));
+ this._wiz.cancel();
+ event.preventDefault();
+ }
+
+ if (!this._migrator || newSource != this._source) {
+ // Create the migrator for the selected source.
+ this._migrator = this.spinResolve(MigrationUtils.getMigrator(newSource));
+
+ this._itemsFlags = kIMig.ALL;
+ this._selectedProfile = null;
+ }
+ this._source = newSource;
+ this._setSourceForDataLocalization();
+
+ // check for more than one source profile
+ var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ if (this._skipImportSourcePage) {
+ this._updateNextPageForPermissions();
+ } else if (sourceProfiles && sourceProfiles.length > 1) {
+ this._wiz.currentPage.next = "selectProfile";
+ } else {
+ if (this._autoMigrate) {
+ this._updateNextPageForPermissions();
+ } else {
+ this._wiz.currentPage.next = "importItems";
+ }
+
+ if (sourceProfiles && sourceProfiles.length == 1) {
+ this._selectedProfile = sourceProfiles[0];
+ } else {
+ this._selectedProfile = null;
+ }
+ }
+ },
+
+ // 2 - [Profile Selection]
+ onSelectProfilePageShow() {
+ // Disabling this for now, since we ask about import sources in automigration
+ // too and don't want to disable the back button
+ // if (this._autoMigrate)
+ // document.documentElement.getButton("back").disabled = true;
+
+ var profiles = document.getElementById("profiles");
+ while (profiles.hasChildNodes()) {
+ profiles.firstChild.remove();
+ }
+
+ // Note that this block is still reached even if the user chose 'From File'
+ // and we canceled the dialog. When that happens, _migrator will be null.
+ if (this._migrator) {
+ var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+
+ for (let profile of sourceProfiles) {
+ var item = document.createXULElement("radio");
+ item.id = profile.id;
+ item.setAttribute("label", profile.name);
+ profiles.appendChild(item);
+ }
+ }
+
+ profiles.selectedItem = this._selectedProfile
+ ? document.getElementById(this._selectedProfile.id)
+ : profiles.firstChild;
+ },
+
+ onSelectProfilePageRewound() {
+ var profiles = document.getElementById("profiles");
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile =
+ sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) ||
+ null;
+ },
+
+ onSelectProfilePageAdvanced() {
+ var profiles = document.getElementById("profiles");
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile =
+ sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) ||
+ null;
+
+ // If we're automigrating or just doing bookmarks don't show the item selection page
+ if (this._autoMigrate) {
+ this._updateNextPageForPermissions();
+ }
+ },
+
+ // 3 - ImportItems
+ onImportItemsPageShow() {
+ var dataSources = document.getElementById("dataSources");
+ while (dataSources.hasChildNodes()) {
+ dataSources.firstChild.remove();
+ }
+
+ var items = this.spinResolve(
+ this._migrator.getMigrateData(this._selectedProfile)
+ );
+
+ for (let itemType of kDataToStringMap.keys()) {
+ let itemValue = Ci.nsIBrowserProfileMigrator[itemType.toUpperCase()];
+ if (items & itemValue) {
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.id = itemValue;
+ document.l10n.setAttributes(
+ checkbox,
+ kDataToStringMap.get(itemType) + "-checkbox",
+ { browser: this._sourceForDataLocalization }
+ );
+ dataSources.appendChild(checkbox);
+ if (!this._itemsFlags || this._itemsFlags & itemValue) {
+ checkbox.checked = true;
+ }
+ }
+ }
+ },
+
+ onImportItemsPageRewound() {
+ this._wiz.canAdvance = true;
+ this.onImportItemsPageAdvanced();
+ },
+
+ onImportItemsPageAdvanced() {
+ var dataSources = document.getElementById("dataSources");
+ this._itemsFlags = 0;
+ for (var i = 0; i < dataSources.childNodes.length; ++i) {
+ var checkbox = dataSources.childNodes[i];
+ if (checkbox.localName == "checkbox" && checkbox.checked) {
+ this._itemsFlags |= parseInt(checkbox.id);
+ }
+ }
+
+ this._updateNextPageForPermissions();
+ },
+
+ onImportItemCommand() {
+ var items = document.getElementById("dataSources");
+ var checkboxes = items.getElementsByTagName("checkbox");
+
+ var oneChecked = false;
+ for (var i = 0; i < checkboxes.length; ++i) {
+ if (checkboxes[i].checked) {
+ oneChecked = true;
+ break;
+ }
+ }
+
+ this._wiz.canAdvance = oneChecked;
+
+ this._updateNextPageForPermissions();
+ },
+
+ _updateNextPageForPermissions() {
+ // We would like to just go straight to work:
+ this._wiz.currentPage.next = "migrating";
+ // If we already have permissions, this is easy:
+ if (this._receivedPermissions.has(this._source)) {
+ return;
+ }
+
+ // Otherwise, if we're on mojave or later and importing from
+ // Safari, prompt for the bookmarks file.
+ // We may add other browser/OS combos here in future.
+ if (
+ this._source == "safari" &&
+ AppConstants.isPlatformAndVersionAtLeast("macosx", "18") &&
+ this._itemsFlags & MigrationUtils.resourceTypes.BOOKMARKS
+ ) {
+ let migrator = this._migrator.wrappedJSObject;
+ let havePermissions = this.spinResolve(migrator.hasPermissions());
+
+ if (!havePermissions) {
+ this._wiz.currentPage.next = "importPermissions";
+ }
+ }
+ },
+
+ // 3b: permissions. This gets invoked when the user clicks "Next"
+ async onImportPermissionsPageAdvanced(event) {
+ // We're done if we have permission:
+ if (this._receivedPermissions.has(this._source)) {
+ return;
+ }
+ // The wizard helper is sync, and we need to check some stuff, so just stop
+ // advancing for now and prompt the user, then advance the wizard if everything
+ // worked.
+ event.preventDefault();
+
+ let migrator = this._migrator.wrappedJSObject;
+ await migrator.getPermissions(window);
+ if (await migrator.hasPermissions()) {
+ this._receivedPermissions.add(this._source);
+ // Re-enter (we'll then allow the advancement through the early return above)
+ this._wiz.advance();
+ }
+ // if we didn't have permissions after the `getPermissions` call, the user
+ // cancelled the dialog. Just no-op out now; the user can re-try by clicking
+ // the 'Continue' button again, or go back and pick a different browser.
+ },
+
+ // 4 - Migrating
+ onMigratingPageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._wiz.canAdvance = false;
+
+ // When automigrating, show all of the data that can be received from this source.
+ if (this._autoMigrate) {
+ this._itemsFlags = this.spinResolve(
+ this._migrator.getMigrateData(this._selectedProfile)
+ );
+ }
+
+ this._listItems("migratingItems");
+ setTimeout(() => this.onMigratingMigrate(), 0);
+ },
+
+ async onMigratingMigrate() {
+ await this._migrator.migrate(
+ this._itemsFlags,
+ this._autoMigrate,
+ this._selectedProfile
+ );
+
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_SOURCE_BROWSER")
+ .add(MigrationUtils.getSourceIdForTelemetry(this._source));
+ if (!this._autoMigrate) {
+ let hist = Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE");
+ let exp = 0;
+ let items = this._itemsFlags;
+ while (items) {
+ if (items & 1) {
+ hist.add(this._source, exp);
+ }
+ items = items >> 1;
+ exp++;
+ }
+ }
+ },
+
+ _listItems(aID) {
+ var items = document.getElementById(aID);
+ while (items.hasChildNodes()) {
+ items.firstChild.remove();
+ }
+
+ for (let itemType of kDataToStringMap.keys()) {
+ let itemValue = Ci.nsIBrowserProfileMigrator[itemType.toUpperCase()];
+ if (this._itemsFlags & itemValue) {
+ var label = document.createXULElement("label");
+ label.id = itemValue + "_migrated";
+ try {
+ document.l10n.setAttributes(
+ label,
+ kDataToStringMap.get(itemType) + "-label",
+ { browser: this._sourceForDataLocalization }
+ );
+ items.appendChild(label);
+ } catch (e) {
+ // if the block above throws, we've enumerated all the import data types we
+ // currently support and are now just wasting time, break.
+ break;
+ }
+ }
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ var label;
+ switch (aTopic) {
+ case "Migration:Started":
+ break;
+ case "Migration:ItemBeforeMigrate":
+ label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.setAttribute("style", "font-weight: bold");
+ }
+ break;
+ case "Migration:ItemAfterMigrate":
+ label = document.getElementById(aData + "_migrated");
+ if (label) {
+ label.removeAttribute("style");
+ }
+ break;
+ case "Migration:Ended":
+ if (this.isInitialMigration) {
+ // Ensure errors in reporting data recency do not affect the rest of the migration.
+ try {
+ this.reportDataRecencyTelemetry();
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ if (this._autoMigrate) {
+ // We're done now.
+ this._wiz.canAdvance = true;
+ this._wiz.advance();
+
+ setTimeout(close, 5000);
+ } else {
+ this._wiz.canAdvance = true;
+ var nextButton = this._wiz.getButton("next");
+ nextButton.click();
+ }
+ break;
+ case "Migration:ItemError":
+ let type = "undefined";
+ let numericType = parseInt(aData);
+ switch (numericType) {
+ case Ci.nsIBrowserProfileMigrator.COOKIES:
+ type = "cookies";
+ break;
+ case Ci.nsIBrowserProfileMigrator.HISTORY:
+ type = "history";
+ break;
+ case Ci.nsIBrowserProfileMigrator.FORMDATA:
+ type = "form data";
+ break;
+ case Ci.nsIBrowserProfileMigrator.PASSWORDS:
+ type = "passwords";
+ break;
+ case Ci.nsIBrowserProfileMigrator.BOOKMARKS:
+ type = "bookmarks";
+ break;
+ case Ci.nsIBrowserProfileMigrator.OTHERDATA:
+ type = "misc. data";
+ break;
+ }
+ Services.console.logStringMessage(
+ "some " + type + " did not successfully migrate."
+ );
+ Services.telemetry
+ .getKeyedHistogramById("FX_MIGRATION_ERRORS")
+ .add(this._source, Math.log2(numericType));
+ break;
+ }
+ },
+
+ onDonePageShow() {
+ this._wiz.getButton("cancel").disabled = true;
+ this._wiz.canRewind = false;
+ this._listItems("doneItems");
+ },
+
+ reportDataRecencyTelemetry() {
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "FX_STARTUP_MIGRATION_DATA_RECENCY"
+ );
+ let lastUsedPromises = [];
+ for (let [key, migrator] of this._availableMigrators) {
+ // No block-scoped let in for...of loop conditions, so get the source:
+ let localKey = key;
+ lastUsedPromises.push(
+ migrator.getLastUsedDate().then(date => {
+ const ONE_YEAR = 24 * 365;
+ let diffInHours = Math.round((Date.now() - date) / (60 * 60 * 1000));
+ if (diffInHours > ONE_YEAR) {
+ diffInHours = ONE_YEAR;
+ }
+ histogram.add(localKey, diffInHours);
+ return [localKey, diffInHours];
+ })
+ );
+ }
+ Promise.all(lastUsedPromises).then(migratorUsedTimeDiff => {
+ // Sort low to high.
+ migratorUsedTimeDiff.sort(
+ ([keyA, diffA], [keyB, diffB]) => diffA - diffB
+ ); /* eslint no-unused-vars: off */
+ let usedMostRecentBrowser =
+ migratorUsedTimeDiff.length &&
+ this._source == migratorUsedTimeDiff[0][0];
+ let usedRecentBrowser = Services.telemetry.getKeyedHistogramById(
+ "FX_STARTUP_MIGRATION_USED_RECENT_BROWSER"
+ );
+ usedRecentBrowser.add(this._source, usedMostRecentBrowser);
+ });
+ },
+};
diff --git a/browser/components/migration/content/migration.xhtml b/browser/components/migration/content/migration.xhtml
new file mode 100644
index 0000000000..ff2b269c5e
--- /dev/null
+++ b/browser/components/migration/content/migration.xhtml
@@ -0,0 +1,104 @@
+<?xml version="1.0"?>
+# 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/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<window id="migrationWizard"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ data-l10n-id="migration-wizard"
+ windowtype="Browser:MigrationWizard"
+ onload="MigrationWizard.init()"
+ onunload="MigrationWizard.uninit()"
+ style="width: 40em;"
+ buttons="accept,cancel">
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="toolkit/global/wizard.ftl"/>
+ <html:link rel="localization" href="browser/migration.ftl"/>
+</linkset>
+
+<script src="chrome://global/content/customElements.js"/>
+<script src="chrome://browser/content/migration/migration.js"/>
+
+<wizard data-branded="true">
+ <wizardpage id="importSource" pageid="importSource" next="selectProfile"
+ data-header-label-id="import-source-page-title">
+ <description id="importAll" control="importSourceGroup" data-l10n-id="import-from"></description>
+ <description id="importBookmarks" control="importSourceGroup" data-l10n-id="import-from-bookmarks" hidden="true" ></description>
+
+ <radiogroup id="importSourceGroup" align="start">
+# NB: if you add items to this list, please also assign them a unique migrator ID in MigrationUtils.jsm
+ <radio id="firefox" data-l10n-id="import-from-firefox"/>
+#ifdef XP_WIN
+ <radio id="chromium-edge" data-l10n-id="import-from-edge"/>
+ <radio id="edge" data-l10n-id="import-from-edge-legacy" />
+ <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/>
+ <radio id="ie" data-l10n-id="import-from-ie"/>
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+ <radio id="canary" data-l10n-id="import-from-canary" />
+ <radio id="360se" data-l10n-id="import-from-360se"/>
+#elifdef XP_MACOSX
+ <radio id="safari" data-l10n-id="import-from-safari"/>
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chromium-edge" data-l10n-id="import-from-edge"/>
+ <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+ <radio id="canary" data-l10n-id="import-from-canary"/>
+#elifdef XP_UNIX
+ <radio id="chrome" data-l10n-id="import-from-chrome"/>
+ <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/>
+ <radio id="chrome-dev" data-l10n-id="import-from-chrome-dev"/>
+ <radio id="chromium" data-l10n-id="import-from-chromium"/>
+#endif
+ <radio id="nothing" data-l10n-id="import-from-nothing" hidden="true"/>
+ </radiogroup>
+ <label id="noSources" hidden="true" data-l10n-id="no-migration-sources"></label>
+ <spacer flex="1"/>
+ <description class="header" id="closeSourceBrowser" data-l10n-id="import-close-source-browser" style="visibility:hidden"></description>
+ </wizardpage>
+
+ <wizardpage id="selectProfile" pageid="selectProfile"
+ data-header-label-id="import-select-profile-page-title"
+ next="importItems">
+ <description control="profiles" data-l10n-id="import-select-profile-description"></description>
+
+ <radiogroup id="profiles" align="start"/>
+ </wizardpage>
+
+ <wizardpage id="importItems" pageid="importItems"
+ data-header-label-id="import-items-page-title"
+ next="migrating"
+ oncommand="MigrationWizard.onImportItemCommand();">
+ <description control="dataSources" data-l10n-id="import-items-description"></description>
+
+ <vbox id="dataSources" style="overflow: auto; appearance: auto; -moz-default-appearance: listbox" align="start" flex="1" role="group"/>
+ </wizardpage>
+
+ <wizardpage id="importPermissions" pageid="importPermissions"
+ data-header-label-id="import-permissions-page-title"
+ next="migrating">
+ <description data-l10n-id="import-permissions-description"></description>
+ </wizardpage>
+
+ <wizardpage id="migrating" pageid="migrating"
+ data-header-label-id="import-migrating-page-title"
+ next="done">
+ <description control="migratingItems" data-l10n-id="import-migrating-description"></description>
+
+ <vbox id="migratingItems" style="overflow: auto;" align="start" role="group"/>
+ </wizardpage>
+
+ <wizardpage id="done" pageid="done"
+ data-header-label-id="import-done-page-title">
+ <description control="doneItems" data-l10n-id="import-done-description"></description>
+
+ <vbox id="doneItems" style="overflow: auto;" align="start" role="group"/>
+ </wizardpage>
+
+</wizard>
+</window>
diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn
new file mode 100644
index 0000000000..9689f5e3b1
--- /dev/null
+++ b/browser/components/migration/jar.mn
@@ -0,0 +1,8 @@
+# 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/.
+
+browser.jar:
+* content/browser/migration/migration.xhtml (content/migration.xhtml)
+ content/browser/migration/migration.js (content/migration.js)
+ content/browser/aboutWelcomeBack.xhtml (content/aboutWelcomeBack.xhtml)
diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build
new file mode 100644
index 0000000000..778c7f938b
--- /dev/null
+++ b/browser/components/migration/moz.build
@@ -0,0 +1,67 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"]
+
+MARIONETTE_UNIT_MANIFESTS += ["tests/marionette/manifest.ini"]
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPIDL_SOURCES += [
+ "nsIBrowserProfileMigrator.idl",
+]
+
+XPIDL_MODULE = "migration"
+
+EXTRA_JS_MODULES += [
+ "ChromeMigrationUtils.jsm",
+ "ChromeProfileMigrator.jsm",
+ "FirefoxProfileMigrator.jsm",
+ "MigrationUtils.jsm",
+ "ProfileMigrator.jsm",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ if CONFIG["ENABLE_TESTS"]:
+ DIRS += [
+ "tests/unit/insertIEHistory",
+ ]
+ SOURCES += [
+ "nsIEHistoryEnumerator.cpp",
+ ]
+ EXTRA_JS_MODULES += [
+ "360seProfileMigrator.jsm",
+ "ChromeWindowsLoginCrypto.jsm",
+ "EdgeProfileMigrator.jsm",
+ "ESEDBReader.jsm",
+ "IEProfileMigrator.jsm",
+ "MSMigrationUtils.jsm",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ EXPORTS += [
+ "nsKeychainMigrationUtils.h",
+ ]
+ EXTRA_JS_MODULES += [
+ "ChromeMacOSLoginCrypto.jsm",
+ "SafariProfileMigrator.jsm",
+ ]
+ SOURCES += [
+ "nsKeychainMigrationUtils.mm",
+ ]
+ XPIDL_SOURCES += [
+ "nsIKeychainMigrationUtils.idl",
+ ]
+
+
+XPCOM_MANIFESTS += [
+ "components.conf",
+]
+
+FINAL_LIBRARY = "browsercomps"
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "Migration")
diff --git a/browser/components/migration/nsIBrowserProfileMigrator.idl b/browser/components/migration/nsIBrowserProfileMigrator.idl
new file mode 100644
index 0000000000..630d3c2b9e
--- /dev/null
+++ b/browser/components/migration/nsIBrowserProfileMigrator.idl
@@ -0,0 +1,73 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+
+interface nsIArray;
+interface nsIProfileStartup;
+
+[scriptable, uuid(22b56ffc-3149-43c5-b5a9-b3a6b678de93)]
+interface nsIBrowserProfileMigrator : nsISupports
+{
+ /**
+ * profile items to migrate. use with migrate().
+ */
+ const unsigned short ALL = 0x0000;
+ /* 0x01 used to be used for settings, but was removed. */
+ const unsigned short COOKIES = 0x0002;
+ const unsigned short HISTORY = 0x0004;
+ const unsigned short FORMDATA = 0x0008;
+ const unsigned short PASSWORDS = 0x0010;
+ const unsigned short BOOKMARKS = 0x0020;
+ const unsigned short OTHERDATA = 0x0040;
+ const unsigned short SESSION = 0x0080;
+
+ /**
+ * Copy user profile information to the current active profile.
+ * @param aItems list of data items to migrate. see above for values.
+ * @param aStartup helper interface which is non-null if called during startup.
+ * @param aProfile profile to migrate from, if there is more than one.
+ */
+ void migrate(in unsigned short aItems, in nsIProfileStartup aStartup, in jsval aProfile);
+
+ /**
+ * A bit field containing profile items that this migrator
+ * offers for import.
+ * @param aProfile the profile that we are looking for available data
+ * to import
+ * @return Promise containing a bit field containing profile items (see above)
+ * @note a return value of 0 represents no items rather than ALL.
+ */
+ jsval getMigrateData(in jsval aProfile);
+
+ /**
+ * Get the last time data from this browser was modified
+ * @return a promise that resolves to a JS Date object
+ */
+ jsval getLastUsedDate();
+
+ /**
+ * Get whether or not there is any data that can be imported from this
+ * browser (i.e. whether or not it is installed, and there exists
+ * a user profile)
+ * @return a promise that resolves with a boolean.
+ */
+ jsval isSourceAvailable();
+
+
+ /**
+ * An enumeration of available profiles. If the import source does
+ * not support profiles, this attribute is null.
+ * @return a promise that resolves with an array of profiles or null.
+ */
+ jsval getSourceProfiles();
+
+
+ /**
+ * Whether the source browser data is locked/in-use meaning migration likely
+ * won't succeed and the user should be warned.
+ */
+ readonly attribute boolean sourceLocked;
+};
diff --git a/browser/components/migration/nsIEHistoryEnumerator.cpp b/browser/components/migration/nsIEHistoryEnumerator.cpp
new file mode 100644
index 0000000000..497b92bab7
--- /dev/null
+++ b/browser/components/migration/nsIEHistoryEnumerator.cpp
@@ -0,0 +1,116 @@
+/* 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/. */
+
+#include "nsIEHistoryEnumerator.h"
+
+#include <urlhist.h>
+#include <shlguid.h>
+
+#include "nsArrayEnumerator.h"
+#include "nsComponentManagerUtils.h"
+#include "nsCOMArray.h"
+#include "nsIURI.h"
+#include "nsNetUtil.h"
+#include "nsString.h"
+#include "nsWindowsMigrationUtils.h"
+#include "prtime.h"
+
+////////////////////////////////////////////////////////////////////////////////
+//// nsIEHistoryEnumerator
+
+nsIEHistoryEnumerator::nsIEHistoryEnumerator() { ::CoInitialize(nullptr); }
+
+nsIEHistoryEnumerator::~nsIEHistoryEnumerator() { ::CoUninitialize(); }
+
+void nsIEHistoryEnumerator::EnsureInitialized() {
+ if (mURLEnumerator) return;
+
+ HRESULT hr =
+ ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER,
+ IID_IUrlHistoryStg2, getter_AddRefs(mIEHistory));
+ if (FAILED(hr)) return;
+
+ hr = mIEHistory->EnumUrls(getter_AddRefs(mURLEnumerator));
+ if (FAILED(hr)) return;
+}
+
+NS_IMETHODIMP
+nsIEHistoryEnumerator::HasMoreElements(bool* _retval) {
+ *_retval = false;
+
+ EnsureInitialized();
+ MOZ_ASSERT(mURLEnumerator,
+ "Should have instanced an IE History URLEnumerator");
+ if (!mURLEnumerator) return NS_OK;
+
+ STATURL statURL;
+ ULONG fetched;
+
+ // First argument is not implemented, so doesn't matter what we pass.
+ HRESULT hr = mURLEnumerator->Next(1, &statURL, &fetched);
+ if (FAILED(hr) || fetched != 1UL) {
+ // Reached the last entry.
+ return NS_OK;
+ }
+
+ nsCOMPtr<nsIURI> uri;
+ if (statURL.pwcsUrl) {
+ nsDependentString url(statURL.pwcsUrl);
+ nsresult rv = NS_NewURI(getter_AddRefs(uri), url);
+ ::CoTaskMemFree(statURL.pwcsUrl);
+ if (NS_FAILED(rv)) {
+ // Got a corrupt or invalid URI, continue to the next entry.
+ return HasMoreElements(_retval);
+ }
+ }
+
+ nsDependentString title(statURL.pwcsTitle ? statURL.pwcsTitle : L"");
+
+ bool lastVisitTimeIsValid;
+ PRTime lastVisited = WinMigrationFileTimeToPRTime(&(statURL.ftLastVisited),
+ &lastVisitTimeIsValid);
+
+ mCachedNextEntry = do_CreateInstance("@mozilla.org/hash-property-bag;1");
+ MOZ_ASSERT(mCachedNextEntry, "Should have instanced a new property bag");
+ if (mCachedNextEntry) {
+ mCachedNextEntry->SetPropertyAsInterface(u"uri"_ns, uri);
+ mCachedNextEntry->SetPropertyAsAString(u"title"_ns, title);
+ if (lastVisitTimeIsValid) {
+ mCachedNextEntry->SetPropertyAsInt64(u"time"_ns, lastVisited);
+ }
+
+ *_retval = true;
+ }
+
+ if (statURL.pwcsTitle) ::CoTaskMemFree(statURL.pwcsTitle);
+
+ return NS_OK;
+}
+
+NS_IMETHODIMP
+nsIEHistoryEnumerator::GetNext(nsISupports** _retval) {
+ *_retval = nullptr;
+
+ EnsureInitialized();
+ MOZ_ASSERT(mURLEnumerator,
+ "Should have instanced an IE History URLEnumerator");
+ if (!mURLEnumerator) return NS_OK;
+
+ if (!mCachedNextEntry) {
+ bool hasMore = false;
+ nsresult rv = this->HasMoreElements(&hasMore);
+ if (NS_FAILED(rv)) {
+ return rv;
+ }
+ if (!hasMore) {
+ return NS_ERROR_FAILURE;
+ }
+ }
+
+ NS_ADDREF(*_retval = mCachedNextEntry);
+ // Release the cached entry, so it can't be returned twice.
+ mCachedNextEntry = nullptr;
+
+ return NS_OK;
+}
diff --git a/browser/components/migration/nsIEHistoryEnumerator.h b/browser/components/migration/nsIEHistoryEnumerator.h
new file mode 100644
index 0000000000..cd0c202bfc
--- /dev/null
+++ b/browser/components/migration/nsIEHistoryEnumerator.h
@@ -0,0 +1,39 @@
+/* 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/. */
+
+#ifndef iehistoryenumerator___h___
+#define iehistoryenumerator___h___
+
+#include <urlhist.h>
+
+#include "mozilla/Attributes.h"
+#include "nsCOMPtr.h"
+#include "nsIWritablePropertyBag2.h"
+#include "nsSimpleEnumerator.h"
+
+class nsIEHistoryEnumerator final : public nsSimpleEnumerator {
+ public:
+ NS_DECL_NSISIMPLEENUMERATOR
+
+ nsIEHistoryEnumerator();
+
+ const nsID& DefaultInterface() override {
+ return NS_GET_IID(nsIWritablePropertyBag2);
+ }
+
+ private:
+ ~nsIEHistoryEnumerator() override;
+
+ /**
+ * Initializes the history reader, if needed.
+ */
+ void EnsureInitialized();
+
+ RefPtr<IUrlHistoryStg2> mIEHistory;
+ RefPtr<IEnumSTATURL> mURLEnumerator;
+
+ nsCOMPtr<nsIWritablePropertyBag2> mCachedNextEntry;
+};
+
+#endif
diff --git a/browser/components/migration/nsIKeychainMigrationUtils.idl b/browser/components/migration/nsIKeychainMigrationUtils.idl
new file mode 100644
index 0000000000..e0a9db4ddf
--- /dev/null
+++ b/browser/components/migration/nsIKeychainMigrationUtils.idl
@@ -0,0 +1,12 @@
+/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* 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/. */
+
+#include "nsISupports.idl"
+
+[scriptable, uuid(647bf80c-cd35-4ce6-b904-fd586b97ae48)]
+interface nsIKeychainMigrationUtils : nsISupports
+{
+ ACString getGenericPassword(in ACString aServiceName, in ACString aAccountName);
+};
diff --git a/browser/components/migration/nsKeychainMigrationUtils.h b/browser/components/migration/nsKeychainMigrationUtils.h
new file mode 100644
index 0000000000..343c24086e
--- /dev/null
+++ b/browser/components/migration/nsKeychainMigrationUtils.h
@@ -0,0 +1,23 @@
+/* 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/. */
+
+#ifndef nsKeychainMigrationUtils_h__
+#define nsKeychainMigrationUtils_h__
+
+#include <CoreFoundation/CoreFoundation.h>
+
+#include "nsIKeychainMigrationUtils.h"
+
+class nsKeychainMigrationUtils : public nsIKeychainMigrationUtils {
+ public:
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIKEYCHAINMIGRATIONUTILS
+
+ nsKeychainMigrationUtils(){};
+
+ protected:
+ virtual ~nsKeychainMigrationUtils(){};
+};
+
+#endif
diff --git a/browser/components/migration/nsKeychainMigrationUtils.mm b/browser/components/migration/nsKeychainMigrationUtils.mm
new file mode 100644
index 0000000000..3b0f662914
--- /dev/null
+++ b/browser/components/migration/nsKeychainMigrationUtils.mm
@@ -0,0 +1,62 @@
+/* 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/. */
+
+#include "nsKeychainMigrationUtils.h"
+
+#include <Security/Security.h>
+
+#include "mozilla/Logging.h"
+
+#include "nsCocoaUtils.h"
+#include "nsString.h"
+
+using namespace mozilla;
+
+LazyLogModule gKeychainUtilsLog("keychainmigrationutils");
+
+NS_IMPL_ISUPPORTS(nsKeychainMigrationUtils, nsIKeychainMigrationUtils)
+
+NS_IMETHODIMP
+nsKeychainMigrationUtils::GetGenericPassword(const nsACString& aServiceName,
+ const nsACString& aAccountName, nsACString& aKey) {
+ // To retrieve a secret, we create a CFDictionary of the form:
+ // { class: generic password,
+ // service: the given service name
+ // account: the given account name,
+ // match limit: match one,
+ // return attributes: true,
+ // return data: true }
+ // This searches for and returns the attributes and data for the secret
+ // matching the given service and account names. We then extract the data
+ // (i.e. the secret) and return it.
+ NSDictionary* searchDictionary = @{
+ (__bridge NSString*)kSecClass : (__bridge NSString*)kSecClassGenericPassword,
+ (__bridge NSString*)kSecAttrService : nsCocoaUtils::ToNSString(aServiceName),
+ (__bridge NSString*)kSecAttrAccount : nsCocoaUtils::ToNSString(aAccountName),
+ (__bridge NSString*)kSecMatchLimit : (__bridge NSString*)kSecMatchLimitOne,
+ (__bridge NSString*)kSecReturnAttributes : @YES,
+ (__bridge NSString*)kSecReturnData : @YES
+ };
+
+ CFTypeRef item;
+ // https://developer.apple.com/documentation/security/1398306-secitemcopymatching
+ OSStatus rv = SecItemCopyMatching((__bridge CFDictionaryRef)searchDictionary, &item);
+ if (rv != errSecSuccess) {
+ MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("SecItemCopyMatching failed: %d", rv));
+ return NS_ERROR_FAILURE;
+ }
+ NSDictionary* resultDict = [(__bridge NSDictionary*)item autorelease];
+ NSData* secret = [resultDict objectForKey:(__bridge NSString*)kSecValueData];
+ if (!secret) {
+ MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("objectForKey failed"));
+ return NS_ERROR_FAILURE;
+ }
+ if ([secret length] != 0) {
+ // We assume that the data is UTF-8 encoded since that seems to be common and
+ // Keychain Access shows it with that encoding.
+ aKey.Assign(reinterpret_cast<const char*>([secret bytes]), [secret length]);
+ }
+
+ return NS_OK;
+}
diff --git a/browser/components/migration/nsWindowsMigrationUtils.h b/browser/components/migration/nsWindowsMigrationUtils.h
new file mode 100644
index 0000000000..4541759485
--- /dev/null
+++ b/browser/components/migration/nsWindowsMigrationUtils.h
@@ -0,0 +1,33 @@
+/* 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/. */
+
+#ifndef windowsmigrationutils__h__
+#define windowsmigrationutils__h__
+
+#include "prtime.h"
+
+static PRTime WinMigrationFileTimeToPRTime(FILETIME* filetime, bool* isValid) {
+ SYSTEMTIME st;
+ *isValid = ::FileTimeToSystemTime(filetime, &st);
+ if (!*isValid) {
+ return 0;
+ }
+ PRExplodedTime prt;
+ prt.tm_year = st.wYear;
+ // SYSTEMTIME's day-of-month parameter is 1-based,
+ // PRExplodedTime's is 0-based.
+ prt.tm_month = st.wMonth - 1;
+ prt.tm_mday = st.wDay;
+ prt.tm_hour = st.wHour;
+ prt.tm_min = st.wMinute;
+ prt.tm_sec = st.wSecond;
+ prt.tm_usec = st.wMilliseconds * 1000;
+ prt.tm_wday = 0;
+ prt.tm_yday = 0;
+ prt.tm_params.tp_gmt_offset = 0;
+ prt.tm_params.tp_dst_offset = 0;
+ return PR_ImplodeTime(&prt);
+}
+
+#endif
diff --git a/browser/components/migration/tests/marionette/manifest.ini b/browser/components/migration/tests/marionette/manifest.ini
new file mode 100644
index 0000000000..afba7ead73
--- /dev/null
+++ b/browser/components/migration/tests/marionette/manifest.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+run-if = buildapp == 'browser'
+
+[test_refresh_firefox.py]
diff --git a/browser/components/migration/tests/marionette/test_refresh_firefox.py b/browser/components/migration/tests/marionette/test_refresh_firefox.py
new file mode 100644
index 0000000000..4de3de5ab7
--- /dev/null
+++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py
@@ -0,0 +1,698 @@
+from __future__ import absolute_import, print_function
+import os
+import time
+
+from marionette_harness import MarionetteTestCase
+from marionette_driver.errors import NoAlertPresentException
+
+
+# Holds info about things we need to cleanup after the tests are done.
+class PendingCleanup:
+ desktop_backup_path = None
+ reset_profile_path = None
+ reset_profile_local_path = None
+
+ def __init__(self, profile_name_to_remove):
+ self.profile_name_to_remove = profile_name_to_remove
+
+
+class TestFirefoxRefresh(MarionetteTestCase):
+ _sandbox = "firefox-refresh"
+
+ _username = "marionette-test-login"
+ _password = "marionette-test-password"
+ _bookmarkURL = "about:mozilla"
+ _bookmarkText = "Some bookmark from Marionette"
+
+ _cookieHost = "firefox-refresh.marionette-test.mozilla.org"
+ _cookiePath = "some/cookie/path"
+ _cookieName = "somecookie"
+ _cookieValue = "some cookie value"
+
+ _historyURL = "http://firefox-refresh.marionette-test.mozilla.org/"
+ _historyTitle = "Test visit for Firefox Reset"
+
+ _formHistoryFieldName = "some-very-unique-marionette-only-firefox-reset-field"
+ _formHistoryValue = "special-pumpkin-value"
+
+ _formAutofillAvailable = False
+ _formAutofillAddressGuid = None
+
+ _expectedURLs = ["about:robots", "about:mozilla"]
+
+ def savePassword(self):
+ self.runCode(
+ """
+ let myLogin = new global.LoginInfo(
+ "test.marionette.mozilla.com",
+ "http://test.marionette.mozilla.com/some/form/",
+ null,
+ arguments[0],
+ arguments[1],
+ "username",
+ "password"
+ );
+ Services.logins.addLogin(myLogin)
+ """,
+ script_args=(self._username, self._password),
+ )
+
+ def createBookmarkInMenu(self):
+ error = self.runAsyncCode(
+ """
+ // let url = arguments[0];
+ // let title = arguments[1];
+ // let resolve = arguments[arguments.length - 1];
+ let [url, title, resolve] = arguments;
+ PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.menuGuid, url, title
+ }).then(() => resolve(false), resolve);
+ """,
+ script_args=(self._bookmarkURL, self._bookmarkText),
+ )
+ if error:
+ print(error)
+
+ def createBookmarksOnToolbar(self):
+ error = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ let children = [];
+ for (let i = 1; i <= 5; i++) {
+ children.push({url: `about:rights?p=${i}`, title: `Bookmark ${i}`});
+ }
+ PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children
+ }).then(() => resolve(false), resolve);
+ """
+ )
+ if error:
+ print(error)
+
+ def createHistory(self):
+ error = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ PlacesUtils.history.insert({
+ url: arguments[0],
+ title: arguments[1],
+ visits: [{
+ date: new Date(Date.now() - 5000),
+ referrer: "about:mozilla"
+ }]
+ }).then(() => resolve(false),
+ ex => resolve("Unexpected error in adding visit: " + ex));
+ """,
+ script_args=(self._historyURL, self._historyTitle),
+ )
+ if error:
+ print(error)
+
+ def createFormHistory(self):
+ error = self.runAsyncCode(
+ """
+ let updateDefinition = {
+ op: "add",
+ fieldname: arguments[0],
+ value: arguments[1],
+ firstUsed: (Date.now() - 5000) * 1000,
+ };
+ let finished = false;
+ let resolve = arguments[arguments.length - 1];
+ global.FormHistory.update(updateDefinition, {
+ handleError(error) {
+ finished = true;
+ resolve(error);
+ },
+ handleCompletion() {
+ if (!finished) {
+ resolve(false);
+ }
+ }
+ });
+ """,
+ script_args=(self._formHistoryFieldName, self._formHistoryValue),
+ )
+ if error:
+ print(error)
+
+ def createFormAutofill(self):
+ if not self._formAutofillAvailable:
+ return
+ self._formAutofillAddressGuid = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ const TEST_ADDRESS_1 = {
+ "given-name": "John",
+ "additional-name": "R.",
+ "family-name": "Smith",
+ organization: "World Wide Web Consortium",
+ "street-address": "32 Vassar Street\\\nMIT Room 32-G524",
+ "address-level2": "Cambridge",
+ "address-level1": "MA",
+ "postal-code": "02139",
+ country: "US",
+ tel: "+15195555555",
+ email: "user@example.com",
+ };
+ return global.formAutofillStorage.initialize().then(() => {
+ return global.formAutofillStorage.addresses.add(TEST_ADDRESS_1);
+ }).then(resolve);
+ """
+ )
+
+ def createCookie(self):
+ self.runCode(
+ """
+ // Expire in 15 minutes:
+ let expireTime = Math.floor(Date.now() / 1000) + 15 * 60;
+ Services.cookies.add(arguments[0], arguments[1], arguments[2], arguments[3],
+ true, false, false, expireTime, {},
+ Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_UNSET);
+ """,
+ script_args=(
+ self._cookieHost,
+ self._cookiePath,
+ self._cookieName,
+ self._cookieValue,
+ ),
+ )
+
+ def createSession(self):
+ self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ const COMPLETE_STATE = Ci.nsIWebProgressListener.STATE_STOP +
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK;
+ let {TabStateFlusher} = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {});
+ let expectedURLs = Array.from(arguments[0])
+ gBrowser.addTabsProgressListener({
+ onStateChange(browser, webprogress, request, flags, status) {
+ try {
+ request && request.QueryInterface(Ci.nsIChannel);
+ } catch (ex) {}
+ let uriLoaded = request.originalURI && request.originalURI.spec;
+ if ((flags & COMPLETE_STATE == COMPLETE_STATE) && uriLoaded &&
+ expectedURLs.includes(uriLoaded)) {
+ TabStateFlusher.flush(browser).then(function() {
+ expectedURLs.splice(expectedURLs.indexOf(uriLoaded), 1);
+ if (!expectedURLs.length) {
+ gBrowser.removeTabsProgressListener(this);
+ resolve();
+ }
+ });
+ }
+ }
+ });
+ let expectedTabs = new Set();
+ for (let url of expectedURLs) {
+ expectedTabs.add(gBrowser.addTab(url, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ }));
+ }
+ // Close any other tabs that might be open:
+ let allTabs = Array.from(gBrowser.tabs);
+ for (let tab of allTabs) {
+ if (!expectedTabs.has(tab)) {
+ gBrowser.removeTab(tab);
+ }
+ }
+ """, # NOQA: E501
+ script_args=(self._expectedURLs,),
+ )
+
+ def createFxa(self):
+ # This script will write an entry to the login manager and create
+ # a signedInUser.json in the profile dir.
+ self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
+ let storage = new FxAccountsStorageManager();
+ let data = {email: "test@test.com", uid: "uid", keyFetchToken: "top-secret"};
+ storage.initialize(data);
+ storage.finalize().then(resolve);
+ """
+ )
+
+ def createSync(self):
+ # This script will write the canonical preference which indicates a user
+ # is signed into sync.
+ self.marionette.execute_script(
+ """
+ Services.prefs.setStringPref("services.sync.username", "test@test.com");
+ """
+ )
+
+ def checkPassword(self):
+ loginInfo = self.marionette.execute_script(
+ """
+ let ary = Services.logins.findLogins(
+ "test.marionette.mozilla.com",
+ "http://test.marionette.mozilla.com/some/form/",
+ null, {});
+ return ary.length ? ary : {username: "null", password: "null"};
+ """
+ )
+ self.assertEqual(len(loginInfo), 1)
+ self.assertEqual(loginInfo[0]["username"], self._username)
+ self.assertEqual(loginInfo[0]["password"], self._password)
+
+ loginCount = self.marionette.execute_script(
+ """
+ return Services.logins.getAllLogins().length;
+ """
+ )
+ # Note that we expect 2 logins - one from us, one from sync.
+ self.assertEqual(loginCount, 2, "No other logins are present")
+
+ def checkBookmarkInMenu(self):
+ titleInBookmarks = self.runAsyncCode(
+ """
+ let [url, resolve] = arguments;
+ PlacesUtils.bookmarks.fetch({url}).then(
+ bookmark => resolve(bookmark ? bookmark.title : ""),
+ ex => resolve(ex)
+ );
+ """,
+ script_args=(self._bookmarkURL,),
+ )
+ self.assertEqual(titleInBookmarks, self._bookmarkText)
+
+ def checkBookmarkToolbarVisibility(self):
+ toolbarVisible = self.marionette.execute_script(
+ """
+ const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL;
+ return Services.xulStore.getValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed");
+ """
+ )
+ if toolbarVisible == "":
+ toolbarVisible = "false"
+ self.assertEqual(toolbarVisible, "false")
+
+ def checkHistory(self):
+ historyResult = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ PlacesUtils.history.fetch(arguments[0]).then(pageInfo => {
+ if (!pageInfo) {
+ resolve("No visits found");
+ } else {
+ resolve(pageInfo);
+ }
+ }).catch(e => {
+ resolve("Unexpected error in fetching page: " + e);
+ });
+ """,
+ script_args=(self._historyURL,),
+ )
+ if type(historyResult) == str:
+ self.fail(historyResult)
+ return
+
+ self.assertEqual(historyResult["title"], self._historyTitle)
+
+ def checkFormHistory(self):
+ formFieldResults = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ let results = [];
+ global.FormHistory.search(["value"], {fieldname: arguments[0]}, {
+ handleError(error) {
+ results = error;
+ },
+ handleResult(result) {
+ results.push(result);
+ },
+ handleCompletion() {
+ resolve(results);
+ },
+ });
+ """,
+ script_args=(self._formHistoryFieldName,),
+ )
+ if type(formFieldResults) == str:
+ self.fail(formFieldResults)
+ return
+
+ formFieldResultCount = len(formFieldResults)
+ self.assertEqual(
+ formFieldResultCount,
+ 1,
+ "Should have exactly 1 entry for this field, got %d" % formFieldResultCount,
+ )
+ if formFieldResultCount == 1:
+ self.assertEqual(formFieldResults[0]["value"], self._formHistoryValue)
+
+ formHistoryCount = self.runAsyncCode(
+ """
+ let [resolve] = arguments;
+ let count;
+ let callbacks = {
+ handleResult: rv => count = rv,
+ handleCompletion() {
+ resolve(count);
+ },
+ };
+ global.FormHistory.count({}, callbacks);
+ """
+ )
+ self.assertEqual(
+ formHistoryCount, 1, "There should be only 1 entry in the form history"
+ )
+
+ def checkFormAutofill(self):
+ if not self._formAutofillAvailable:
+ return
+
+ formAutofillResults = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1];
+ return global.formAutofillStorage.initialize().then(() => {
+ return global.formAutofillStorage.addresses.getAll()
+ }).then(resolve);
+ """,
+ )
+ if type(formAutofillResults) == str:
+ self.fail(formAutofillResults)
+ return
+
+ formAutofillAddressCount = len(formAutofillResults)
+ self.assertEqual(
+ formAutofillAddressCount,
+ 1,
+ "Should have exactly 1 saved address, got %d" % formAutofillAddressCount,
+ )
+ if formAutofillAddressCount == 1:
+ self.assertEqual(
+ formAutofillResults[0]["guid"], self._formAutofillAddressGuid
+ )
+
+ def checkCookie(self):
+ cookieInfo = self.runCode(
+ """
+ try {
+ let cookies = Services.cookies.getCookiesFromHost(arguments[0], {});
+ let cookie = null;
+ for (let hostCookie of cookies) {
+ // getCookiesFromHost returns any cookie from the BASE host.
+ if (hostCookie.rawHost != arguments[0])
+ continue;
+ if (cookie != null) {
+ return "more than 1 cookie! That shouldn't happen!";
+ }
+ cookie = hostCookie;
+ }
+ return {path: cookie.path, name: cookie.name, value: cookie.value};
+ } catch (ex) {
+ return "got exception trying to fetch cookie: " + ex;
+ }
+ """,
+ script_args=(self._cookieHost,),
+ )
+ if not isinstance(cookieInfo, dict):
+ self.fail(cookieInfo)
+ return
+ self.assertEqual(cookieInfo["path"], self._cookiePath)
+ self.assertEqual(cookieInfo["value"], self._cookieValue)
+ self.assertEqual(cookieInfo["name"], self._cookieName)
+
+ def checkSession(self):
+ tabURIs = self.runCode(
+ """
+ return [... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec)
+ """
+ )
+ self.assertSequenceEqual(tabURIs, ["about:welcomeback"])
+
+ # Dismiss modal dialog if any. This is mainly to dismiss the check for
+ # default browser dialog if it shows up.
+ try:
+ alert = self.marionette.switch_to_alert()
+ alert.dismiss()
+ except NoAlertPresentException:
+ pass
+
+ tabURIs = self.runAsyncCode(
+ """
+ let resolve = arguments[arguments.length - 1]
+ let mm = gBrowser.selectedBrowser.messageManager;
+
+ let {TabStateFlusher} = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {});
+ window.addEventListener("SSWindowStateReady", function testSSPostReset() {
+ window.removeEventListener("SSWindowStateReady", testSSPostReset, false);
+ Promise.all(gBrowser.browsers.map(b => TabStateFlusher.flush(b))).then(function() {
+ resolve([... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec));
+ });
+ }, false);
+
+ let fs = function() {
+ if (content.document.readyState === "complete") {
+ content.document.getElementById("errorTryAgain").click();
+ } else {
+ content.window.addEventListener("load", function(event) {
+ content.document.getElementById("errorTryAgain").click();
+ }, { once: true });
+ }
+ };
+
+ mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true);
+ """ # NOQA: E501
+ )
+ self.assertSequenceEqual(tabURIs, self._expectedURLs)
+
+ def checkFxA(self):
+ result = self.runAsyncCode(
+ """
+ Cu.import("resource://gre/modules/FxAccountsStorage.jsm");
+ let resolve = arguments[arguments.length - 1];
+ let storage = new FxAccountsStorageManager();
+ let result = {};
+ storage.initialize();
+ storage.getAccountData().then(data => {
+ result.accountData = data;
+ return storage.finalize();
+ }).then(() => {
+ resolve(result);
+ }).catch(err => {
+ resolve(err.toString());
+ });
+ """
+ )
+ if type(result) != dict:
+ self.fail(result)
+ return
+ self.assertEqual(result["accountData"]["email"], "test@test.com")
+ self.assertEqual(result["accountData"]["uid"], "uid")
+ self.assertEqual(result["accountData"]["keyFetchToken"], "top-secret")
+
+ def checkSync(self, expect_sync_user):
+ pref_value = self.marionette.execute_script(
+ """
+ return Services.prefs.getStringPref("services.sync.username", null);
+ """
+ )
+ expected_value = "test@test.com" if expect_sync_user else None
+ self.assertEqual(pref_value, expected_value)
+
+ def checkProfile(self, has_migrated=False, expect_sync_user=True):
+ self.checkPassword()
+ self.checkBookmarkInMenu()
+ self.checkHistory()
+ self.checkFormHistory()
+ self.checkFormAutofill()
+ self.checkCookie()
+ self.checkFxA()
+ self.checkSync(expect_sync_user)
+ if has_migrated:
+ self.checkBookmarkToolbarVisibility()
+ self.checkSession()
+
+ def createProfileData(self):
+ self.savePassword()
+ self.createBookmarkInMenu()
+ self.createBookmarksOnToolbar()
+ self.createHistory()
+ self.createFormHistory()
+ self.createFormAutofill()
+ self.createCookie()
+ self.createSession()
+ self.createFxa()
+ self.createSync()
+
+ def setUpScriptData(self):
+ self.marionette.set_context(self.marionette.CONTEXT_CHROME)
+ self.runCode(
+ """
+ window.global = {};
+ global.LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", "nsILoginInfo", "init");
+ global.profSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(Ci.nsIToolkitProfileService);
+ global.Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences;
+ global.FormHistory = Cu.import("resource://gre/modules/FormHistory.jsm", {}).FormHistory;
+ """ # NOQA: E501
+ )
+ self._formAutofillAvailable = self.runCode(
+ """
+ try {
+ global.formAutofillStorage = Cu.import("resource://formautofill/FormAutofillStorage.jsm", {}).formAutofillStorage;
+ } catch(e) {
+ return false;
+ }
+ return true;
+ """ # NOQA: E501
+ )
+
+ def runCode(self, script, *args, **kwargs):
+ return self.marionette.execute_script(
+ script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
+ )
+
+ def runAsyncCode(self, script, *args, **kwargs):
+ return self.marionette.execute_async_script(
+ script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
+ )
+
+ def setUp(self):
+ MarionetteTestCase.setUp(self)
+ self.setUpScriptData()
+
+ self.cleanups = []
+
+ def tearDown(self):
+ # Force yet another restart with a clean profile to disconnect from the
+ # profile and environment changes we've made, to leave a more or less
+ # blank slate for the next person.
+ self.marionette.restart(clean=True, in_app=False)
+ self.setUpScriptData()
+
+ # Super
+ MarionetteTestCase.tearDown(self)
+
+ # A helper to deal with removing a load of files
+ import mozfile
+
+ for cleanup in self.cleanups:
+ if cleanup.desktop_backup_path:
+ mozfile.remove(cleanup.desktop_backup_path)
+
+ if cleanup.reset_profile_path:
+ # Remove ourselves from profiles.ini
+ self.runCode(
+ """
+ let name = arguments[0];
+ let profile = global.profSvc.getProfileByName(name);
+ profile.remove(false)
+ global.profSvc.flush();
+ """,
+ script_args=(cleanup.profile_name_to_remove,),
+ )
+ # Remove the local profile dir if it's not the same as the profile dir:
+ different_path = (
+ cleanup.reset_profile_local_path != cleanup.reset_profile_path
+ )
+ if cleanup.reset_profile_local_path and different_path:
+ mozfile.remove(cleanup.reset_profile_local_path)
+
+ # And delete all the files.
+ mozfile.remove(cleanup.reset_profile_path)
+
+ def doReset(self):
+ profileName = "marionette-test-profile-" + str(int(time.time() * 1000))
+ cleanup = PendingCleanup(profileName)
+ self.runCode(
+ """
+ // Ensure the current (temporary) profile is in profiles.ini:
+ let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let profileName = arguments[1];
+ let myProfile = global.profSvc.createProfile(profD, profileName);
+ global.profSvc.flush()
+
+ // Now add the reset parameters:
+ let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment);
+ let prefsToKeep = Array.from(Services.prefs.getChildList("marionette."));
+ prefsToKeep.push("datareporting.policy.dataSubmissionPolicyBypassNotification");
+ let prefObj = {};
+ for (let pref of prefsToKeep) {
+ prefObj[pref] = global.Preferences.get(pref);
+ }
+ env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj));
+ env.set("MOZ_RESET_PROFILE_RESTART", "1");
+ env.set("XRE_PROFILE_PATH", arguments[0]);
+ """,
+ script_args=(
+ self.marionette.instance.profile.profile,
+ profileName,
+ ),
+ )
+
+ profileLeafName = os.path.basename(
+ os.path.normpath(self.marionette.instance.profile.profile)
+ )
+
+ # Now restart the browser to get it reset:
+ self.marionette.restart(clean=False, in_app=True)
+ self.setUpScriptData()
+
+ # Determine the new profile path (we'll need to remove it when we're done)
+ [cleanup.reset_profile_path, cleanup.reset_profile_local_path] = self.runCode(
+ """
+ let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+ let localD = Services.dirsvc.get("ProfLD", Ci.nsIFile);
+ return [profD.path, localD.path];
+ """
+ )
+
+ # Determine the backup path
+ cleanup.desktop_backup_path = self.runCode(
+ """
+ let container;
+ try {
+ container = Services.dirsvc.get("Desk", Ci.nsIFile);
+ } catch (ex) {
+ container = Services.dirsvc.get("Home", Ci.nsIFile);
+ }
+ let bundle = Services.strings.createBundle("chrome://mozapps/locale/profile/profileSelection.properties");
+ let dirName = bundle.formatStringFromName("resetBackupDirectory", [Services.appinfo.name]);
+ container.append(dirName);
+ container.append(arguments[0]);
+ return container.path;
+ """, # NOQA: E501
+ script_args=(profileLeafName,),
+ )
+
+ self.assertTrue(
+ os.path.isdir(cleanup.reset_profile_path),
+ "Reset profile path should be present",
+ )
+ self.assertTrue(
+ os.path.isdir(cleanup.desktop_backup_path),
+ "Backup profile path should be present",
+ )
+ self.assertIn(cleanup.profile_name_to_remove, cleanup.reset_profile_path)
+ return cleanup
+
+ def testResetEverything(self):
+ self.createProfileData()
+
+ self.checkProfile(expect_sync_user=True)
+
+ this_cleanup = self.doReset()
+ self.cleanups.append(this_cleanup)
+
+ # Now check that we're doing OK...
+ self.checkProfile(has_migrated=True, expect_sync_user=True)
+
+ def testFxANoSync(self):
+ # This test doesn't need to repeat all the non-sync tests...
+ # Setup FxA but *not* sync
+ self.createFxa()
+
+ self.checkFxA()
+ self.checkSync(False)
+
+ this_cleanup = self.doReset()
+ self.cleanups.append(this_cleanup)
+
+ self.checkFxA()
+ self.checkSync(False)
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data
new file mode 100644
index 0000000000..7e6e843a03
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data
Binary files differ
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State
new file mode 100644
index 0000000000..3f3fecb651
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State
@@ -0,0 +1,5 @@
+{
+ "os_crypt" : {
+ "encrypted_key" : "RFBBUEk/ThisNPAPIKeyCanOnlyBeDecryptedByTheOriginalDeviceSoThisWillThrowFromDecryptData"
+ }
+}
diff --git a/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data
new file mode 100644
index 0000000000..fd135624c4
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data
Binary files differ
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db
new file mode 100644
index 0000000000..a632fdcbad
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db
@@ -0,0 +1 @@
+Placeholder file to satisfy the resource existence check, not a real SQLite db.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db
new file mode 100644
index 0000000000..1835c33583
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db
Binary files differ
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini
new file mode 100644
index 0000000000..47f6f024e1
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini
@@ -0,0 +1,17 @@
+# This file contains Chinese characters encoded in GBK
+[NowLogin]
+NickName=ıǻ
+email=test@firefox.com.cn
+UserMd5=0f3ab103a522f4463ecacc36d34eb996
+IsLogined=1
+# Will be excluded from sourceProfiles due to missing files
+[20070606]
+NickName=ı
+email=test@mozillaonline.com
+UserMd5=46a579b8b64358fd45616247df4ea604
+# Will be excluded from sourceProfiles as duplication of NowLogin
+[20110303]
+NickName=ıǻ
+email=test@firefox.com.cn
+UserMd5=0f3ab103a522f4463ecacc36d34eb996
+# There's also a default profile (not included here) for anonymous users
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies
new file mode 100644
index 0000000000..83d855cb33
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies
Binary files differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json
new file mode 100644
index 0000000000..44e855edbd
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json
@@ -0,0 +1,9 @@
+{
+ "description": {
+ "description": "Extension description in manifest. Should not exceed 132 characters.",
+ "message": "It is the description of fake app 1."
+ },
+ "name": {
+ "message": "Fake App 1"
+ }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json
new file mode 100644
index 0000000000..1550bf1c0e
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json
@@ -0,0 +1,10 @@
+{
+ "app": {
+ "launch": {
+ "local_path": "main.html"
+ }
+ },
+ "default_locale": "en_US",
+ "description": "__MSG_description__",
+ "name": "__MSG_name__"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json
new file mode 100644
index 0000000000..11657460d8
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json
@@ -0,0 +1,9 @@
+{
+ "description": {
+ "description": "Extension description in manifest. Should not exceed 132 characters.",
+ "message": "It is the description of fake extension 1."
+ },
+ "name": {
+ "message": "Fake Extension 1"
+ }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json
new file mode 100644
index 0000000000..5ceced8031
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json
@@ -0,0 +1,5 @@
+{
+ "default_locale": "en_US",
+ "description": "__MSG_description__",
+ "name": "__MSG_name__"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json
new file mode 100644
index 0000000000..0333a91e56
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json
@@ -0,0 +1,5 @@
+{
+ "default_locale": "en_US",
+ "description": "It is the description of fake extension 2.",
+ "name": "Fake Extension 2"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster
new file mode 100644
index 0000000000..7fb19903b0
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster
Binary files differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data
new file mode 100644
index 0000000000..19b8542b98
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data
Binary files differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State
new file mode 100644
index 0000000000..01b99455e4
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State
@@ -0,0 +1,22 @@
+{
+ "profile" : {
+ "info_cache" : {
+ "Default" : {
+ "active_time" : 1430950755.65137,
+ "is_using_default_name" : true,
+ "is_ephemeral" : false,
+ "is_omitted_from_profile_list" : false,
+ "user_name" : "",
+ "background_apps" : false,
+ "is_using_default_avatar" : true,
+ "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
+ "name" : "Person 1"
+ }
+ },
+ "profiles_created" : 1,
+ "last_used" : "Default",
+ "last_active_profiles" : [
+ "Default"
+ ]
+ }
+}
diff --git a/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist
new file mode 100644
index 0000000000..8046d5e9c9
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist
Binary files differ
diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data
new file mode 100644
index 0000000000..b2d425eb4a
--- /dev/null
+++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data
Binary files differ
diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State
new file mode 100644
index 0000000000..bb03d6b9a1
--- /dev/null
+++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State
@@ -0,0 +1,22 @@
+{
+ "profile" : {
+ "info_cache" : {
+ "Default" : {
+ "active_time" : 1430950755.65137,
+ "is_using_default_name" : true,
+ "is_ephemeral" : false,
+ "is_omitted_from_profile_list" : false,
+ "user_name" : "",
+ "background_apps" : false,
+ "is_using_default_avatar" : true,
+ "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
+ "name" : "Person With No Data"
+ }
+ },
+ "profiles_created" : 1,
+ "last_used" : "Default",
+ "last_active_profiles" : [
+ "Default"
+ ]
+ }
+}
diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js
new file mode 100644
index 0000000000..ef2a142c34
--- /dev/null
+++ b/browser/components/migration/tests/unit/head_migration.js
@@ -0,0 +1,109 @@
+"use strict";
+
+var { MigrationUtils, MigratorPrototype } = ChromeUtils.import(
+ "resource:///modules/MigrationUtils.jsm"
+);
+var { LoginHelper } = ChromeUtils.import(
+ "resource://gre/modules/LoginHelper.jsm"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { PlacesUtils } = ChromeUtils.import(
+ "resource://gre/modules/PlacesUtils.jsm"
+);
+var { Preferences } = ChromeUtils.import(
+ "resource://gre/modules/Preferences.jsm"
+);
+var { PromiseUtils } = ChromeUtils.import(
+ "resource://gre/modules/PromiseUtils.jsm"
+);
+var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
+var { XPCOMUtils } = ChromeUtils.import(
+ "resource://gre/modules/XPCOMUtils.jsm"
+);
+var { TestUtils } = ChromeUtils.import(
+ "resource://testing-common/TestUtils.jsm"
+);
+var { PlacesTestUtils } = ChromeUtils.import(
+ "resource://testing-common/PlacesTestUtils.jsm"
+);
+
+XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "FileUtils",
+ "resource://gre/modules/FileUtils.jsm"
+);
+ChromeUtils.defineModuleGetter(
+ this,
+ "Sqlite",
+ "resource://gre/modules/Sqlite.jsm"
+);
+
+// Initialize profile.
+var gProfD = do_get_profile();
+
+var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.import(
+ "resource://testing-common/AppInfo.jsm"
+);
+updateAppInfo();
+
+/**
+ * Migrates the requested resource and waits for the migration to be complete.
+ */
+async function promiseMigration(
+ migrator,
+ resourceType,
+ aProfile = null,
+ succeeds = null
+) {
+ // Ensure resource migration is available.
+ let availableSources = await migrator.getMigrateData(aProfile);
+ Assert.ok(
+ (availableSources & resourceType) > 0,
+ "Resource supported by migrator"
+ );
+ let promises = [TestUtils.topicObserved("Migration:Ended")];
+
+ if (succeeds !== null) {
+ // Check that the specific resource type succeeded
+ promises.push(
+ TestUtils.topicObserved(
+ succeeds ? "Migration:ItemAfterMigrate" : "Migration:ItemError",
+ (_, data) => data == resourceType
+ )
+ );
+ }
+
+ // Start the migration.
+ migrator.migrate(resourceType, null, aProfile);
+
+ return Promise.all(promises);
+}
+
+/**
+ * Replaces a directory service entry with a given nsIFile.
+ */
+function registerFakePath(key, file) {
+ let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties);
+ let originalFile;
+ try {
+ // If a file is already provided save it and undefine, otherwise set will
+ // throw for persistent entries (ones that are cached).
+ originalFile = dirsvc.get(key, Ci.nsIFile);
+ dirsvc.undefine(key);
+ } catch (e) {
+ // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine
+ // will throw if it's not a persistent entry, in either case we don't want
+ // to set the original file in cleanup.
+ originalFile = undefined;
+ }
+
+ dirsvc.set(key, file);
+ registerCleanupFunction(() => {
+ dirsvc.undefine(key);
+ if (originalFile) {
+ dirsvc.set(key, originalFile);
+ }
+ });
+}
diff --git a/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp
new file mode 100644
index 0000000000..cdc1faff7d
--- /dev/null
+++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Insert URLs into Internet Explorer (IE) history so we can test importing
+ * them.
+ *
+ * See API docs at
+ * https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms774949(v%3dvs.85)
+ */
+
+#include <urlhist.h> // IUrlHistoryStg
+#include <shlguid.h> // SID_SUrlHistory
+
+int main(int argc, char** argv) {
+ HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED);
+ if (FAILED(hr)) {
+ CoUninitialize();
+ return -1;
+ }
+ IUrlHistoryStg* ieHist;
+
+ hr =
+ ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER,
+ IID_IUrlHistoryStg, reinterpret_cast<void**>(&ieHist));
+ if (FAILED(hr)) return -2;
+
+ hr = ieHist->AddUrl(L"http://www.mozilla.org/1", L"Mozilla HTTP Test", 0);
+ if (FAILED(hr)) return -3;
+
+ hr = ieHist->AddUrl(L"https://www.mozilla.org/2", L"Mozilla HTTPS Test 🦊",
+ 0);
+ if (FAILED(hr)) return -4;
+
+ CoUninitialize();
+
+ return 0;
+}
diff --git a/browser/components/migration/tests/unit/insertIEHistory/moz.build b/browser/components/migration/tests/unit/insertIEHistory/moz.build
new file mode 100644
index 0000000000..33c261c746
--- /dev/null
+++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build
@@ -0,0 +1,18 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+FINAL_TARGET = "_tests/xpcshell/browser/components/migration/tests/unit"
+
+Program("InsertIEHistory")
+OS_LIBS += [
+ "ole32",
+]
+SOURCES += [
+ "InsertIEHistory.cpp",
+]
+
+NO_PGO = True
+DisableStlWrapping()
diff --git a/browser/components/migration/tests/unit/test_360se_bookmarks.js b/browser/components/migration/tests/unit/test_360se_bookmarks.js
new file mode 100644
index 0000000000..e08959f50a
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js
@@ -0,0 +1,75 @@
+"use strict";
+
+const { CustomizableUI } = ChromeUtils.import(
+ "resource:///modules/CustomizableUI.jsm"
+);
+
+add_task(async function() {
+ registerFakePath("AppData", do_get_file("AppData/Roaming/"));
+
+ let migrator = await MigrationUtils.getMigrator("360se");
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ let profiles = await migrator.getSourceProfiles();
+ Assert.equal(profiles.length, 2, "Should present two profiles");
+ Assert.equal(
+ profiles[0].name,
+ "test@firefox.com.cn",
+ "Current logged in user should be the first"
+ );
+ Assert.equal(
+ profiles[profiles.length - 1].name,
+ "Default",
+ "Default user should be the last"
+ );
+
+ let importedToBookmarksToolbar = false;
+ let itemsSeen = { bookmarks: 0, folders: 0 };
+
+ let listener = events => {
+ for (let event of events) {
+ itemsSeen[
+ event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER
+ ? "folders"
+ : "bookmarks"
+ ]++;
+ if (event.parentId == PlacesUtils.toolbarFolderId) {
+ importedToBookmarksToolbar = true;
+ }
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+ let observerNotified = false;
+ Services.obs.addObserver((aSubject, aTopic, aData) => {
+ let [toolbar, visibility] = JSON.parse(aData);
+ Assert.equal(
+ toolbar,
+ CustomizableUI.AREA_BOOKMARKS,
+ "Notification should be received for bookmarks toolbar"
+ );
+ Assert.equal(
+ visibility,
+ "true",
+ "Notification should say to reveal the bookmarks toolbar"
+ );
+ observerNotified = true;
+ }, "browser-set-toolbar-visibility");
+
+ await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS, {
+ id: "default",
+ });
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+
+ // Check the bookmarks have been imported to all the expected parents.
+ Assert.ok(importedToBookmarksToolbar, "Bookmarks imported in the toolbar");
+ Assert.equal(itemsSeen.bookmarks, 8, "Should import all bookmarks.");
+ Assert.equal(itemsSeen.folders, 2, "Should import all folders.");
+ // Check that the telemetry matches:
+ Assert.equal(
+ MigrationUtils._importQuantities.bookmarks,
+ itemsSeen.bookmarks + itemsSeen.folders,
+ "Telemetry reporting correct."
+ );
+ Assert.ok(observerNotified, "The observer should be notified upon migration");
+});
diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js
new file mode 100644
index 0000000000..937332628c
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js
@@ -0,0 +1,84 @@
+"use strict";
+
+const { ChromeMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/ChromeMigrationUtils.jsm"
+);
+
+// Setup chrome user data path for all platforms.
+ChromeMigrationUtils.getDataPath = () => {
+ return do_get_file("Library/Application Support/Google/Chrome/").path;
+};
+
+add_task(async function test_getExtensionList_function() {
+ let extensionList = await ChromeMigrationUtils.getExtensionList("Default");
+ Assert.equal(
+ extensionList.length,
+ 2,
+ "There should be 2 extensions installed."
+ );
+ Assert.deepEqual(
+ extensionList.find(extension => extension.id == "fake-extension-1"),
+ {
+ id: "fake-extension-1",
+ name: "Fake Extension 1",
+ description: "It is the description of fake extension 1.",
+ },
+ "First extension should match expectations."
+ );
+ Assert.deepEqual(
+ extensionList.find(extension => extension.id == "fake-extension-2"),
+ {
+ id: "fake-extension-2",
+ name: "Fake Extension 2",
+ description: "It is the description of fake extension 2.",
+ },
+ "Second extension should match expectations."
+ );
+});
+
+add_task(async function test_getExtensionInformation_function() {
+ let extension = await ChromeMigrationUtils.getExtensionInformation(
+ "fake-extension-1",
+ "Default"
+ );
+ Assert.deepEqual(
+ extension,
+ {
+ id: "fake-extension-1",
+ name: "Fake Extension 1",
+ description: "It is the description of fake extension 1.",
+ },
+ "Should get the extension information correctly."
+ );
+});
+
+add_task(async function test_getLocaleString_function() {
+ let name = await ChromeMigrationUtils._getLocaleString(
+ "__MSG_name__",
+ "en_US",
+ "fake-extension-1",
+ "Default"
+ );
+ Assert.deepEqual(
+ name,
+ "Fake Extension 1",
+ "The value of __MSG_name__ locale key is Fake Extension 1."
+ );
+});
+
+add_task(async function test_isExtensionInstalled_function() {
+ let isInstalled = await ChromeMigrationUtils.isExtensionInstalled(
+ "fake-extension-1",
+ "Default"
+ );
+ Assert.ok(isInstalled, "The fake-extension-1 extension should be installed.");
+});
+
+add_task(async function test_getLastUsedProfileId_function() {
+ let profileId = await ChromeMigrationUtils.getLastUsedProfileId();
+ Assert.equal(
+ profileId,
+ "Default",
+ "The last used profile ID should be Default."
+ );
+});
diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js
new file mode 100644
index 0000000000..ef34b7ce1c
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js
@@ -0,0 +1,114 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { ChromeMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/ChromeMigrationUtils.jsm"
+);
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+
+function getRootPath() {
+ let dirKey;
+ if (AppConstants.platform == "win") {
+ dirKey = "winLocalAppDataDir";
+ } else if (AppConstants.platform == "macosx") {
+ dirKey = "macUserLibDir";
+ } else {
+ dirKey = "homeDir";
+ }
+ return OS.Constants.Path[dirKey];
+}
+
+add_task(async function test_getDataPath_function() {
+ let chromeUserDataPath = ChromeMigrationUtils.getDataPath("Chrome");
+ let chromiumUserDataPath = ChromeMigrationUtils.getDataPath("Chromium");
+ let canaryUserDataPath = ChromeMigrationUtils.getDataPath("Canary");
+ if (AppConstants.platform == "win") {
+ Assert.equal(
+ chromeUserDataPath,
+ OS.Path.join(getRootPath(), "Google", "Chrome", "User Data"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ OS.Path.join(getRootPath(), "Chromium", "User Data"),
+ "Should get the path of Chromium data directory."
+ );
+ Assert.equal(
+ canaryUserDataPath,
+ OS.Path.join(getRootPath(), "Google", "Chrome SxS", "User Data"),
+ "Should get the path of Canary data directory."
+ );
+ } else if (AppConstants.platform == "macosx") {
+ Assert.equal(
+ chromeUserDataPath,
+ OS.Path.join(getRootPath(), "Application Support", "Google", "Chrome"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ OS.Path.join(getRootPath(), "Application Support", "Chromium"),
+ "Should get the path of Chromium data directory."
+ );
+ Assert.equal(
+ canaryUserDataPath,
+ OS.Path.join(
+ getRootPath(),
+ "Application Support",
+ "Google",
+ "Chrome Canary"
+ ),
+ "Should get the path of Canary data directory."
+ );
+ } else {
+ Assert.equal(
+ chromeUserDataPath,
+ OS.Path.join(getRootPath(), ".config", "google-chrome"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ OS.Path.join(getRootPath(), ".config", "chromium"),
+ "Should get the path of Chromium data directory."
+ );
+ Assert.equal(canaryUserDataPath, null, "Should get null for Canary.");
+ }
+});
+
+add_task(async function test_getExtensionPath_function() {
+ let extensionPath = ChromeMigrationUtils.getExtensionPath("Default");
+ let expectedPath;
+ if (AppConstants.platform == "win") {
+ expectedPath = OS.Path.join(
+ getRootPath(),
+ "Google",
+ "Chrome",
+ "User Data",
+ "Default",
+ "Extensions"
+ );
+ } else if (AppConstants.platform == "macosx") {
+ expectedPath = OS.Path.join(
+ getRootPath(),
+ "Application Support",
+ "Google",
+ "Chrome",
+ "Default",
+ "Extensions"
+ );
+ } else {
+ expectedPath = OS.Path.join(
+ getRootPath(),
+ ".config",
+ "google-chrome",
+ "Default",
+ "Extensions"
+ );
+ }
+ Assert.equal(
+ extensionPath,
+ expectedPath,
+ "Should get the path of extensions directory."
+ );
+});
diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js
new file mode 100644
index 0000000000..6198eb7265
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js
@@ -0,0 +1,198 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+const { CustomizableUI } = ChromeUtils.import(
+ "resource:///modules/CustomizableUI.jsm"
+);
+
+const { PlacesUIUtils } = ChromeUtils.import(
+ "resource:///modules/PlacesUIUtils.jsm"
+);
+
+let rootDir = do_get_file("chromefiles/", true);
+
+add_task(async function setup_fakePaths() {
+ let pathId;
+ if (AppConstants.platform == "macosx") {
+ pathId = "ULibDir";
+ } else if (AppConstants.platform == "win") {
+ pathId = "LocalAppData";
+ } else {
+ pathId = "Home";
+ }
+ registerFakePath(pathId, rootDir);
+});
+
+add_task(async function setup_initialBookmarks() {
+ let bookmarks = [];
+ for (let i = 0; i < PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + 1; i++) {
+ bookmarks.push({ url: "https://example.com/" + i, title: "" + i });
+ }
+
+ // Ensure we have enough items in both the menu and toolbar to trip creating a "from" folder.
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: bookmarks,
+ });
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.menuGuid,
+ children: bookmarks,
+ });
+});
+
+async function testBookmarks(migratorKey, subDirs, folderName) {
+ if (AppConstants.platform == "macosx") {
+ subDirs.unshift("Application Support");
+ } else if (AppConstants.platform == "win") {
+ subDirs.push("User Data");
+ } else {
+ subDirs.unshift(".config");
+ }
+
+ let target = rootDir.clone();
+ // Pretend this is the default profile
+ subDirs.push("Default");
+ while (subDirs.length) {
+ target.append(subDirs.shift());
+ }
+ // We don't import osfile.jsm until after registering the fake path, because
+ // importing osfile will sometimes greedily fetch certain path identifiers
+ // from the dir service, which means they get cached, which means we can't
+ // register a fake path for them anymore.
+ const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+ await OS.File.makeDir(target.path, {
+ from: rootDir.parent.path,
+ ignoreExisting: true,
+ });
+
+ target.append("Bookmarks");
+ await OS.File.remove(target.path, { ignoreAbsent: true });
+
+ let bookmarksData = {
+ roots: { bookmark_bar: { children: [] }, other: { children: [] } },
+ };
+ const MAX_BMS = 100;
+ let barKids = bookmarksData.roots.bookmark_bar.children;
+ let menuKids = bookmarksData.roots.other.children;
+ let currentMenuKids = menuKids;
+ let currentBarKids = barKids;
+ for (let i = 0; i < MAX_BMS; i++) {
+ currentBarKids.push({
+ url: "https://www.chrome-bookmark-bar-bookmark" + i + ".com",
+ name: "bookmark " + i,
+ type: "url",
+ });
+ currentMenuKids.push({
+ url: "https://www.chrome-menu-bookmark" + i + ".com",
+ name: "bookmark for menu " + i,
+ type: "url",
+ });
+ if (i % 20 == 19) {
+ let nextFolder = {
+ name: "toolbar folder " + Math.ceil(i / 20),
+ type: "folder",
+ children: [],
+ };
+ currentBarKids.push(nextFolder);
+ currentBarKids = nextFolder.children;
+
+ nextFolder = {
+ name: "menu folder " + Math.ceil(i / 20),
+ type: "folder",
+ children: [],
+ };
+ currentMenuKids.push(nextFolder);
+ currentMenuKids = nextFolder.children;
+ }
+ }
+
+ await OS.File.writeAtomic(target.path, JSON.stringify(bookmarksData), {
+ encoding: "utf-8",
+ });
+
+ let migrator = await MigrationUtils.getMigrator(migratorKey);
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ let itemsSeen = { bookmarks: 0, folders: 0 };
+ let gotImportedFolderWrapper = false;
+ let listener = events => {
+ for (let event of events) {
+ // "From " comes from the string `importedBookmarksFolder`
+ if (
+ event.title.startsWith("From ") &&
+ event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER
+ ) {
+ Assert.equal(event.title, folderName, "Bookmark folder name");
+ gotImportedFolderWrapper = true;
+ } else {
+ itemsSeen[
+ event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER
+ ? "folders"
+ : "bookmarks"
+ ]++;
+ }
+ }
+ };
+
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+ const PROFILE = {
+ id: "Default",
+ name: "Default",
+ };
+ let observerNotified = false;
+ Services.obs.addObserver((aSubject, aTopic, aData) => {
+ let [toolbar, visibility] = JSON.parse(aData);
+ Assert.equal(
+ toolbar,
+ CustomizableUI.AREA_BOOKMARKS,
+ "Notification should be received for bookmarks toolbar"
+ );
+ Assert.equal(
+ visibility,
+ "true",
+ "Notification should say to reveal the bookmarks toolbar"
+ );
+ observerNotified = true;
+ }, "browser-set-toolbar-visibility");
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.BOOKMARKS,
+ PROFILE
+ );
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+
+ Assert.equal(itemsSeen.bookmarks, 200, "Should have seen 200 bookmarks.");
+ Assert.equal(itemsSeen.folders, 10, "Should have seen 10 folders.");
+ Assert.equal(
+ gotImportedFolderWrapper,
+ !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020"),
+ "Should only get a 'From BrowserX' folder when the 2h2020 pref is disabled"
+ );
+ Assert.equal(
+ MigrationUtils._importQuantities.bookmarks,
+ itemsSeen.bookmarks + itemsSeen.folders,
+ "Telemetry reporting correct."
+ );
+ Assert.ok(observerNotified, "The observer should be notified upon migration");
+}
+
+add_task(async function test_Chrome() {
+ let subDirs =
+ AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"];
+ await testBookmarks("chrome", subDirs, "From Google Chrome");
+});
+
+add_task(async function test_ChromiumEdge() {
+ if (AppConstants.platform == "linux") {
+ // Edge isn't available on Linux.
+ return;
+ }
+ let subDirs =
+ AppConstants.platform == "macosx"
+ ? ["Microsoft Edge"]
+ : ["Microsoft", "Edge"];
+ await testBookmarks("chromium-edge", subDirs, "From Microsoft Edge");
+});
diff --git a/browser/components/migration/tests/unit/test_Chrome_cookies.js b/browser/components/migration/tests/unit/test_Chrome_cookies.js
new file mode 100644
index 0000000000..b738c1d1bc
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_cookies.js
@@ -0,0 +1,72 @@
+"use strict";
+
+const { ForgetAboutSite } = ChromeUtils.import(
+ "resource://gre/modules/ForgetAboutSite.jsm"
+);
+
+add_task(async function() {
+ registerFakePath("ULibDir", do_get_file("Library/"));
+ let migrator = await MigrationUtils.getMigrator("chrome");
+
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ const COOKIE = {
+ expiry: 2145934800,
+ host: "unencryptedcookie.invalid",
+ isHttpOnly: false,
+ isSession: false,
+ name: "testcookie",
+ path: "/",
+ value: "testvalue",
+ };
+
+ // Sanity check.
+ Assert.equal(
+ Services.cookies.countCookiesFromHost(COOKIE.host),
+ 0,
+ "There are no cookies initially"
+ );
+
+ const PROFILE = {
+ id: "Default",
+ name: "Person 1",
+ };
+
+ // Migrate unencrypted cookies.
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.COOKIES,
+ PROFILE
+ );
+
+ Assert.equal(
+ Services.cookies.countCookiesFromHost(COOKIE.host),
+ 1,
+ "Migrated the expected number of unencrypted cookies"
+ );
+ Assert.equal(
+ Services.cookies.countCookiesFromHost("encryptedcookie.invalid"),
+ 0,
+ "Migrated the expected number of encrypted cookies"
+ );
+
+ // Now check the cookie details.
+ let cookies = Services.cookies.getCookiesFromHost(COOKIE.host, {});
+ Assert.ok(cookies.length, "Cookies available");
+ let foundCookie = cookies[0];
+
+ for (let prop of Object.keys(COOKIE)) {
+ Assert.equal(foundCookie[prop], COOKIE[prop], "Check cookie " + prop);
+ }
+
+ // Cleanup.
+ await ForgetAboutSite.removeDataFromDomain(COOKIE.host);
+ Assert.equal(
+ Services.cookies.countCookiesFromHost(COOKIE.host),
+ 0,
+ "There are no cookies after cleanup"
+ );
+});
diff --git a/browser/components/migration/tests/unit/test_Chrome_history.js b/browser/components/migration/tests/unit/test_Chrome_history.js
new file mode 100644
index 0000000000..de0c7d9751
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_history.js
@@ -0,0 +1,206 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ChromeMigrationUtils } = ChromeUtils.import(
+ "resource:///modules/ChromeMigrationUtils.jsm"
+);
+
+const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/";
+
+const PROFILE = {
+ id: "Default",
+ name: "Person 1",
+};
+
+/**
+ * TEST_URLS reflects the data stored in '${SOURCE_PROFILE_DIR}HistoryMaster'.
+ * The main object reflects the data in the 'urls' table. The visits property
+ * reflects the associated data in the 'visits' table.
+ */
+const TEST_URLS = [
+ {
+ id: 1,
+ url: "http://example.com/",
+ title: "test",
+ visit_count: 1,
+ typed_count: 0,
+ last_visit_time: 13193151310368000,
+ hidden: 0,
+ visits: [
+ {
+ id: 1,
+ url: 1,
+ visit_time: 13193151310368000,
+ from_visit: 0,
+ transition: 805306370,
+ segment_id: 0,
+ visit_duration: 10745006,
+ incremented_omnibox_typed_score: 0,
+ },
+ ],
+ },
+ {
+ id: 2,
+ url: "http://invalid.com/",
+ title: "test2",
+ visit_count: 1,
+ typed_count: 0,
+ last_visit_time: 13193154948901000,
+ hidden: 0,
+ visits: [
+ {
+ id: 2,
+ url: 2,
+ visit_time: 13193154948901000,
+ from_visit: 0,
+ transition: 805306376,
+ segment_id: 0,
+ visit_duration: 6568270,
+ incremented_omnibox_typed_score: 0,
+ },
+ ],
+ },
+];
+
+async function setVisitTimes(time) {
+ let loginDataFile = do_get_file(`${SOURCE_PROFILE_DIR}History`);
+ let dbConn = await Sqlite.openConnection({ path: loginDataFile.path });
+
+ await dbConn.execute(`UPDATE urls SET last_visit_time = :last_visit_time`, {
+ last_visit_time: time,
+ });
+ await dbConn.execute(`UPDATE visits SET visit_time = :visit_time`, {
+ visit_time: time,
+ });
+
+ await dbConn.close();
+}
+
+function setExpectedVisitTimes(time) {
+ for (let urlInfo of TEST_URLS) {
+ urlInfo.last_visit_time = time;
+ urlInfo.visits[0].visit_time = time;
+ }
+}
+
+function assertEntryMatches(entry, urlInfo, dateWasInFuture = false) {
+ info(`Checking url: ${urlInfo.url}`);
+ Assert.ok(entry, `Should have stored an entry`);
+
+ Assert.equal(entry.url, urlInfo.url, "Should have the correct URL");
+ Assert.equal(entry.title, urlInfo.title, "Should have the correct title");
+ Assert.equal(
+ entry.visits.length,
+ urlInfo.visits.length,
+ "Should have the correct number of visits"
+ );
+
+ for (let index in urlInfo.visits) {
+ Assert.equal(
+ entry.visits[index].transition,
+ PlacesUtils.history.TRANSITIONS.LINK,
+ "Should have Link type transition"
+ );
+
+ if (dateWasInFuture) {
+ Assert.lessOrEqual(
+ entry.visits[index].date.getTime(),
+ new Date().getTime(),
+ "Should have moved the date to no later than the current date."
+ );
+ } else {
+ Assert.equal(
+ entry.visits[index].date.getTime(),
+ ChromeMigrationUtils.chromeTimeToDate(
+ urlInfo.visits[index].visit_time,
+ new Date()
+ ).getTime(),
+ "Should have the correct date"
+ );
+ }
+ }
+}
+
+function setupHistoryFile() {
+ removeHistoryFile();
+ let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryMaster`);
+ file.copyTo(file.parent, "History");
+}
+
+function removeHistoryFile() {
+ let file = do_get_file(`${SOURCE_PROFILE_DIR}History`, true);
+ try {
+ file.remove(false);
+ } catch (ex) {
+ // It is ok if this doesn't exist.
+ if (ex.result != Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST) {
+ throw ex;
+ }
+ }
+}
+
+add_task(async function setup() {
+ registerFakePath("ULibDir", do_get_file("Library/"));
+
+ registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+ removeHistoryFile();
+ });
+});
+
+add_task(async function test_import() {
+ setupHistoryFile();
+ await PlacesUtils.history.clear();
+ // Update to ~10 days ago since the date can't be too old or Places may expire it.
+ const pastDate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 10);
+ const pastChromeTime = ChromeMigrationUtils.dateToChromeTime(pastDate);
+ await setVisitTimes(pastChromeTime);
+ setExpectedVisitTimes(pastChromeTime);
+
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.HISTORY,
+ PROFILE
+ );
+
+ for (let urlInfo of TEST_URLS) {
+ let entry = await PlacesUtils.history.fetch(urlInfo.url, {
+ includeVisits: true,
+ });
+ assertEntryMatches(entry, urlInfo);
+ }
+});
+
+add_task(async function test_import_future_date() {
+ setupHistoryFile();
+ await PlacesUtils.history.clear();
+ const futureDate = new Date().getTime() + 6000 * 60 * 24;
+ await setVisitTimes(ChromeMigrationUtils.dateToChromeTime(futureDate));
+
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.HISTORY,
+ PROFILE
+ );
+
+ for (let urlInfo of TEST_URLS) {
+ let entry = await PlacesUtils.history.fetch(urlInfo.url, {
+ includeVisits: true,
+ });
+ assertEntryMatches(entry, urlInfo, true);
+ }
+});
diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js
new file mode 100644
index 0000000000..48d1934140
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js
@@ -0,0 +1,379 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const PROFILE = {
+ id: "Default",
+ name: "Person 1",
+};
+
+const TEST_LOGINS = [
+ {
+ id: 1, // id of the row in the chrome login db
+ username: "username",
+ password: "password",
+ origin: "https://c9.io",
+ formActionOrigin: "https://c9.io",
+ httpRealm: null,
+ usernameField: "inputEmail",
+ passwordField: "inputPassword",
+ timeCreated: 1437418416037,
+ timePasswordChanged: 1437418416037,
+ timesUsed: 1,
+ },
+ {
+ id: 2,
+ username: "username@gmail.com",
+ password: "password2",
+ origin: "https://accounts.google.com",
+ formActionOrigin: "https://accounts.google.com",
+ httpRealm: null,
+ usernameField: "Email",
+ passwordField: "Passwd",
+ timeCreated: 1437418446598,
+ timePasswordChanged: 1437418446598,
+ timesUsed: 6,
+ },
+ {
+ id: 3,
+ username: "username",
+ password: "password3",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "https://www.facebook.com",
+ httpRealm: null,
+ usernameField: "email",
+ passwordField: "pass",
+ timeCreated: 1437418478851,
+ timePasswordChanged: 1437418478851,
+ timesUsed: 1,
+ },
+ {
+ id: 4,
+ username: "user",
+ password: "اقرأPÀßwörd",
+ origin: "http://httpbin.org",
+ formActionOrigin: null,
+ httpRealm: "me@kennethreitz.com", // Digest auth.
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1437787462368,
+ timePasswordChanged: 1437787462368,
+ timesUsed: 1,
+ },
+ {
+ id: 5,
+ username: "buser",
+ password: "bpassword",
+ origin: "http://httpbin.org",
+ formActionOrigin: null,
+ httpRealm: "Fake Realm", // Basic auth.
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1437787539233,
+ timePasswordChanged: 1437787539233,
+ timesUsed: 1,
+ },
+ {
+ id: 6,
+ username: "username",
+ password: "password6",
+ origin: "https://www.example.com",
+ formActionOrigin: "", // NULL `action_url`
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "pass",
+ timeCreated: 1557291348878,
+ timePasswordChanged: 1557291348878,
+ timesUsed: 1,
+ },
+ {
+ id: 7,
+ version: "v10",
+ username: "username",
+ password: "password",
+ origin: "https://v10.io",
+ formActionOrigin: "https://v10.io",
+ httpRealm: null,
+ usernameField: "inputEmail",
+ passwordField: "inputPassword",
+ timeCreated: 1437418416037,
+ timePasswordChanged: 1437418416037,
+ timesUsed: 1,
+ },
+];
+
+var loginCrypto;
+var dbConn;
+
+async function promiseSetPassword(login) {
+ let encryptedString = await loginCrypto.encryptData(
+ login.password,
+ login.version
+ );
+ info(`promiseSetPassword: ${encryptedString}`);
+ let passwordValue = new Uint8Array(
+ loginCrypto.stringToArray(encryptedString)
+ );
+ return dbConn.execute(
+ `UPDATE logins
+ SET password_value = :password_value
+ WHERE rowid = :rowid
+ `,
+ { password_value: passwordValue, rowid: login.id }
+ );
+}
+
+function checkLoginsAreEqual(passwordManagerLogin, chromeLogin, id) {
+ passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo);
+
+ Assert.equal(
+ passwordManagerLogin.username,
+ chromeLogin.username,
+ "The two logins ID " + id + " have the same username"
+ );
+ Assert.equal(
+ passwordManagerLogin.password,
+ chromeLogin.password,
+ "The two logins ID " + id + " have the same password"
+ );
+ Assert.equal(
+ passwordManagerLogin.origin,
+ chromeLogin.origin,
+ "The two logins ID " + id + " have the same origin"
+ );
+ Assert.equal(
+ passwordManagerLogin.formActionOrigin,
+ chromeLogin.formActionOrigin,
+ "The two logins ID " + id + " have the same formActionOrigin"
+ );
+ Assert.equal(
+ passwordManagerLogin.httpRealm,
+ chromeLogin.httpRealm,
+ "The two logins ID " + id + " have the same httpRealm"
+ );
+ Assert.equal(
+ passwordManagerLogin.usernameField,
+ chromeLogin.usernameField,
+ "The two logins ID " + id + " have the same usernameElement"
+ );
+ Assert.equal(
+ passwordManagerLogin.passwordField,
+ chromeLogin.passwordField,
+ "The two logins ID " + id + " have the same passwordElement"
+ );
+ Assert.equal(
+ passwordManagerLogin.timeCreated,
+ chromeLogin.timeCreated,
+ "The two logins ID " + id + " have the same timeCreated"
+ );
+ Assert.equal(
+ passwordManagerLogin.timePasswordChanged,
+ chromeLogin.timePasswordChanged,
+ "The two logins ID " + id + " have the same timePasswordChanged"
+ );
+ Assert.equal(
+ passwordManagerLogin.timesUsed,
+ chromeLogin.timesUsed,
+ "The two logins ID " + id + " have the same timesUsed"
+ );
+}
+
+function generateDifferentLogin(login) {
+ let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
+ Ci.nsILoginInfo
+ );
+
+ newLogin.init(
+ login.origin,
+ login.formActionOrigin,
+ null,
+ login.username,
+ login.password + 1,
+ login.usernameField + 1,
+ login.passwordField + 1
+ );
+ newLogin.QueryInterface(Ci.nsILoginMetaInfo);
+ newLogin.timeCreated = login.timeCreated + 1;
+ newLogin.timePasswordChanged = login.timePasswordChanged + 1;
+ newLogin.timesUsed = login.timesUsed + 1;
+ return newLogin;
+}
+
+add_task(async function setup() {
+ let dirSvcPath;
+ let pathId;
+ let profilePathSegments;
+
+ // Use a mock service and account name to avoid a Keychain auth. prompt that
+ // would block the test from finishing if Chrome has already created a matching
+ // Keychain entry. This allows us to still exercise the keychain lookup code.
+ // The mock encryption passphrase is used when the Keychain item isn't found.
+ let mockMacOSKeychain = {
+ passphrase: "bW96aWxsYWZpcmVmb3g=",
+ serviceName: "TESTING Chrome Safe Storage",
+ accountName: "TESTING Chrome",
+ };
+ if (AppConstants.platform == "macosx") {
+ let { ChromeMacOSLoginCrypto } = ChromeUtils.import(
+ "resource:///modules/ChromeMacOSLoginCrypto.jsm"
+ );
+ loginCrypto = new ChromeMacOSLoginCrypto(
+ mockMacOSKeychain.serviceName,
+ mockMacOSKeychain.accountName,
+ mockMacOSKeychain.passphrase
+ );
+ dirSvcPath = "Library/";
+ pathId = "ULibDir";
+ profilePathSegments = [
+ "Application Support",
+ "Google",
+ "Chrome",
+ "Default",
+ "Login Data",
+ ];
+ } else if (AppConstants.platform == "win") {
+ let { ChromeWindowsLoginCrypto } = ChromeUtils.import(
+ "resource:///modules/ChromeWindowsLoginCrypto.jsm"
+ );
+ loginCrypto = new ChromeWindowsLoginCrypto("Chrome");
+ dirSvcPath = "AppData/Local/";
+ pathId = "LocalAppData";
+ profilePathSegments = [
+ "Google",
+ "Chrome",
+ "User Data",
+ "Default",
+ "Login Data",
+ ];
+ } else {
+ throw new Error("Not implemented");
+ }
+ let dirSvcFile = do_get_file(dirSvcPath);
+ registerFakePath(pathId, dirSvcFile);
+
+ // We don't import osfile.jsm until after registering the fake path, because
+ // importing osfile will sometimes greedily fetch certain path identifiers
+ // from the dir service, which means they get cached, which means we can't
+ // register a fake path for them anymore.
+ const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+ info(OS.Path.join(dirSvcFile.path, ...profilePathSegments));
+ let loginDataFilePath = OS.Path.join(dirSvcFile.path, ...profilePathSegments);
+ dbConn = await Sqlite.openConnection({ path: loginDataFilePath });
+
+ if (AppConstants.platform == "macosx") {
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Object.assign(migrator.wrappedJSObject, {
+ _keychainServiceName: mockMacOSKeychain.serviceName,
+ _keychainAccountName: mockMacOSKeychain.accountName,
+ _keychainMockPassphrase: mockMacOSKeychain.passphrase,
+ });
+ }
+
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ if (loginCrypto.finalize) {
+ loginCrypto.finalize();
+ }
+ return dbConn.close();
+ });
+});
+
+add_task(async function test_importIntoEmptyDB() {
+ for (let login of TEST_LOGINS) {
+ await promiseSetPassword(login);
+ }
+
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "There are no logins initially");
+
+ // Migrate the logins.
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.PASSWORDS,
+ PROFILE,
+ true
+ );
+
+ logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ TEST_LOGINS.length,
+ "Check login count after importing the data"
+ );
+ Assert.equal(
+ logins.length,
+ MigrationUtils._importQuantities.logins,
+ "Check telemetry matches the actual import."
+ );
+
+ for (let i = 0; i < TEST_LOGINS.length; i++) {
+ checkLoginsAreEqual(logins[i], TEST_LOGINS[i], i + 1);
+ }
+});
+
+// Test that existing logins for the same primary key don't get overwritten
+add_task(async function test_importExistingLogins() {
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ Services.logins.removeAllUserFacingLogins();
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins after removing all of them"
+ );
+
+ let newLogins = [];
+
+ // Create 3 new logins that are different but where the key properties are still the same.
+ for (let i = 0; i < 3; i++) {
+ newLogins.push(generateDifferentLogin(TEST_LOGINS[i]));
+ Services.logins.addLogin(newLogins[i]);
+ }
+
+ logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ newLogins.length,
+ "Check login count after the insertion"
+ );
+
+ for (let i = 0; i < newLogins.length; i++) {
+ checkLoginsAreEqual(logins[i], newLogins[i], i + 1);
+ }
+ // Migrate the logins.
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.PASSWORDS,
+ PROFILE,
+ true
+ );
+
+ logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ TEST_LOGINS.length,
+ "Check there are still the same number of logins after re-importing the data"
+ );
+ Assert.equal(
+ logins.length,
+ MigrationUtils._importQuantities.logins,
+ "Check telemetry matches the actual import."
+ );
+
+ for (let i = 0; i < newLogins.length; i++) {
+ checkLoginsAreEqual(logins[i], newLogins[i], i + 1);
+ }
+});
diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js
new file mode 100644
index 0000000000..505e8219fc
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js
@@ -0,0 +1,83 @@
+"use strict";
+
+/**
+ * Ensure that there is no dialog for OS crypto that blocks a migration when
+ * importing from an empty Login Data DB.
+ */
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+const PROFILE = {
+ id: "Default",
+ name: "Person With No Data",
+};
+
+add_task(async function setup() {
+ let dirSvcPath;
+ let pathId;
+
+ // Use a mock service and account name to avoid a Keychain auth. prompt that
+ // would block the test from finishing if Chrome has already created a matching
+ // Keychain entry. This allows us to still exercise the keychain lookup code.
+ // The mock encryption passphrase is used when the Keychain item isn't found.
+ let mockMacOSKeychain = {
+ passphrase: "bW96aWxsYWZpcmVmb3g=",
+ serviceName: "TESTING Chrome Safe Storage",
+ accountName: "TESTING Chrome",
+ };
+ if (AppConstants.platform == "macosx") {
+ dirSvcPath = "LibraryWithNoData/";
+ pathId = "ULibDir";
+ } else if (AppConstants.platform == "win") {
+ dirSvcPath = "AppData/LocalWithNoData/";
+ pathId = "LocalAppData";
+ } else {
+ throw new Error("Not implemented");
+ }
+ let dirSvcFile = do_get_file(dirSvcPath);
+ registerFakePath(pathId, dirSvcFile);
+
+ if (AppConstants.platform == "macosx") {
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Object.assign(migrator.wrappedJSObject, {
+ _keychainServiceName: mockMacOSKeychain.serviceName,
+ _keychainAccountName: mockMacOSKeychain.accountName,
+ // No `_keychainMockPassphrase` as we don't want to mock the OS dialog as
+ // it shouldn't appear.
+ });
+ }
+
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_importEmptyDBWithoutAuthPrompts() {
+ let migrator = await MigrationUtils.getMigrator("chrome");
+ Assert.ok(
+ await migrator.isSourceAvailable(),
+ "Sanity check the source exists"
+ );
+
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "There are no logins initially");
+
+ // Migrate the logins. If an OS dialog (e.g. Keychain) blocks this the test
+ // would timeout.
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.PASSWORDS,
+ PROFILE,
+ true
+ );
+
+ logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "Check login count after importing the data");
+ Assert.equal(
+ logins.length,
+ MigrationUtils._importQuantities.logins,
+ "Check telemetry matches the actual import."
+ );
+});
diff --git a/browser/components/migration/tests/unit/test_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js
new file mode 100644
index 0000000000..0ebd1b1fff
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js
@@ -0,0 +1,791 @@
+"use strict";
+
+const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm");
+let eseBackStage = ChromeUtils.import(
+ "resource:///modules/ESEDBReader.jsm",
+ null
+);
+let ESE = eseBackStage.ESE;
+let KERNEL = eseBackStage.KERNEL;
+let gLibs = eseBackStage.gLibs;
+let COLUMN_TYPES = eseBackStage.COLUMN_TYPES;
+let declareESEFunction = eseBackStage.declareESEFunction;
+let loadLibraries = eseBackStage.loadLibraries;
+
+let gESEInstanceCounter = 1;
+
+ESE.JET_COLUMNCREATE_W = new ctypes.StructType("JET_COLUMNCREATE_W", [
+ { cbStruct: ctypes.unsigned_long },
+ { szColumnName: ESE.JET_PCWSTR },
+ { coltyp: ESE.JET_COLTYP },
+ { cbMax: ctypes.unsigned_long },
+ { grbit: ESE.JET_GRBIT },
+ { pvDefault: ctypes.voidptr_t },
+ { cbDefault: ctypes.unsigned_long },
+ { cp: ctypes.unsigned_long },
+ { columnid: ESE.JET_COLUMNID },
+ { err: ESE.JET_ERR },
+]);
+
+function createColumnCreationWrapper({ name, type, cbMax }) {
+ // We use a wrapper object because we need to be sure the JS engine won't GC
+ // data that we're "only" pointing to.
+ let wrapper = {};
+ wrapper.column = new ESE.JET_COLUMNCREATE_W();
+ wrapper.column.cbStruct = ESE.JET_COLUMNCREATE_W.size;
+ let wchar_tArray = ctypes.ArrayType(ctypes.char16_t);
+ wrapper.name = new wchar_tArray(name.length + 1);
+ wrapper.name.value = String(name);
+ wrapper.column.szColumnName = wrapper.name;
+ wrapper.column.coltyp = type;
+ let fallback = 0;
+ switch (type) {
+ case COLUMN_TYPES.JET_coltypText:
+ fallback = 255;
+ // Intentional fall-through
+ case COLUMN_TYPES.JET_coltypLongText:
+ wrapper.column.cbMax = cbMax || fallback || 64 * 1024;
+ break;
+ case COLUMN_TYPES.JET_coltypGUID:
+ wrapper.column.cbMax = 16;
+ break;
+ case COLUMN_TYPES.JET_coltypBit:
+ wrapper.column.cbMax = 1;
+ break;
+ case COLUMN_TYPES.JET_coltypLongLong:
+ wrapper.column.cbMax = 8;
+ break;
+ default:
+ throw new Error("Unknown column type!");
+ }
+
+ wrapper.column.columnid = new ESE.JET_COLUMNID();
+ wrapper.column.grbit = 0;
+ wrapper.column.pvDefault = null;
+ wrapper.column.cbDefault = 0;
+ wrapper.column.cp = 0;
+
+ return wrapper;
+}
+
+// "forward declarations" of indexcreate and setinfo structs, which we don't use.
+ESE.JET_INDEXCREATE = new ctypes.StructType("JET_INDEXCREATE");
+ESE.JET_SETINFO = new ctypes.StructType("JET_SETINFO");
+
+ESE.JET_TABLECREATE_W = new ctypes.StructType("JET_TABLECREATE_W", [
+ { cbStruct: ctypes.unsigned_long },
+ { szTableName: ESE.JET_PCWSTR },
+ { szTemplateTableName: ESE.JET_PCWSTR },
+ { ulPages: ctypes.unsigned_long },
+ { ulDensity: ctypes.unsigned_long },
+ { rgcolumncreate: ESE.JET_COLUMNCREATE_W.ptr },
+ { cColumns: ctypes.unsigned_long },
+ { rgindexcreate: ESE.JET_INDEXCREATE.ptr },
+ { cIndexes: ctypes.unsigned_long },
+ { grbit: ESE.JET_GRBIT },
+ { tableid: ESE.JET_TABLEID },
+ { cCreated: ctypes.unsigned_long },
+]);
+
+function createTableCreationWrapper(tableName, columns) {
+ let wrapper = {};
+ let wchar_tArray = ctypes.ArrayType(ctypes.char16_t);
+ wrapper.name = new wchar_tArray(tableName.length + 1);
+ wrapper.name.value = String(tableName);
+ wrapper.table = new ESE.JET_TABLECREATE_W();
+ wrapper.table.cbStruct = ESE.JET_TABLECREATE_W.size;
+ wrapper.table.szTableName = wrapper.name;
+ wrapper.table.szTemplateTableName = null;
+ wrapper.table.ulPages = 1;
+ wrapper.table.ulDensity = 0;
+ let columnArrayType = ESE.JET_COLUMNCREATE_W.array(columns.length);
+ wrapper.columnAry = new columnArrayType();
+ wrapper.table.rgcolumncreate = wrapper.columnAry.addressOfElement(0);
+ wrapper.table.cColumns = columns.length;
+ wrapper.columns = [];
+ for (let i = 0; i < columns.length; i++) {
+ let column = columns[i];
+ let columnWrapper = createColumnCreationWrapper(column);
+ wrapper.columnAry.addressOfElement(i).contents = columnWrapper.column;
+ wrapper.columns.push(columnWrapper);
+ }
+ wrapper.table.rgindexcreate = null;
+ wrapper.table.cIndexes = 0;
+ return wrapper;
+}
+
+function convertValueForWriting(value, valueType) {
+ let buffer;
+ let valueOfValueType = ctypes.UInt64.lo(valueType);
+ switch (valueOfValueType) {
+ case COLUMN_TYPES.JET_coltypLongLong:
+ if (value instanceof Date) {
+ buffer = new KERNEL.FILETIME();
+ let sysTime = new KERNEL.SYSTEMTIME();
+ sysTime.wYear = value.getUTCFullYear();
+ sysTime.wMonth = value.getUTCMonth() + 1;
+ sysTime.wDay = value.getUTCDate();
+ sysTime.wHour = value.getUTCHours();
+ sysTime.wMinute = value.getUTCMinutes();
+ sysTime.wSecond = value.getUTCSeconds();
+ sysTime.wMilliseconds = value.getUTCMilliseconds();
+ let rv = KERNEL.SystemTimeToFileTime(
+ sysTime.address(),
+ buffer.address()
+ );
+ if (!rv) {
+ throw new Error("Failed to get FileTime.");
+ }
+ return [buffer, KERNEL.FILETIME.size];
+ }
+ throw new Error("Unrecognized value for longlong column");
+ case COLUMN_TYPES.JET_coltypLongText:
+ let wchar_tArray = ctypes.ArrayType(ctypes.char16_t);
+ buffer = new wchar_tArray(value.length + 1);
+ buffer.value = String(value);
+ return [buffer, buffer.length * 2];
+ case COLUMN_TYPES.JET_coltypBit:
+ buffer = new ctypes.uint8_t();
+ // Bizarre boolean values, but whatever:
+ buffer.value = value ? 255 : 0;
+ return [buffer, 1];
+ case COLUMN_TYPES.JET_coltypGUID:
+ let byteArray = ctypes.ArrayType(ctypes.uint8_t);
+ buffer = new byteArray(16);
+ let j = 0;
+ for (let i = 0; i < value.length; i++) {
+ if (!/[0-9a-f]/i.test(value[i])) {
+ continue;
+ }
+ let byteAsHex = value.substr(i, 2);
+ buffer[j++] = parseInt(byteAsHex, 16);
+ i++;
+ }
+ return [buffer, 16];
+ }
+
+ throw new Error("Unknown type " + valueType);
+}
+
+let initializedESE = false;
+
+let eseDBWritingHelpers = {
+ setupDB(dbFile, tables) {
+ if (!initializedESE) {
+ initializedESE = true;
+ loadLibraries();
+
+ KERNEL.SystemTimeToFileTime = gLibs.kernel.declare(
+ "SystemTimeToFileTime",
+ ctypes.winapi_abi,
+ ctypes.bool,
+ KERNEL.SYSTEMTIME.ptr,
+ KERNEL.FILETIME.ptr
+ );
+
+ declareESEFunction(
+ "CreateDatabaseW",
+ ESE.JET_SESID,
+ ESE.JET_PCWSTR,
+ ESE.JET_PCWSTR,
+ ESE.JET_DBID.ptr,
+ ESE.JET_GRBIT
+ );
+ declareESEFunction(
+ "CreateTableColumnIndexW",
+ ESE.JET_SESID,
+ ESE.JET_DBID,
+ ESE.JET_TABLECREATE_W.ptr
+ );
+ declareESEFunction("BeginTransaction", ESE.JET_SESID);
+ declareESEFunction("CommitTransaction", ESE.JET_SESID, ESE.JET_GRBIT);
+ declareESEFunction(
+ "PrepareUpdate",
+ ESE.JET_SESID,
+ ESE.JET_TABLEID,
+ ctypes.unsigned_long
+ );
+ declareESEFunction(
+ "Update",
+ ESE.JET_SESID,
+ ESE.JET_TABLEID,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ctypes.unsigned_long.ptr
+ );
+ declareESEFunction(
+ "SetColumn",
+ ESE.JET_SESID,
+ ESE.JET_TABLEID,
+ ESE.JET_COLUMNID,
+ ctypes.voidptr_t,
+ ctypes.unsigned_long,
+ ESE.JET_GRBIT,
+ ESE.JET_SETINFO.ptr
+ );
+ ESE.SetSystemParameterW(
+ null,
+ 0,
+ 64 /* JET_paramDatabasePageSize*/,
+ 8192,
+ null
+ );
+ }
+
+ let rootPath = dbFile.parent.path + "\\";
+ let logPath = rootPath + "LogFiles\\";
+
+ try {
+ this._instanceId = new ESE.JET_INSTANCE();
+ ESE.CreateInstanceW(
+ this._instanceId.address(),
+ "firefox-dbwriter-" + gESEInstanceCounter++
+ );
+ this._instanceCreated = true;
+
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 0 /* JET_paramSystemPath*/,
+ 0,
+ rootPath
+ );
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 1 /* JET_paramTempPath */,
+ 0,
+ rootPath
+ );
+ ESE.SetSystemParameterW(
+ this._instanceId.address(),
+ 0,
+ 2 /* JET_paramLogFilePath*/,
+ 0,
+ logPath
+ );
+ // Shouldn't try to call JetTerm if the following call fails.
+ this._instanceCreated = false;
+ ESE.Init(this._instanceId.address());
+ this._instanceCreated = true;
+ this._sessionId = new ESE.JET_SESID();
+ ESE.BeginSessionW(
+ this._instanceId,
+ this._sessionId.address(),
+ null,
+ null
+ );
+ this._sessionCreated = true;
+
+ this._dbId = new ESE.JET_DBID();
+ this._dbPath = rootPath + "spartan.edb";
+ ESE.CreateDatabaseW(
+ this._sessionId,
+ this._dbPath,
+ null,
+ this._dbId.address(),
+ 0
+ );
+ this._opened = this._attached = true;
+
+ for (let [tableName, data] of tables) {
+ let { rows, columns } = data;
+ let tableCreationWrapper = createTableCreationWrapper(
+ tableName,
+ columns
+ );
+ ESE.CreateTableColumnIndexW(
+ this._sessionId,
+ this._dbId,
+ tableCreationWrapper.table.address()
+ );
+ this._tableId = tableCreationWrapper.table.tableid;
+
+ let columnIdMap = new Map();
+ if (rows.length) {
+ // Iterate over the struct we passed into ESENT because they have the
+ // created column ids.
+ let columnCount = ctypes.UInt64.lo(
+ tableCreationWrapper.table.cColumns
+ );
+ let columnsPassed = tableCreationWrapper.table.rgcolumncreate;
+ for (let i = 0; i < columnCount; i++) {
+ let column = columnsPassed.contents;
+ columnIdMap.set(column.szColumnName.readString(), column);
+ columnsPassed = columnsPassed.increment();
+ }
+ ESE.ManualMove(
+ this._sessionId,
+ this._tableId,
+ -2147483648 /* JET_MoveFirst */,
+ 0
+ );
+ ESE.BeginTransaction(this._sessionId);
+ for (let row of rows) {
+ ESE.PrepareUpdate(
+ this._sessionId,
+ this._tableId,
+ 0 /* JET_prepInsert */
+ );
+ for (let columnName in row) {
+ let col = columnIdMap.get(columnName);
+ let colId = col.columnid;
+ let [val, valSize] = convertValueForWriting(
+ row[columnName],
+ col.coltyp
+ );
+ /* JET_bitSetOverwriteLV */
+ ESE.SetColumn(
+ this._sessionId,
+ this._tableId,
+ colId,
+ val.address(),
+ valSize,
+ 4,
+ null
+ );
+ }
+ let actualBookmarkSize = new ctypes.unsigned_long();
+ ESE.Update(
+ this._sessionId,
+ this._tableId,
+ null,
+ 0,
+ actualBookmarkSize.address()
+ );
+ }
+ ESE.CommitTransaction(
+ this._sessionId,
+ 0 /* JET_bitWaitLastLevel0Commit */
+ );
+ }
+ }
+ } finally {
+ try {
+ this._close();
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ },
+
+ _close() {
+ if (this._tableId) {
+ ESE.FailSafeCloseTable(this._sessionId, this._tableId);
+ delete this._tableId;
+ }
+ if (this._opened) {
+ ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0);
+ this._opened = false;
+ }
+ if (this._attached) {
+ ESE.FailSafeDetachDatabaseW(this._sessionId, this._dbPath);
+ this._attached = false;
+ }
+ if (this._sessionCreated) {
+ ESE.FailSafeEndSession(this._sessionId, 0);
+ this._sessionCreated = false;
+ }
+ if (this._instanceCreated) {
+ ESE.FailSafeTerm(this._instanceId);
+ this._instanceCreated = false;
+ }
+ },
+};
+
+add_task(async function() {
+ let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tempFile.append("fx-xpcshell-edge-db");
+ tempFile.createUnique(tempFile.DIRECTORY_TYPE, 0o600);
+
+ let db = tempFile.clone();
+ db.append("spartan.edb");
+
+ let logs = tempFile.clone();
+ logs.append("LogFiles");
+ logs.create(tempFile.DIRECTORY_TYPE, 0o600);
+
+ let creationDate = new Date(Date.now() - 5000);
+ const kEdgeMenuParent = "62d07e2b-5f0d-4e41-8426-5f5ec9717beb";
+ let bookmarkReferenceItems = [
+ {
+ URL: "http://www.mozilla.org/",
+ Title: "Mozilla",
+ DateUpdated: new Date(creationDate.valueOf() + 100),
+ ItemId: "1c00c10a-15f6-4618-92dd-22575102a4da",
+ ParentId: kEdgeMenuParent,
+ IsFolder: false,
+ IsDeleted: false,
+ },
+ {
+ Title: "Folder",
+ DateUpdated: new Date(creationDate.valueOf() + 200),
+ ItemId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa",
+ ParentId: kEdgeMenuParent,
+ IsFolder: true,
+ IsDeleted: false,
+ },
+ {
+ Title: "Item in folder",
+ URL: "http://www.iteminfolder.org/",
+ DateUpdated: new Date(creationDate.valueOf() + 300),
+ ItemId: "c295ddaf-04a1-424a-866c-0ebde011e7c8",
+ ParentId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa",
+ IsFolder: false,
+ IsDeleted: false,
+ },
+ {
+ Title: "Deleted folder",
+ DateUpdated: new Date(creationDate.valueOf() + 400),
+ ItemId: "a547573c-4d4d-4406-a736-5b5462d93bca",
+ ParentId: kEdgeMenuParent,
+ IsFolder: true,
+ IsDeleted: true,
+ },
+ {
+ Title: "Deleted item",
+ URL: "http://www.deleteditem.org/",
+ DateUpdated: new Date(creationDate.valueOf() + 500),
+ ItemId: "37a574bb-b44b-4bbc-a414-908615536435",
+ ParentId: kEdgeMenuParent,
+ IsFolder: false,
+ IsDeleted: true,
+ },
+ {
+ Title: "Item in deleted folder (should be in root)",
+ URL: "http://www.itemindeletedfolder.org/",
+ DateUpdated: new Date(creationDate.valueOf() + 600),
+ ItemId: "74dd1cc3-4c5d-471f-bccc-7bc7c72fa621",
+ ParentId: "a547573c-4d4d-4406-a736-5b5462d93bca",
+ IsFolder: false,
+ IsDeleted: false,
+ },
+ {
+ Title: "_Favorites_Bar_",
+ DateUpdated: new Date(creationDate.valueOf() + 700),
+ ItemId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf",
+ ParentId: kEdgeMenuParent,
+ IsFolder: true,
+ IsDeleted: false,
+ },
+ {
+ Title: "Item in favorites bar",
+ URL: "http://www.iteminfavoritesbar.org/",
+ DateUpdated: new Date(creationDate.valueOf() + 800),
+ ItemId: "9f2b1ff8-b651-46cf-8f41-16da8bcb6791",
+ ParentId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf",
+ IsFolder: false,
+ IsDeleted: false,
+ },
+ ];
+
+ let readingListReferenceItems = [
+ {
+ Title: "Some mozilla page",
+ URL: "http://www.mozilla.org/somepage/",
+ AddedDate: new Date(creationDate.valueOf() + 900),
+ ItemId: "c88426fd-52a7-419d-acbc-d2310e8afebe",
+ IsDeleted: false,
+ },
+ {
+ Title: "Some other page",
+ URL: "https://www.example.org/somepage/",
+ AddedDate: new Date(creationDate.valueOf() + 1000),
+ ItemId: "a35fc843-5d5a-4d1e-9be8-45214be24b5c",
+ IsDeleted: false,
+ },
+ ];
+ eseDBWritingHelpers.setupDB(
+ db,
+ new Map([
+ [
+ "Favorites",
+ {
+ columns: [
+ { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 },
+ {
+ type: COLUMN_TYPES.JET_coltypLongText,
+ name: "Title",
+ cbMax: 4096,
+ },
+ { type: COLUMN_TYPES.JET_coltypLongLong, name: "DateUpdated" },
+ { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" },
+ { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" },
+ { type: COLUMN_TYPES.JET_coltypBit, name: "IsFolder" },
+ { type: COLUMN_TYPES.JET_coltypGUID, name: "ParentId" },
+ ],
+ rows: bookmarkReferenceItems,
+ },
+ ],
+ [
+ "ReadingList",
+ {
+ columns: [
+ { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 },
+ {
+ type: COLUMN_TYPES.JET_coltypLongText,
+ name: "Title",
+ cbMax: 4096,
+ },
+ { type: COLUMN_TYPES.JET_coltypLongLong, name: "AddedDate" },
+ { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" },
+ { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" },
+ ],
+ rows: readingListReferenceItems,
+ },
+ ],
+ ])
+ );
+
+ let migrator = Cc[
+ "@mozilla.org/profile/migrator;1?app=browser&type=edge"
+ ].createInstance(Ci.nsIBrowserProfileMigrator);
+ let bookmarksMigrator = migrator.wrappedJSObject.getBookmarksMigratorForTesting(
+ db
+ );
+ Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created");
+
+ let source = await MigrationUtils.getLocalizedString("source-name-edge");
+ let sourceLabel = await MigrationUtils.getLocalizedString(
+ "imported-bookmarks-source",
+ { source }
+ );
+
+ let seenBookmarks = [];
+ let listener = events => {
+ for (let event of events) {
+ let {
+ id,
+ itemType,
+ url,
+ title,
+ dateAdded,
+ guid,
+ index,
+ parentGuid,
+ parentId,
+ } = event;
+ if (title.startsWith("Deleted")) {
+ ok(false, "Should not see deleted items being bookmarked!");
+ }
+ seenBookmarks.push({
+ id,
+ parentId,
+ index,
+ itemType,
+ url,
+ title,
+ dateAdded,
+ guid,
+ parentGuid,
+ });
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+
+ let migrateResult = await new Promise(resolve =>
+ bookmarksMigrator.migrate(resolve)
+ ).catch(ex => {
+ Cu.reportError(ex);
+ Assert.ok(false, "Got an exception trying to migrate data! " + ex);
+ return false;
+ });
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+ Assert.ok(migrateResult, "Migration should succeed");
+ Assert.equal(
+ seenBookmarks.length,
+ 5,
+ "Should have seen 5 items being bookmarked."
+ );
+ Assert.equal(
+ seenBookmarks.filter(bm => bm.title != sourceLabel).length,
+ MigrationUtils._importQuantities.bookmarks,
+ "Telemetry should have items except for 'From Microsoft Edge' folders"
+ );
+
+ let menuParents = seenBookmarks.filter(
+ item => item.parentGuid == PlacesUtils.bookmarks.menuGuid
+ );
+ Assert.equal(
+ menuParents.length,
+ 3,
+ "Bookmarks are added to the menu without a folder"
+ );
+ let toolbarParents = seenBookmarks.filter(
+ item => item.parentGuid == PlacesUtils.bookmarks.toolbarGuid
+ );
+ Assert.equal(
+ toolbarParents.length,
+ 1,
+ "Should have a single item added to the toolbar"
+ );
+ let menuParentGuid = PlacesUtils.bookmarks.menuGuid;
+ let toolbarParentGuid = PlacesUtils.bookmarks.toolbarGuid;
+
+ let expectedTitlesInMenu = bookmarkReferenceItems
+ .filter(item => item.ParentId == kEdgeMenuParent)
+ .map(item => item.Title);
+ // Hacky, but seems like much the simplest way:
+ expectedTitlesInMenu.push("Item in deleted folder (should be in root)");
+ let expectedTitlesInToolbar = bookmarkReferenceItems
+ .filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf")
+ .map(item => item.Title);
+
+ let importParentFolderName = sourceLabel;
+
+ for (let bookmark of seenBookmarks) {
+ let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title);
+ let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title);
+ if (
+ bookmark.title == "Folder" ||
+ bookmark.title == importParentFolderName
+ ) {
+ Assert.equal(
+ bookmark.itemType,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ "Bookmark " + bookmark.title + " should be a folder"
+ );
+ } else {
+ Assert.notEqual(
+ bookmark.itemType,
+ PlacesUtils.bookmarks.TYPE_FOLDER,
+ "Bookmark " + bookmark.title + " should not be a folder"
+ );
+ }
+
+ if (shouldBeInMenu) {
+ Assert.equal(
+ bookmark.parentGuid,
+ menuParentGuid,
+ "Item '" + bookmark.title + "' should be in menu"
+ );
+ } else if (shouldBeInToolbar) {
+ Assert.equal(
+ bookmark.parentGuid,
+ toolbarParentGuid,
+ "Item '" + bookmark.title + "' should be in toolbar"
+ );
+ } else if (
+ bookmark.guid == menuParentGuid ||
+ bookmark.guid == toolbarParentGuid
+ ) {
+ Assert.ok(
+ true,
+ "Expect toolbar and menu folders to not be in menu or toolbar"
+ );
+ } else {
+ // Bit hacky, but we do need to check this.
+ Assert.equal(
+ bookmark.title,
+ "Item in folder",
+ "Subfoldered item shouldn't be in menu or toolbar"
+ );
+ let parent = seenBookmarks.find(
+ maybeParent => maybeParent.guid == bookmark.parentGuid
+ );
+ Assert.equal(
+ parent && parent.title,
+ "Folder",
+ "Subfoldered item should be in subfolder labeled 'Folder'"
+ );
+ }
+
+ let dbItem = bookmarkReferenceItems.find(
+ someItem => bookmark.title == someItem.Title
+ );
+ if (!dbItem) {
+ Assert.equal(
+ bookmark.title,
+ importParentFolderName,
+ "Only the extra layer of folders isn't in the input we stuck in the DB."
+ );
+ Assert.ok(
+ [menuParentGuid, toolbarParentGuid].includes(bookmark.guid),
+ "This item should be one of the containers"
+ );
+ } else {
+ Assert.equal(dbItem.URL || "", bookmark.url, "URL is correct");
+ Assert.equal(
+ dbItem.DateUpdated.valueOf(),
+ new Date(bookmark.dateAdded).valueOf(),
+ "Date added is correct"
+ );
+ }
+ }
+
+ MigrationUtils._importQuantities.bookmarks = 0;
+ seenBookmarks = [];
+ listener = events => {
+ for (let event of events) {
+ let {
+ id,
+ itemType,
+ url,
+ title,
+ dateAdded,
+ guid,
+ index,
+ parentGuid,
+ parentId,
+ } = event;
+ seenBookmarks.push({
+ id,
+ parentId,
+ index,
+ itemType,
+ url,
+ title,
+ dateAdded,
+ guid,
+ parentGuid,
+ });
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+
+ let readingListMigrator = migrator.wrappedJSObject.getReadingListMigratorForTesting(
+ db
+ );
+ Assert.ok(readingListMigrator.exists, "Should recognize db we just created");
+ migrateResult = await new Promise(resolve =>
+ readingListMigrator.migrate(resolve)
+ ).catch(ex => {
+ Cu.reportError(ex);
+ Assert.ok(false, "Got an exception trying to migrate data! " + ex);
+ return false;
+ });
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+ Assert.ok(migrateResult, "Migration should succeed");
+ Assert.equal(
+ seenBookmarks.length,
+ 3,
+ "Should have seen 3 items being bookmarked (2 items + 1 folder)."
+ );
+ Assert.equal(
+ seenBookmarks.filter(bm => bm.title != sourceLabel).length,
+ MigrationUtils._importQuantities.bookmarks,
+ "Telemetry should have items except for 'From Microsoft Edge' folders"
+ );
+ let readingListContainerLabel = await MigrationUtils.getLocalizedString(
+ "imported-edge-reading-list"
+ );
+
+ for (let bookmark of seenBookmarks) {
+ if (readingListContainerLabel == bookmark.title) {
+ continue;
+ }
+ let referenceItem = readingListReferenceItems.find(
+ item => item.Title == bookmark.title
+ );
+ Assert.ok(referenceItem, "Should have imported what we expected");
+ Assert.equal(referenceItem.URL, bookmark.url, "Should have the right URL");
+ readingListReferenceItems.splice(
+ readingListReferenceItems.findIndex(item => item.Title == bookmark.title),
+ 1
+ );
+ }
+ Assert.ok(
+ !readingListReferenceItems.length,
+ "Should have seen all expected items."
+ );
+});
diff --git a/browser/components/migration/tests/unit/test_IE7_passwords.js b/browser/components/migration/tests/unit/test_IE7_passwords.js
new file mode 100644
index 0000000000..14acf5ad33
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE7_passwords.js
@@ -0,0 +1,1369 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.import(
+ "resource://gre/modules/AppConstants.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "OSCrypto",
+ "resource://gre/modules/OSCrypto.jsm"
+);
+
+const IE7_FORM_PASSWORDS_MIGRATOR_NAME = "IE7FormPasswords";
+const LOGINS_KEY =
+ "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";
+const EXTENSION = "-backup";
+const TESTED_WEBSITES = {
+ twitter: {
+ uri: makeURI("https://twitter.com"),
+ hash: "A89D42BC6406E27265B1AD0782B6F376375764A301",
+ data: [
+ 12,
+ 0,
+ 0,
+ 0,
+ 56,
+ 0,
+ 0,
+ 0,
+ 38,
+ 0,
+ 0,
+ 0,
+ 87,
+ 73,
+ 67,
+ 75,
+ 24,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 68,
+ 36,
+ 67,
+ 124,
+ 118,
+ 212,
+ 208,
+ 1,
+ 8,
+ 0,
+ 0,
+ 0,
+ 18,
+ 0,
+ 0,
+ 0,
+ 68,
+ 36,
+ 67,
+ 124,
+ 118,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 97,
+ 0,
+ 98,
+ 0,
+ 99,
+ 0,
+ 100,
+ 0,
+ 101,
+ 0,
+ 102,
+ 0,
+ 103,
+ 0,
+ 104,
+ 0,
+ 0,
+ 0,
+ 49,
+ 0,
+ 50,
+ 0,
+ 51,
+ 0,
+ 52,
+ 0,
+ 53,
+ 0,
+ 54,
+ 0,
+ 55,
+ 0,
+ 56,
+ 0,
+ 57,
+ 0,
+ 0,
+ 0,
+ ],
+ logins: [
+ {
+ username: "abcdefgh",
+ password: "123456789",
+ origin: "https://twitter.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439325854000,
+ timeLastUsed: 1439325854000,
+ timePasswordChanged: 1439325854000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ facebook: {
+ uri: makeURI("https://www.facebook.com/"),
+ hash: "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796",
+ data: [
+ 12,
+ 0,
+ 0,
+ 0,
+ 152,
+ 0,
+ 0,
+ 0,
+ 160,
+ 0,
+ 0,
+ 0,
+ 87,
+ 73,
+ 67,
+ 75,
+ 24,
+ 0,
+ 0,
+ 0,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 88,
+ 182,
+ 125,
+ 18,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 20,
+ 0,
+ 0,
+ 0,
+ 88,
+ 182,
+ 125,
+ 18,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 40,
+ 0,
+ 0,
+ 0,
+ 134,
+ 65,
+ 33,
+ 37,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 60,
+ 0,
+ 0,
+ 0,
+ 134,
+ 65,
+ 33,
+ 37,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 80,
+ 0,
+ 0,
+ 0,
+ 45,
+ 242,
+ 246,
+ 62,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 100,
+ 0,
+ 0,
+ 0,
+ 45,
+ 242,
+ 246,
+ 62,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 120,
+ 0,
+ 0,
+ 0,
+ 28,
+ 10,
+ 193,
+ 80,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 140,
+ 0,
+ 0,
+ 0,
+ 28,
+ 10,
+ 193,
+ 80,
+ 121,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 117,
+ 0,
+ 115,
+ 0,
+ 101,
+ 0,
+ 114,
+ 0,
+ 110,
+ 0,
+ 97,
+ 0,
+ 109,
+ 0,
+ 101,
+ 0,
+ 48,
+ 0,
+ 0,
+ 0,
+ 112,
+ 0,
+ 97,
+ 0,
+ 115,
+ 0,
+ 115,
+ 0,
+ 119,
+ 0,
+ 111,
+ 0,
+ 114,
+ 0,
+ 100,
+ 0,
+ 48,
+ 0,
+ 0,
+ 0,
+ 117,
+ 0,
+ 115,
+ 0,
+ 101,
+ 0,
+ 114,
+ 0,
+ 110,
+ 0,
+ 97,
+ 0,
+ 109,
+ 0,
+ 101,
+ 0,
+ 49,
+ 0,
+ 0,
+ 0,
+ 112,
+ 0,
+ 97,
+ 0,
+ 115,
+ 0,
+ 115,
+ 0,
+ 119,
+ 0,
+ 111,
+ 0,
+ 114,
+ 0,
+ 100,
+ 0,
+ 49,
+ 0,
+ 0,
+ 0,
+ 117,
+ 0,
+ 115,
+ 0,
+ 101,
+ 0,
+ 114,
+ 0,
+ 110,
+ 0,
+ 97,
+ 0,
+ 109,
+ 0,
+ 101,
+ 0,
+ 50,
+ 0,
+ 0,
+ 0,
+ 112,
+ 0,
+ 97,
+ 0,
+ 115,
+ 0,
+ 115,
+ 0,
+ 119,
+ 0,
+ 111,
+ 0,
+ 114,
+ 0,
+ 100,
+ 0,
+ 50,
+ 0,
+ 0,
+ 0,
+ 117,
+ 0,
+ 115,
+ 0,
+ 101,
+ 0,
+ 114,
+ 0,
+ 110,
+ 0,
+ 97,
+ 0,
+ 109,
+ 0,
+ 101,
+ 0,
+ 51,
+ 0,
+ 0,
+ 0,
+ 112,
+ 0,
+ 97,
+ 0,
+ 115,
+ 0,
+ 115,
+ 0,
+ 119,
+ 0,
+ 111,
+ 0,
+ 114,
+ 0,
+ 100,
+ 0,
+ 51,
+ 0,
+ 0,
+ 0,
+ ],
+ logins: [
+ {
+ username: "username0",
+ password: "password0",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439326966000,
+ timeLastUsed: 1439326966000,
+ timePasswordChanged: 1439326966000,
+ timesUsed: 1,
+ },
+ {
+ username: "username1",
+ password: "password1",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439326997000,
+ timeLastUsed: 1439326997000,
+ timePasswordChanged: 1439326997000,
+ timesUsed: 1,
+ },
+ {
+ username: "username2",
+ password: "password2",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439327040000,
+ timeLastUsed: 1439327040000,
+ timePasswordChanged: 1439327040000,
+ timesUsed: 1,
+ },
+ {
+ username: "username3",
+ password: "password3",
+ origin: "https://www.facebook.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439327070000,
+ timeLastUsed: 1439327070000,
+ timePasswordChanged: 1439327070000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ live: {
+ uri: makeURI("https://login.live.com/"),
+ hash: "7B506F2D6B81D939A8E0456F036EE8970856FF705E",
+ data: [
+ 12,
+ 0,
+ 0,
+ 0,
+ 56,
+ 0,
+ 0,
+ 0,
+ 44,
+ 0,
+ 0,
+ 0,
+ 87,
+ 73,
+ 67,
+ 75,
+ 24,
+ 0,
+ 0,
+ 0,
+ 2,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 212,
+ 17,
+ 219,
+ 140,
+ 148,
+ 212,
+ 208,
+ 1,
+ 9,
+ 0,
+ 0,
+ 0,
+ 20,
+ 0,
+ 0,
+ 0,
+ 212,
+ 17,
+ 219,
+ 140,
+ 148,
+ 212,
+ 208,
+ 1,
+ 11,
+ 0,
+ 0,
+ 0,
+ 114,
+ 0,
+ 105,
+ 0,
+ 97,
+ 0,
+ 100,
+ 0,
+ 104,
+ 0,
+ 49,
+ 6,
+ 74,
+ 6,
+ 39,
+ 6,
+ 54,
+ 6,
+ 0,
+ 0,
+ 39,
+ 6,
+ 66,
+ 6,
+ 49,
+ 6,
+ 35,
+ 6,
+ 80,
+ 0,
+ 192,
+ 0,
+ 223,
+ 0,
+ 119,
+ 0,
+ 246,
+ 0,
+ 114,
+ 0,
+ 100,
+ 0,
+ 0,
+ 0,
+ ],
+ logins: [
+ {
+ username: "riadhرياض",
+ password: "اقرأPÀßwörd",
+ origin: "https://login.live.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439338767000,
+ timeLastUsed: 1439338767000,
+ timePasswordChanged: 1439338767000,
+ timesUsed: 1,
+ },
+ ],
+ },
+ reddit: {
+ uri: makeURI("http://www.reddit.com/"),
+ hash: "B644028D1C109A91EC2C4B9D1F145E55A1FAE42065",
+ data: [
+ 12,
+ 0,
+ 0,
+ 0,
+ 152,
+ 0,
+ 0,
+ 0,
+ 212,
+ 0,
+ 0,
+ 0,
+ 87,
+ 73,
+ 67,
+ 75,
+ 24,
+ 0,
+ 0,
+ 0,
+ 8,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 1,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 32,
+ 8,
+ 234,
+ 114,
+ 153,
+ 212,
+ 208,
+ 1,
+ 1,
+ 0,
+ 0,
+ 0,
+ 4,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 6,
+ 0,
+ 0,
+ 0,
+ 97,
+ 93,
+ 131,
+ 116,
+ 153,
+ 212,
+ 208,
+ 1,
+ 3,
+ 0,
+ 0,
+ 0,
+ 14,
+ 0,
+ 0,
+ 0,
+ 97,
+ 93,
+ 131,
+ 116,
+ 153,
+ 212,
+ 208,
+ 1,
+ 16,
+ 0,
+ 0,
+ 0,
+ 48,
+ 0,
+ 0,
+ 0,
+ 88,
+ 150,
+ 78,
+ 174,
+ 153,
+ 212,
+ 208,
+ 1,
+ 4,
+ 0,
+ 0,
+ 0,
+ 58,
+ 0,
+ 0,
+ 0,
+ 88,
+ 150,
+ 78,
+ 174,
+ 153,
+ 212,
+ 208,
+ 1,
+ 29,
+ 0,
+ 0,
+ 0,
+ 118,
+ 0,
+ 0,
+ 0,
+ 79,
+ 102,
+ 137,
+ 34,
+ 154,
+ 212,
+ 208,
+ 1,
+ 15,
+ 0,
+ 0,
+ 0,
+ 150,
+ 0,
+ 0,
+ 0,
+ 79,
+ 102,
+ 137,
+ 34,
+ 154,
+ 212,
+ 208,
+ 1,
+ 30,
+ 0,
+ 0,
+ 0,
+ 97,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 252,
+ 140,
+ 173,
+ 138,
+ 146,
+ 48,
+ 0,
+ 0,
+ 66,
+ 0,
+ 105,
+ 0,
+ 116,
+ 0,
+ 116,
+ 0,
+ 101,
+ 0,
+ 32,
+ 0,
+ 98,
+ 0,
+ 101,
+ 0,
+ 115,
+ 0,
+ 116,
+ 0,
+ 228,
+ 0,
+ 116,
+ 0,
+ 105,
+ 0,
+ 103,
+ 0,
+ 101,
+ 0,
+ 110,
+ 0,
+ 0,
+ 0,
+ 205,
+ 145,
+ 110,
+ 127,
+ 198,
+ 91,
+ 1,
+ 120,
+ 0,
+ 0,
+ 31,
+ 4,
+ 48,
+ 4,
+ 64,
+ 4,
+ 62,
+ 4,
+ 59,
+ 4,
+ 76,
+ 4,
+ 32,
+ 0,
+ 67,
+ 4,
+ 65,
+ 4,
+ 63,
+ 4,
+ 53,
+ 4,
+ 72,
+ 4,
+ 61,
+ 4,
+ 62,
+ 4,
+ 32,
+ 0,
+ 65,
+ 4,
+ 49,
+ 4,
+ 64,
+ 4,
+ 62,
+ 4,
+ 72,
+ 4,
+ 53,
+ 4,
+ 61,
+ 4,
+ 46,
+ 0,
+ 32,
+ 0,
+ 18,
+ 4,
+ 62,
+ 4,
+ 57,
+ 4,
+ 66,
+ 4,
+ 56,
+ 4,
+ 0,
+ 0,
+ 40,
+ 6,
+ 51,
+ 6,
+ 69,
+ 6,
+ 32,
+ 0,
+ 39,
+ 6,
+ 68,
+ 6,
+ 68,
+ 6,
+ 71,
+ 6,
+ 32,
+ 0,
+ 39,
+ 6,
+ 68,
+ 6,
+ 49,
+ 6,
+ 45,
+ 6,
+ 69,
+ 6,
+ 70,
+ 6,
+ 0,
+ 0,
+ 118,
+ 0,
+ 101,
+ 0,
+ 117,
+ 0,
+ 105,
+ 0,
+ 108,
+ 0,
+ 108,
+ 0,
+ 101,
+ 0,
+ 122,
+ 0,
+ 32,
+ 0,
+ 108,
+ 0,
+ 101,
+ 0,
+ 32,
+ 0,
+ 118,
+ 0,
+ 233,
+ 0,
+ 114,
+ 0,
+ 105,
+ 0,
+ 102,
+ 0,
+ 105,
+ 0,
+ 101,
+ 0,
+ 114,
+ 0,
+ 32,
+ 0,
+ 224,
+ 0,
+ 32,
+ 0,
+ 110,
+ 0,
+ 111,
+ 0,
+ 117,
+ 0,
+ 118,
+ 0,
+ 101,
+ 0,
+ 97,
+ 0,
+ 117,
+ 0,
+ 0,
+ 0,
+ ],
+ logins: [
+ // This login is present in the data, but should be stripped out
+ // by the validation rules of the importer:
+ // {
+ // "username": "a",
+ // "password": "",
+ // "origin": "http://www.reddit.com",
+ // "formActionOrigin": "",
+ // "httpRealm": null,
+ // "usernameField": "",
+ // "passwordField": ""
+ // },
+ {
+ username: "購読を",
+ password: "Bitte bestätigen",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439340874000,
+ timeLastUsed: 1439340874000,
+ timePasswordChanged: 1439340874000,
+ timesUsed: 1,
+ },
+ {
+ username: "重置密码",
+ password: "Пароль успешно сброшен. Войти",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439340971000,
+ timeLastUsed: 1439340971000,
+ timePasswordChanged: 1439340971000,
+ timesUsed: 1,
+ },
+ {
+ username: "بسم الله الرحمن",
+ password: "veuillez le vérifier à nouveau",
+ origin: "http://www.reddit.com",
+ formActionOrigin: "",
+ httpRealm: null,
+ usernameField: "",
+ passwordField: "",
+ timeCreated: 1439341166000,
+ timeLastUsed: 1439341166000,
+ timePasswordChanged: 1439341166000,
+ timesUsed: 1,
+ },
+ ],
+ },
+};
+
+const TESTED_URLS = [
+ "http://a.foo.com",
+ "http://b.foo.com",
+ "http://c.foo.com",
+ "http://www.test.net",
+ "http://www.test.net/home",
+ "http://www.test.net/index",
+ "https://a.bar.com",
+ "https://b.bar.com",
+ "https://c.bar.com",
+];
+
+var nsIWindowsRegKey = Ci.nsIWindowsRegKey;
+var Storage2Key;
+
+/*
+ * If the key value exists, it's going to be backed up and replaced, so the value could be restored.
+ * Otherwise a new value is going to be created.
+ */
+function backupAndStore(key, name, value) {
+ if (key.hasValue(name)) {
+ // backup the the current value
+ let type = key.getValueType(name);
+ // create a new value using use the current value name followed by EXTENSION as its new name
+ switch (type) {
+ case nsIWindowsRegKey.TYPE_STRING:
+ key.writeStringValue(name + EXTENSION, key.readStringValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_BINARY:
+ key.writeBinaryValue(name + EXTENSION, key.readBinaryValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_INT:
+ key.writeIntValue(name + EXTENSION, key.readIntValue(name));
+ break;
+ case nsIWindowsRegKey.TYPE_INT64:
+ key.writeInt64Value(name + EXTENSION, key.readInt64Value(name));
+ break;
+ }
+ }
+ key.writeBinaryValue(name, value);
+}
+
+// Remove all values where their names are members of the names array from the key of registry
+function removeAllValues(key, names) {
+ for (let name of names) {
+ key.removeValue(name);
+ }
+}
+
+// Restore all the backed up values
+function restore(key) {
+ let count = key.valueCount;
+ let names = []; // the names of the key values
+ for (let i = 0; i < count; ++i) {
+ names.push(key.getValueName(i));
+ }
+
+ for (let name of names) {
+ // backed up values have EXTENSION at the end of their names
+ if (name.lastIndexOf(EXTENSION) == name.length - EXTENSION.length) {
+ let valueName = name.substr(0, name.length - EXTENSION.length);
+ let type = key.getValueType(name);
+ // create a new value using the name before the backup and removed the backed up one
+ switch (type) {
+ case nsIWindowsRegKey.TYPE_STRING:
+ key.writeStringValue(valueName, key.readStringValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_BINARY:
+ key.writeBinaryValue(valueName, key.readBinaryValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_INT:
+ key.writeIntValue(valueName, key.readIntValue(name));
+ key.removeValue(name);
+ break;
+ case nsIWindowsRegKey.TYPE_INT64:
+ key.writeInt64Value(valueName, key.readInt64Value(name));
+ key.removeValue(name);
+ break;
+ }
+ }
+ }
+}
+
+function checkLoginsAreEqual(passwordManagerLogin, IELogin, id) {
+ passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo);
+ for (let attribute in IELogin) {
+ Assert.equal(
+ passwordManagerLogin[attribute],
+ IELogin[attribute],
+ "The two logins ID " + id + " have the same " + attribute
+ );
+ }
+}
+
+function createRegistryPath(path) {
+ let loginPath = path.split("\\");
+ let parentKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ let currentPath = [];
+ for (let currentKey of loginPath) {
+ parentKey.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ currentPath.join("\\"),
+ nsIWindowsRegKey.ACCESS_ALL
+ );
+
+ if (!parentKey.hasChild(currentKey)) {
+ parentKey.createChild(currentKey, 0);
+ }
+ currentPath.push(currentKey);
+ parentKey.close();
+ }
+}
+
+function getFirstResourceOfType(type) {
+ let migrator = Cc[
+ "@mozilla.org/profile/migrator;1?app=browser&type=ie"
+ ].createInstance(Ci.nsISupports).wrappedJSObject;
+ let migrators = migrator.getResources();
+ for (let m of migrators) {
+ if (m.name == IE7_FORM_PASSWORDS_MIGRATOR_NAME && m.type == type) {
+ return m;
+ }
+ }
+ throw new Error("failed to find the " + type + " migrator");
+}
+
+function makeURI(aURL) {
+ return Services.io.newURI(aURL);
+}
+
+add_task(async function setup() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ Assert.throws(
+ () => getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS),
+ /failed to find/,
+ "The migrator doesn't exist for win8+"
+ );
+ return;
+ }
+ // create the path to Storage2 in the registry if it doest exist.
+ createRegistryPath(LOGINS_KEY);
+ Storage2Key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ nsIWindowsRegKey
+ );
+ Storage2Key.open(
+ nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ LOGINS_KEY,
+ nsIWindowsRegKey.ACCESS_ALL
+ );
+
+ // create a dummy value otherwise the migrator doesn't exist
+ if (!Storage2Key.hasValue("dummy")) {
+ Storage2Key.writeBinaryValue("dummy", "dummy");
+ }
+});
+
+add_task(async function test_passwordsNotAvailable() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return;
+ }
+
+ let migrator = getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS);
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins at the beginning of the test"
+ );
+
+ let uris = []; // the uris of the migrated logins
+ for (let url of TESTED_URLS) {
+ uris.push(makeURI(url));
+ // in this test, there is no IE login data in the registry, so after the migration, the number
+ // of logins in the store should be 0
+ await migrator._migrateURIs(uris);
+ logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins after doing the migration without adding values to the registry"
+ );
+ }
+});
+
+add_task(async function test_passwordsAvailable() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) {
+ return;
+ }
+
+ let crypto = new OSCrypto();
+ let hashes = []; // the hashes of all migrator websites, this is going to be used for the clean up
+
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ logins = Services.logins.getAllLogins();
+ Assert.equal(logins.length, 0, "There are no logins after the cleanup");
+ // remove all the values created in this test from the registry
+ removeAllValues(Storage2Key, hashes);
+ // restore all backed up values
+ restore(Storage2Key);
+
+ // clean the dummy value
+ if (Storage2Key.hasValue("dummy")) {
+ Storage2Key.removeValue("dummy");
+ }
+ Storage2Key.close();
+ crypto.finalize();
+ });
+
+ let migrator = getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS);
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ let logins = Services.logins.getAllLogins();
+ Assert.equal(
+ logins.length,
+ 0,
+ "There are no logins at the beginning of the test"
+ );
+
+ let uris = []; // the uris of the migrated logins
+
+ let loginCount = 0;
+ for (let current in TESTED_WEBSITES) {
+ let website = TESTED_WEBSITES[current];
+ // backup the current the registry value if it exists and replace the existing value/create a
+ // new value with the encrypted data
+ backupAndStore(
+ Storage2Key,
+ website.hash,
+ crypto.encryptData(crypto.arrayToString(website.data), website.uri.spec)
+ );
+ Assert.ok(migrator.exists, "The migrator has to exist");
+ uris.push(website.uri);
+ hashes.push(website.hash);
+
+ await migrator._migrateURIs(uris);
+ logins = Services.logins.getAllLogins();
+ // check that the number of logins in the password manager has increased as expected which means
+ // that all the values for the current website were imported
+ loginCount += website.logins.length;
+ Assert.equal(
+ logins.length,
+ loginCount,
+ "The number of logins has increased after the migration"
+ );
+ // NB: because telemetry records any login data passed to the login manager, it
+ // also gets told about logins that are duplicates or invalid (for one reason
+ // or another) and so its counts might exceed those of the login manager itself.
+ Assert.greaterOrEqual(
+ MigrationUtils._importQuantities.logins,
+ loginCount,
+ "Telemetry quantities equal or exceed the actual import."
+ );
+ // Reset - this normally happens at the start of a new migration, but we're calling
+ // the migrator directly so can't rely on that:
+ MigrationUtils._importQuantities.logins = 0;
+
+ let startIndex = loginCount - website.logins.length;
+ // compares the imported password manager logins with their expected logins
+ for (let i = 0; i < website.logins.length; i++) {
+ checkLoginsAreEqual(
+ logins[startIndex + i],
+ website.logins[i],
+ " " + current + " - " + i + " "
+ );
+ }
+ }
+});
diff --git a/browser/components/migration/tests/unit/test_IE_bookmarks.js b/browser/components/migration/tests/unit/test_IE_bookmarks.js
new file mode 100644
index 0000000000..4055797b1c
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js
@@ -0,0 +1,70 @@
+"use strict";
+
+const { CustomizableUI } = ChromeUtils.import(
+ "resource:///modules/CustomizableUI.jsm"
+);
+
+add_task(async function() {
+ let migrator = await MigrationUtils.getMigrator("ie");
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable(), "Check migrator source");
+
+ // Since this test doesn't mock out the favorites, execution is dependent
+ // on the actual favorites stored on the local machine's IE favorites database.
+ // As such, we can't assert that bookmarks were migrated to both the bookmarks
+ // menu and the bookmarks toolbar.
+ let bookmarkRoots = 0;
+ let itemCount = 0;
+ let listener = events => {
+ for (let event of events) {
+ if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) {
+ if (event.parentGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ bookmarkRoots |=
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR;
+ } else if (event.parentGuid == PlacesUtils.bookmarks.menuGuid) {
+ bookmarkRoots |= MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU;
+ }
+ info("bookmark added: " + event.parentGuid);
+ itemCount++;
+ }
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+ let observerNotified = false;
+ Services.obs.addObserver((aSubject, aTopic, aData) => {
+ let [toolbar, visibility] = JSON.parse(aData);
+ Assert.equal(
+ toolbar,
+ CustomizableUI.AREA_BOOKMARKS,
+ "Notification should be received for bookmarks toolbar"
+ );
+ Assert.equal(
+ visibility,
+ "true",
+ "Notification should say to reveal the bookmarks toolbar"
+ );
+ observerNotified = true;
+ }, "browser-set-toolbar-visibility");
+
+ await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS);
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+ Assert.equal(
+ MigrationUtils._importQuantities.bookmarks,
+ itemCount,
+ "Ensure telemetry matches actual number of imported items."
+ );
+ await TestUtils.waitForCondition(() => {
+ let snapshot = Services.telemetry.getSnapshotForKeyedHistograms(
+ "main",
+ false
+ ).parent.FX_MIGRATION_BOOKMARKS_ROOTS;
+ if (!snapshot || !snapshot.ie) {
+ return false;
+ }
+ info(`Expected ${bookmarkRoots}, got ${snapshot.ie.sum}`);
+ return snapshot.ie.sum == bookmarkRoots;
+ }, "Wait until telemetry is updated");
+
+ // Check the bookmarks have been imported to all the expected parents.
+ Assert.ok(observerNotified, "The observer should be notified upon migration");
+});
diff --git a/browser/components/migration/tests/unit/test_IE_cookies.js b/browser/components/migration/tests/unit/test_IE_cookies.js
new file mode 100644
index 0000000000..bb003ad5a5
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE_cookies.js
@@ -0,0 +1,149 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "ctypes",
+ "resource://gre/modules/ctypes.jsm"
+);
+
+add_task(async function() {
+ let migrator = await MigrationUtils.getMigrator("ie");
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ // Windows versions newer than 1709 don't store cookies as files anymore,
+ // thus our migrators don't import anything and this test is pointless.
+ // In these versions the CookD folder contains a deprecated.cookie file.
+ let deprecatedCookie = Services.dirsvc.get("CookD", Ci.nsIFile);
+ deprecatedCookie.append("deprecated.cookie");
+ if (deprecatedCookie.exists()) {
+ return;
+ }
+
+ const BOOL = ctypes.bool;
+ const LPCTSTR = ctypes.char16_t.ptr;
+ const DWORD = ctypes.uint32_t;
+ const LPDWORD = DWORD.ptr;
+
+ let wininet = ctypes.open("Wininet");
+
+ /*
+ BOOL InternetSetCookieW(
+ _In_ LPCTSTR lpszUrl,
+ _In_ LPCTSTR lpszCookieName,
+ _In_ LPCTSTR lpszCookieData
+ );
+ */
+ // NOTE: Even though MSDN documentation does not indicate a calling convention,
+ // InternetSetCookieW is declared in SDK headers as __stdcall but is exported
+ // from wininet.dll without name mangling, so it is effectively winapi_abi
+ let setIECookie = wininet.declare(
+ "InternetSetCookieW",
+ ctypes.winapi_abi,
+ BOOL,
+ LPCTSTR,
+ LPCTSTR,
+ LPCTSTR
+ );
+
+ /*
+ BOOL InternetGetCookieW(
+ _In_ LPCTSTR lpszUrl,
+ _In_ LPCTSTR lpszCookieName,
+ _Out_ LPCTSTR lpszCookieData,
+ _Inout_ LPDWORD lpdwSize
+ );
+ */
+ // NOTE: Even though MSDN documentation does not indicate a calling convention,
+ // InternetGetCookieW is declared in SDK headers as __stdcall but is exported
+ // from wininet.dll without name mangling, so it is effectively winapi_abi
+ let getIECookie = wininet.declare(
+ "InternetGetCookieW",
+ ctypes.winapi_abi,
+ BOOL,
+ LPCTSTR,
+ LPCTSTR,
+ LPCTSTR,
+ LPDWORD
+ );
+
+ // We need to randomize the cookie to avoid clashing with other cookies
+ // that might have been set by previous tests and not properly cleared.
+ let date = new Date().getDate();
+ const COOKIE = {
+ get host() {
+ return new URL(this.href).host;
+ },
+ href: `http://mycookietest.${Math.random()}.com`,
+ name: "testcookie",
+ value: "testvalue",
+ expiry: new Date(new Date().setDate(date + 2)),
+ };
+ let data = ctypes.char16_t.array()(256);
+ let sizeRef = DWORD(256).address();
+
+ registerCleanupFunction(() => {
+ // Remove the cookie.
+ try {
+ let expired = new Date(new Date().setDate(date - 2));
+ let rv = setIECookie(
+ COOKIE.href,
+ COOKIE.name,
+ `; expires=${expired.toUTCString()}`
+ );
+ Assert.ok(rv, "Expired the IE cookie");
+ Assert.ok(
+ !getIECookie(COOKIE.href, COOKIE.name, data, sizeRef),
+ "The cookie has been properly removed"
+ );
+ } catch (ex) {}
+
+ // Close the library.
+ try {
+ wininet.close();
+ } catch (ex) {}
+ });
+
+ // Create the persistent cookie in IE.
+ let value = `${COOKIE.value}; expires=${COOKIE.expiry.toUTCString()}`;
+ let rv = setIECookie(COOKIE.href, COOKIE.name, value);
+ Assert.ok(rv, "Added a persistent IE cookie: " + value);
+
+ // Sanity check the cookie has been created.
+ Assert.ok(
+ getIECookie(COOKIE.href, COOKIE.name, data, sizeRef),
+ "Found the added persistent IE cookie"
+ );
+ info("Found cookie: " + data.readString());
+ Assert.equal(
+ data.readString(),
+ `${COOKIE.name}=${COOKIE.value}`,
+ "Found the expected cookie"
+ );
+
+ // Sanity check that there are no cookies.
+ Assert.equal(
+ Services.cookies.countCookiesFromHost(COOKIE.host),
+ 0,
+ "There are no cookies initially"
+ );
+
+ // Migrate cookies.
+ await promiseMigration(migrator, MigrationUtils.resourceTypes.COOKIES);
+
+ Assert.equal(
+ Services.cookies.countCookiesFromHost(COOKIE.host),
+ 1,
+ "Migrated the expected number of cookies"
+ );
+
+ // Now check the cookie details.
+ let cookies = Services.cookies.getCookiesFromHost(COOKIE.host, {});
+ Assert.ok(cookies.length);
+ let foundCookie = cookies[0];
+
+ Assert.equal(foundCookie.name, COOKIE.name);
+ Assert.equal(foundCookie.value, COOKIE.value);
+ Assert.equal(foundCookie.host, "." + COOKIE.host);
+ Assert.equal(foundCookie.expiry, Math.floor(COOKIE.expiry / 1000));
+});
diff --git a/browser/components/migration/tests/unit/test_IE_history.js b/browser/components/migration/tests/unit/test_IE_history.js
new file mode 100644
index 0000000000..fd803f4607
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE_history.js
@@ -0,0 +1,50 @@
+"use strict";
+
+// These match what we add to IE via InsertIEHistory.exe.
+const TEST_ENTRIES = [
+ {
+ url: "http://www.mozilla.org/1",
+ title: "Mozilla HTTP Test",
+ },
+ {
+ url: "https://www.mozilla.org/2",
+ // Test character encoding with a fox emoji:
+ title: "Mozilla HTTPS Test 🦊",
+ },
+];
+
+function insertIEHistory() {
+ let file = do_get_file("InsertIEHistory.exe", false);
+ let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess);
+ process.init(file);
+
+ let args = [];
+ process.run(true, args, args.length);
+
+ Assert.ok(!process.isRunning, "Should be done running");
+ Assert.equal(process.exitValue, 0, "Check exit code");
+}
+
+add_task(async function setup() {
+ await PlacesUtils.history.clear();
+
+ insertIEHistory();
+
+ registerCleanupFunction(async () => {
+ await PlacesUtils.history.clear();
+ });
+});
+
+add_task(async function test_IE_history() {
+ let migrator = await MigrationUtils.getMigrator("ie");
+ Assert.ok(await migrator.isSourceAvailable(), "Source is available");
+
+ await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY);
+
+ for (let { url, title } of TEST_ENTRIES) {
+ let entry = await PlacesUtils.history.fetch(url, { includeVisits: true });
+ Assert.equal(entry.url, url, "Should have the correct URL");
+ Assert.equal(entry.title, title, "Should have the correct title");
+ Assert.ok(!!entry.visits.length, "Should have some visits");
+ }
+});
diff --git a/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js
new file mode 100644
index 0000000000..5038f33f2b
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js
@@ -0,0 +1,27 @@
+"use strict";
+
+ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm");
+
+let tmpFile = FileUtils.getDir("TmpD", [], true);
+let dbConn;
+
+add_task(async function setup() {
+ tmpFile.append("TestDB");
+ dbConn = await Sqlite.openConnection({ path: tmpFile.path });
+
+ registerCleanupFunction(() => {
+ dbConn.close();
+ OS.File.remove(tmpFile.path);
+ });
+});
+
+add_task(async function testgetRowsFromDBWithoutLocksRetries() {
+ let promise = MigrationUtils.getRowsFromDBWithoutLocks(
+ tmpFile.path,
+ "Temp DB",
+ "SELECT * FROM moz_temp_table"
+ );
+ await new Promise(resolve => do_timeout(50, resolve));
+ dbConn.execute("CREATE TABLE moz_temp_table (id INTEGER PRIMARY KEY)");
+ await promise;
+});
diff --git a/browser/components/migration/tests/unit/test_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js
new file mode 100644
index 0000000000..79cbcf9203
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js
@@ -0,0 +1,105 @@
+"use strict";
+
+const { CustomizableUI } = ChromeUtils.import(
+ "resource:///modules/CustomizableUI.jsm"
+);
+
+add_task(async function() {
+ registerFakePath("ULibDir", do_get_file("Library/"));
+
+ let migrator = await MigrationUtils.getMigrator("safari");
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ // Wait for the imported bookmarks. We don't check that "From Safari"
+ // folders are created on the toolbar since the profile
+ // we're importing to has less than 3 bookmarks in the destination
+ // so a "From Safari" folder isn't created.
+ let expectedParents = [PlacesUtils.toolbarFolderId];
+ let bookmarkRoots = 0;
+ let bookmarkRootMap = {
+ [PlacesUtils.bookmarks.toolbarGuid]:
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR,
+ [PlacesUtils.bookmarks.menuGuid]:
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU,
+ [PlacesUtils.bookmarks.unfiledGuid]:
+ MigrationUtils.SOURCE_BOOKMARK_ROOTS_UNFILED,
+ };
+ let itemCount = 0;
+
+ let gotFolder = false;
+ let listener = events => {
+ for (let event of events) {
+ itemCount++;
+ if (
+ event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK &&
+ bookmarkRootMap[event.parentGuid]
+ ) {
+ bookmarkRoots |= bookmarkRootMap[event.parentGuid];
+ }
+
+ if (
+ event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER &&
+ event.title == "Stuff"
+ ) {
+ gotFolder = true;
+ }
+ if (expectedParents.length) {
+ let index = expectedParents.indexOf(event.parentId);
+ Assert.ok(index != -1, "Found expected parent");
+ expectedParents.splice(index, 1);
+ }
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+ let observerNotified = false;
+ Services.obs.addObserver((aSubject, aTopic, aData) => {
+ let [toolbar, visibility] = JSON.parse(aData);
+ Assert.equal(
+ toolbar,
+ CustomizableUI.AREA_BOOKMARKS,
+ "Notification should be received for bookmarks toolbar"
+ );
+ Assert.equal(
+ visibility,
+ "true",
+ "Notification should say to reveal the bookmarks toolbar"
+ );
+ observerNotified = true;
+ }, "browser-set-toolbar-visibility");
+
+ await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS);
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+
+ // Check the bookmarks have been imported to all the expected parents.
+ Assert.ok(!expectedParents.length, "No more expected parents");
+ Assert.ok(gotFolder, "Should have seen the folder get imported");
+ Assert.equal(itemCount, 13, "Should import all 13 items.");
+ // Check that the telemetry matches:
+ Assert.equal(
+ MigrationUtils._importQuantities.bookmarks,
+ itemCount,
+ "Telemetry reporting correct."
+ );
+ let telemetryRootsMatchesExpectations = await TestUtils.waitForCondition(
+ () => {
+ let snapshot = Services.telemetry.getSnapshotForKeyedHistograms(
+ "main",
+ false
+ ).parent.FX_MIGRATION_BOOKMARKS_ROOTS;
+ if (!snapshot || !snapshot.safari) {
+ return false;
+ }
+ let sum = arr => Object.values(arr).reduce((a, b) => a + b, 0);
+ let sumOfValues = sum(snapshot.safari.values);
+ info(`Expected ${bookmarkRoots}, got ${sumOfValues}`);
+ return sumOfValues == bookmarkRoots;
+ },
+ "Wait until telemetry is updated"
+ );
+ ok(
+ telemetryRootsMatchesExpectations,
+ "The value in the roots histogram should match expectations"
+ );
+ Assert.ok(observerNotified, "The observer should be notified upon migration");
+});
diff --git a/browser/components/migration/tests/unit/test_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js
new file mode 100644
index 0000000000..0cdb779fbc
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_fx_telemetry.js
@@ -0,0 +1,265 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function readFile(file) {
+ let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(
+ Ci.nsIFileInputStream
+ );
+ stream.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF);
+
+ let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sis.init(stream);
+ let contents = sis.read(file.fileSize);
+ sis.close();
+ return contents;
+}
+
+function checkDirectoryContains(dir, files) {
+ print("checking " + dir.path + " - should contain " + Object.keys(files));
+ let seen = new Set();
+ for (let file of dir.directoryEntries) {
+ print("found file: " + file.path);
+ Assert.ok(file.leafName in files, file.leafName + " exists, but shouldn't");
+
+ let expectedContents = files[file.leafName];
+ if (typeof expectedContents != "string") {
+ // it's a subdir - recurse!
+ Assert.ok(file.isDirectory(), "should be a subdir");
+ let newDir = dir.clone();
+ newDir.append(file.leafName);
+ checkDirectoryContains(newDir, expectedContents);
+ } else {
+ Assert.ok(!file.isDirectory(), "should be a regular file");
+ let contents = readFile(file);
+ Assert.equal(contents, expectedContents);
+ }
+ seen.add(file.leafName);
+ }
+ let missing = [];
+ for (let x in files) {
+ if (!seen.has(x)) {
+ missing.push(x);
+ }
+ }
+ Assert.deepEqual(missing, [], "no missing files in " + dir.path);
+}
+
+function getTestDirs() {
+ // we make a directory structure in a temp dir which mirrors what we are
+ // testing.
+ let tempDir = do_get_tempdir();
+ let srcDir = tempDir.clone();
+ srcDir.append("test_source_dir");
+ srcDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ let targetDir = tempDir.clone();
+ targetDir.append("test_target_dir");
+ targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+ // no need to cleanup these dirs - the xpcshell harness will do it for us.
+ return [srcDir, targetDir];
+}
+
+function writeToFile(dir, leafName, contents) {
+ let file = dir.clone();
+ file.append(leafName);
+
+ let outputStream = FileUtils.openFileOutputStream(file);
+ outputStream.write(contents, contents.length);
+ outputStream.close();
+}
+
+function createSubDir(dir, subDirName) {
+ let subDir = dir.clone();
+ subDir.append(subDirName);
+ subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+ return subDir;
+}
+
+function promiseMigrator(name, srcDir, targetDir) {
+ let migrator = Cc[
+ "@mozilla.org/profile/migrator;1?app=browser&type=firefox"
+ ].createInstance(Ci.nsISupports).wrappedJSObject;
+ let migrators = migrator._getResourcesInternal(srcDir, targetDir);
+ for (let m of migrators) {
+ if (m.name == name) {
+ return new Promise(resolve => m.migrate(resolve));
+ }
+ }
+ throw new Error("failed to find the " + name + " migrator");
+}
+
+function promiseTelemetryMigrator(srcDir, targetDir) {
+ return promiseMigrator("telemetry", srcDir, targetDir);
+}
+
+add_task(async function test_empty() {
+ let [srcDir, targetDir] = getTestDirs();
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true with empty directories");
+ // check both are empty
+ checkDirectoryContains(srcDir, {});
+ checkDirectoryContains(targetDir, {});
+});
+
+add_task(async function test_migrate_files() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Set up datareporting files, some to copy, some not.
+ let stateContent = JSON.stringify({
+ clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c",
+ });
+ let sessionStateContent = "foobar 5432";
+ let subDir = createSubDir(srcDir, "datareporting");
+ writeToFile(subDir, "state.json", stateContent);
+ writeToFile(subDir, "session-state.json", sessionStateContent);
+ writeToFile(subDir, "other.file", "do not copy");
+
+ let archived = createSubDir(subDir, "archived");
+ writeToFile(archived, "other.file", "do not copy");
+
+ // Set up FHR files, they should not be copied.
+ writeToFile(srcDir, "healthreport.sqlite", "do not copy");
+ writeToFile(srcDir, "healthreport.sqlite-wal", "do not copy");
+ subDir = createSubDir(srcDir, "healthreport");
+ writeToFile(subDir, "state.json", "do not copy");
+ writeToFile(subDir, "other.file", "do not copy");
+
+ // Perform migration.
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(
+ ok,
+ "callback should have been true with important telemetry files copied"
+ );
+
+ checkDirectoryContains(targetDir, {
+ datareporting: {
+ "state.json": stateContent,
+ "session-state.json": sessionStateContent,
+ },
+ });
+});
+
+add_task(async function test_datareporting_not_dir() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ writeToFile(srcDir, "datareporting", "I'm a file but should be a directory");
+
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(
+ ok,
+ "callback should have been true even though the directory was a file"
+ );
+
+ checkDirectoryContains(targetDir, {});
+});
+
+add_task(async function test_datareporting_empty() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Migrate with an empty 'datareporting' subdir.
+ createSubDir(srcDir, "datareporting");
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+
+ // We should end up with no migrated files.
+ checkDirectoryContains(targetDir, {
+ datareporting: {},
+ });
+});
+
+add_task(async function test_healthreport_empty() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Migrate with no 'datareporting' and an empty 'healthreport' subdir.
+ createSubDir(srcDir, "healthreport");
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+
+ // We should end up with no migrated files.
+ checkDirectoryContains(targetDir, {});
+});
+
+add_task(async function test_datareporting_many() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Create some datareporting files.
+ let subDir = createSubDir(srcDir, "datareporting");
+ let shouldBeCopied = "should be copied";
+ writeToFile(subDir, "state.json", shouldBeCopied);
+ writeToFile(subDir, "session-state.json", shouldBeCopied);
+ writeToFile(subDir, "something.else", "should not");
+ createSubDir(subDir, "emptyDir");
+
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+
+ checkDirectoryContains(targetDir, {
+ datareporting: {
+ "state.json": shouldBeCopied,
+ "session-state.json": shouldBeCopied,
+ },
+ });
+});
+
+add_task(async function test_no_session_state() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Check that migration still works properly if we only have state.json.
+ let subDir = createSubDir(srcDir, "datareporting");
+ let stateContent = "abcd984";
+ writeToFile(subDir, "state.json", stateContent);
+
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+
+ checkDirectoryContains(targetDir, {
+ datareporting: {
+ "state.json": stateContent,
+ },
+ });
+});
+
+add_task(async function test_no_state() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // Check that migration still works properly if we only have session-state.json.
+ let subDir = createSubDir(srcDir, "datareporting");
+ let sessionStateContent = "abcd512";
+ writeToFile(subDir, "session-state.json", sessionStateContent);
+
+ let ok = await promiseTelemetryMigrator(srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+
+ checkDirectoryContains(targetDir, {
+ datareporting: {
+ "session-state.json": sessionStateContent,
+ },
+ });
+});
+
+add_task(async function test_times_migration() {
+ let [srcDir, targetDir] = getTestDirs();
+
+ // create a times.json in the source directory.
+ let contents = JSON.stringify({ created: 1234 });
+ writeToFile(srcDir, "times.json", contents);
+
+ let earliest = Date.now();
+ let ok = await promiseMigrator("times", srcDir, targetDir);
+ Assert.ok(ok, "callback should have been true");
+ let latest = Date.now();
+
+ let timesFile = targetDir.clone();
+ timesFile.append("times.json");
+
+ let raw = readFile(timesFile);
+ let times = JSON.parse(raw);
+ Assert.ok(times.reset >= earliest && times.reset <= latest);
+ // and it should have left the creation time alone.
+ Assert.equal(times.created, 1234);
+});
diff --git a/browser/components/migration/tests/unit/xpcshell.ini b/browser/components/migration/tests/unit/xpcshell.ini
new file mode 100644
index 0000000000..0e25e6bc94
--- /dev/null
+++ b/browser/components/migration/tests/unit/xpcshell.ini
@@ -0,0 +1,40 @@
+[DEFAULT]
+head = head_migration.js
+firefox-appdir = browser
+skip-if = toolkit == 'android'
+prefs =
+ browser.migrate.showBookmarksToolbarAfterMigration=true
+support-files =
+ Library/**
+ AppData/**
+
+[test_360se_bookmarks.js]
+skip-if = os != "win"
+[test_Chrome_bookmarks.js]
+[test_Chrome_cookies.js]
+skip-if = os != "mac" # Relies on ULibDir
+[test_Chrome_history.js]
+skip-if = os != "mac" # Relies on ULibDir
+[test_Chrome_passwords.js]
+skip-if = os != "win" && os != "mac"
+[test_Chrome_passwords_emptySource.js]
+skip-if = os != "win" && os != "mac"
+support-files =
+ LibraryWithNoData/**
+[test_ChromeMigrationUtils.js]
+[test_ChromeMigrationUtils_path.js]
+[test_Edge_db_migration.js]
+skip-if = os != "win"
+[test_fx_telemetry.js]
+[test_IE_bookmarks.js]
+skip-if = !(os == "win" && bits == 64) # bug 1392396
+[test_IE_cookies.js]
+skip-if = os != "win" || (os == "win" && bits == 64 && processor == "x86_64") # bug 1522818
+[test_IE_history.js]
+skip-if = os != "win"
+[test_IE7_passwords.js]
+skip-if = os != "win"
+[test_MigrationUtils_timedRetry.js]
+skip-if = !debug && os == "mac" #Bug 1558330
+[test_Safari_bookmarks.js]
+skip-if = os != "mac"