summaryrefslogtreecommitdiffstats
path: root/browser/components/migration
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/migration')
-rw-r--r--browser/components/migration/.eslintrc.js43
-rw-r--r--browser/components/migration/360seMigrationUtils.sys.mjs191
-rw-r--r--browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs185
-rw-r--r--browser/components/migration/ChromeMigrationUtils.sys.mjs481
-rw-r--r--browser/components/migration/ChromeProfileMigrator.sys.mjs768
-rw-r--r--browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs178
-rw-r--r--browser/components/migration/ESEDBReader.sys.mjs799
-rw-r--r--browser/components/migration/EdgeProfileMigrator.sys.mjs547
-rw-r--r--browser/components/migration/FirefoxProfileMigrator.sys.mjs362
-rw-r--r--browser/components/migration/IEProfileMigrator.sys.mjs402
-rw-r--r--browser/components/migration/MSMigrationUtils.sys.mjs1017
-rw-r--r--browser/components/migration/MigrationUtils.sys.mjs958
-rw-r--r--browser/components/migration/MigrationWizardChild.sys.mjs57
-rw-r--r--browser/components/migration/MigrationWizardParent.sys.mjs60
-rw-r--r--browser/components/migration/MigratorBase.sys.mjs493
-rw-r--r--browser/components/migration/ProfileMigrator.sys.mjs15
-rw-r--r--browser/components/migration/SafariProfileMigrator.sys.mjs467
-rw-r--r--browser/components/migration/components.conf37
-rw-r--r--browser/components/migration/content/aboutWelcomeBack.xhtml76
-rw-r--r--browser/components/migration/content/migration-dialog.html18
-rw-r--r--browser/components/migration/content/migration-wizard-constants.mjs18
-rw-r--r--browser/components/migration/content/migration-wizard.mjs146
-rw-r--r--browser/components/migration/content/migration.js635
-rw-r--r--browser/components/migration/content/migration.xhtml114
-rw-r--r--browser/components/migration/docs/index.rst16
-rw-r--r--browser/components/migration/docs/migration-utils.rst5
-rw-r--r--browser/components/migration/docs/migration-wizard-architecture-diagram.svg128
-rw-r--r--browser/components/migration/docs/migration-wizard.rst66
-rw-r--r--browser/components/migration/docs/migrators.rst88
-rw-r--r--browser/components/migration/jar.mn11
-rw-r--r--browser/components/migration/moz.build81
-rw-r--r--browser/components/migration/nsEdgeMigrationUtils.cpp61
-rw-r--r--browser/components/migration/nsEdgeMigrationUtils.h24
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.cpp116
-rw-r--r--browser/components/migration/nsIEHistoryEnumerator.h39
-rw-r--r--browser/components/migration/nsIEdgeMigrationUtils.idl23
-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/chrome/chrome.ini4
-rw-r--r--browser/components/migration/tests/chrome/test_migration_wizard.html33
-rw-r--r--browser/components/migration/tests/marionette/manifest.ini4
-rw-r--r--browser/components/migration/tests/marionette/test_refresh_firefox.py688
-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/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.datbin0 -> 6144 bytes
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat1
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks3
-rw-r--r--browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State12
-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.js124
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp38
-rw-r--r--browser/components/migration/tests/unit/insertIEHistory/moz.build19
-rw-r--r--browser/components/migration/tests/unit/test_360seMigrationUtils.js169
-rw-r--r--browser/components/migration/tests/unit/test_360se_bookmarks.js62
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils.js84
-rw-r--r--browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js113
-rw-r--r--browser/components/migration/tests/unit/test_Chrome_bookmarks.js218
-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.js773
-rw-r--r--browser/components/migration/tests/unit/test_IE7_passwords.js1371
-rw-r--r--browser/components/migration/tests/unit/test_IE_bookmarks.js30
-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.js25
-rw-r--r--browser/components/migration/tests/unit/test_Safari_bookmarks.js69
-rw-r--r--browser/components/migration/tests/unit/test_fx_telemetry.js269
-rw-r--r--browser/components/migration/tests/unit/xpcshell.ini49
90 files changed, 14051 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js
new file mode 100644
index 0000000000..2ffca83012
--- /dev/null
+++ b/browser/components/migration/.eslintrc.js
@@ -0,0 +1,43 @@
+/* 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 = {
+ extends: ["plugin:mozilla/require-jsdoc", "plugin:mozilla/valid-jsdoc"],
+ 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/360seMigrationUtils.sys.mjs b/browser/components/migration/360seMigrationUtils.sys.mjs
new file mode 100644
index 0000000000..7c1ecbdb0f
--- /dev/null
+++ b/browser/components/migration/360seMigrationUtils.sys.mjs
@@ -0,0 +1,191 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+XPCOMUtils.defineLazyGetter(
+ lazy,
+ "filenamesRegex",
+ () => /^360(?:default_ori|sefav)_([0-9_]+)\.favdb$/i
+);
+
+const kBookmarksFileName = "360sefav.dat";
+
+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 lazy.Sqlite.openConnection({
+ path: this._file.path,
+ });
+
+ 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: lazy.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) {
+ let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid;
+ await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid);
+ }
+ })().then(
+ () => aCallback(true),
+ e => {
+ Cu.reportError(e);
+ aCallback(false);
+ }
+ );
+ },
+};
+
+export var Qihoo360seMigrationUtils = {
+ async getAlternativeBookmarks({ bookmarksPath, localState }) {
+ let lastModificationDate = new Date(0);
+ let path = bookmarksPath;
+ let profileFolder = PathUtils.parent(bookmarksPath);
+
+ if (await IOUtils.exists(bookmarksPath)) {
+ try {
+ let { lastModified } = await IOUtils.stat(bookmarksPath);
+ lastModificationDate = new Date(lastModified);
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+
+ // Somewhat similar to source profiles, but for bookmarks only
+ let subDir =
+ (localState.sync_login_info && localState.sync_login_info.filepath) || "";
+
+ if (subDir) {
+ let legacyBookmarksPath = PathUtils.join(
+ profileFolder,
+ subDir,
+ kBookmarksFileName
+ );
+ if (await IOUtils.exists(legacyBookmarksPath)) {
+ try {
+ let { lastModified } = await IOUtils.stat(legacyBookmarksPath);
+ lastModificationDate = new Date(lastModified);
+ path = legacyBookmarksPath;
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+ }
+ }
+
+ let dailyBackupPath = PathUtils.join(profileFolder, subDir, "DailyBackup");
+ for (const entry of await IOUtils.getChildren(dailyBackupPath, {
+ ignoreAbsent: true,
+ })) {
+ let filename = PathUtils.filename(entry);
+ let matches = lazy.filenamesRegex.exec(filename);
+ if (!matches) {
+ continue;
+ }
+
+ let entryDate = new Date(matches[1].replace(/_/g, "-"));
+ if (entryDate < lastModificationDate) {
+ continue;
+ }
+
+ lastModificationDate = entryDate;
+ path = entry;
+ }
+
+ if (PathUtils.filename(path) === kBookmarksFileName) {
+ let resource = this.getLegacyBookmarksResource(PathUtils.parent(path));
+ return { resource };
+ }
+ return { path };
+ },
+
+ getLegacyBookmarksResource(aParentFolder) {
+ let parentFolder;
+ try {
+ parentFolder = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ parentFolder.initWithPath(aParentFolder);
+ } catch (ex) {
+ Cu.reportError(ex);
+ return null;
+ }
+
+ let bookmarks = new Bookmarks(parentFolder);
+ return bookmarks.exists ? bookmarks : null;
+ },
+};
diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs
new file mode 100644
index 0000000000..c07376eb5d
--- /dev/null
+++ b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs
@@ -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/. */
+
+/**
+ * Class to handle encryption and decryption of logins stored in Chrome/Chromium
+ * on macOS.
+ */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "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.
+ */
+export 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 = lazy.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.sys.mjs b/browser/components/migration/ChromeMigrationUtils.sys.mjs
new file mode 100644
index 0000000000..055dc80452
--- /dev/null
+++ b/browser/components/migration/ChromeMigrationUtils.sys.mjs
@@ -0,0 +1,481 @@
+/* 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/. */
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ LoginHelper: "resource://gre/modules/LoginHelper.jsm",
+});
+
+const S100NS_FROM1601TO1970 = 0x19db1ded53e8000;
+const S100NS_PER_MS = 10;
+
+export 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 extensionList = [];
+ try {
+ for (const child of await IOUtils.getChildren(path)) {
+ const info = await IOUtils.stat(child);
+ if (info.type === "directory") {
+ const name = PathUtils.filename(child);
+ let extensionInformation = await this.getExtensionInformation(
+ 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.
+ * @returns {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 = PathUtils.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 = PathUtils.join(
+ manifestPath,
+ directories[0],
+ "manifest.json"
+ );
+ let manifest = await IOUtils.readJSON(manifestPath);
+ // 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.
+ * @returns {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 = PathUtils.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 = PathUtils.join(
+ localeFilePath,
+ directories[0],
+ "_locales",
+ locale,
+ "messages.json"
+ );
+ localeFile = await IOUtils.readJSON(localeFilePath);
+ 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 IOUtils.exists(
+ PathUtils.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: {
+ Brave: ["BraveSoftware", "Brave-Browser"],
+ Chrome: ["Google", "Chrome"],
+ "Chrome Beta": ["Google", "Chrome Beta"],
+ Chromium: ["Chromium"],
+ Canary: ["Google", "Chrome SxS"],
+ Edge: ["Microsoft", "Edge"],
+ "Edge Beta": ["Microsoft", "Edge Beta"],
+ "360 SE": ["360se6"],
+ Opera: ["Opera Software", "Opera Stable"],
+ "Opera GX": ["Opera Software", "Opera GX Stable"],
+ Vivaldi: ["Vivaldi"],
+ },
+ macosx: {
+ Brave: ["BraveSoftware", "Brave-Browser"],
+ Chrome: ["Google", "Chrome"],
+ Chromium: ["Chromium"],
+ Canary: ["Google", "Chrome Canary"],
+ Edge: ["Microsoft Edge"],
+ "Edge Beta": ["Microsoft Edge Beta"],
+ "Opera GX": ["com.operasoftware.OperaGX"],
+ Opera: ["com.operasoftware.Opera"],
+ Vivaldi: ["Vivaldi"],
+ },
+ linux: {
+ Brave: ["BraveSoftware", "Brave-Browser"],
+ Chrome: ["google-chrome"],
+ "Chrome Beta": ["google-chrome-beta"],
+ "Chrome Dev": ["google-chrome-unstable"],
+ Chromium: ["chromium"],
+ // Opera GX is not available on Linux.
+ // Canary is not available on Linux.
+ // Edge is not available on Linux.
+ Opera: ["opera"],
+ Vivaldi: ["vivaldi"],
+ },
+ };
+ let subfolders = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName];
+ if (!subfolders) {
+ return null;
+ }
+
+ let rootDir;
+ if (AppConstants.platform == "win") {
+ if (
+ chromeProjectName === "360 SE" ||
+ chromeProjectName === "Opera" ||
+ chromeProjectName === "Opera GX"
+ ) {
+ rootDir = "AppData";
+ } else {
+ rootDir = "LocalAppData";
+ }
+ if (chromeProjectName != "Opera" && chromeProjectName != "Opera GX") {
+ 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 entries = [];
+ try {
+ for (const child of await IOUtils.getChildren(path)) {
+ const info = await IOUtils.stat(child);
+ if (info.type === "directory") {
+ const name = PathUtils.filename(child);
+ entries.push(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. Google Chrome uses FILETIME / 10 as time.
+ * FILETIME is based on the same structure of Windows.
+ *
+ * @param {number} aTime Chrome time
+ * @param {string|number|Date} aFallbackValue a date or timestamp (valid argument
+ * for the Date constructor) that will be used if the chrometime value passed is
+ * invalid.
+ * @returns {Date} converted Date object
+ */
+ 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. For details on Chrome time, see
+ * chromeTimeToDate.
+ *
+ * @param {Date|number} aDate Date object or integer equivalent
+ * @returns {number} Chrome time
+ */
+ 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 lazy.MigrationUtils.getMigrator(browserId);
+ if (!migrator) {
+ continue;
+ }
+
+ // Check each profile for logins.
+ const dataPath = await migrator._getChromeUserDataPathIfExists();
+ for (const profile of await migrator.getSourceProfiles()) {
+ const path = PathUtils.join(dataPath, profile.id, "Login Data");
+ // Skip if login data is missing.
+ if (!(await IOUtils.exists(path))) {
+ Cu.reportError(`Missing file at ${path}`);
+ continue;
+ }
+
+ try {
+ for (const row of await lazy.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 = lazy.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.sys.mjs b/browser/components/migration/ChromeProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..748515a71d
--- /dev/null
+++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs
@@ -0,0 +1,768 @@
+/* -*- 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/. */
+
+const AUTH_TYPE = {
+ SCHEME_HTML: 0,
+ SCHEME_BASIC: 1,
+ SCHEME_DIGEST: 2,
+};
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Qihoo360seMigrationUtils: "resource:///modules/360seMigrationUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+});
+
+/**
+ * Converts an array of chrome bookmark objects into one our own places code
+ * understands.
+ *
+ * @param {object[]} items Chrome Bookmark items to be inserted on this parent
+ * @param {Function} errorAccumulator function that gets called with any errors
+ * thrown so we don't drop them on the floor.
+ * @returns {object[]}
+ */
+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: lazy.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;
+}
+
+/**
+ * Chrome profile migrator. This can also be used as a parent class for
+ * migrators for browsers that are variants of Chrome.
+ */
+export class ChromeProfileMigrator extends MigratorBase {
+ static get key() {
+ return "chrome";
+ }
+
+ get _chromeUserDataPathSuffix() {
+ return "Chrome";
+ }
+
+ _keychainServiceName = "Chrome Safe Storage";
+
+ _keychainAccountName = "Chrome";
+
+ async _getChromeUserDataPathIfExists() {
+ if (this._chromeUserDataPath) {
+ return this._chromeUserDataPath;
+ }
+ let path = lazy.ChromeMigrationUtils.getDataPath(
+ this._chromeUserDataPathSuffix
+ );
+ let exists = await IOUtils.exists(path);
+ if (exists) {
+ this._chromeUserDataPath = path;
+ } else {
+ this._chromeUserDataPath = null;
+ }
+ return this._chromeUserDataPath;
+ }
+
+ async getResources(aProfile) {
+ let chromeUserDataPath = await this._getChromeUserDataPathIfExists();
+ if (chromeUserDataPath) {
+ let profileFolder = chromeUserDataPath;
+ if (aProfile) {
+ profileFolder = PathUtils.join(chromeUserDataPath, aProfile.id);
+ }
+ if (await IOUtils.exists(profileFolder)) {
+ let possibleResourcePromises = [
+ GetBookmarksResource(profileFolder, this.constructor.key),
+ GetHistoryResource(profileFolder),
+ GetCookiesResource(profileFolder),
+ ];
+ if (lazy.ChromeMigrationUtils.supportsLoginsForPlatform) {
+ possibleResourcePromises.push(
+ this._GetPasswordsResource(profileFolder)
+ );
+ }
+ let possibleResources = await Promise.all(possibleResourcePromises);
+ return possibleResources.filter(r => r != null);
+ }
+ }
+ return [];
+ }
+
+ async 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 = PathUtils.join(chromeUserDataPath, profile.id);
+ let fileDatePromises = ["Bookmarks", "History", "Cookies"].map(
+ async leafName => {
+ let path = PathUtils.join(basePath, leafName);
+ let info = await IOUtils.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));
+ }
+
+ async getSourceProfiles() {
+ if ("__sourceProfiles" in this) {
+ return this.__sourceProfiles;
+ }
+
+ let chromeUserDataPath = await this._getChromeUserDataPathIfExists();
+ if (!chromeUserDataPath) {
+ return [];
+ }
+
+ let localState;
+ let profiles = [];
+ try {
+ localState = await lazy.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;
+ }
+
+ async _GetPasswordsResource(aProfileFolder) {
+ let loginPath = PathUtils.join(aProfileFolder, "Login Data");
+ if (!(await IOUtils.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.importESModule(
+ "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs"
+ );
+ crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix);
+ } else if (AppConstants.platform == "macosx") {
+ let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule(
+ "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs"
+ );
+ 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 = lazy.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: lazy.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 = lazy.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);
+ },
+ };
+ }
+}
+
+async function GetBookmarksResource(aProfileFolder, aBrowserKey) {
+ let bookmarksPath = PathUtils.join(aProfileFolder, "Bookmarks");
+
+ if (aBrowserKey === "chromium-360se") {
+ let localState = {};
+ try {
+ localState = await lazy.ChromeMigrationUtils.getLocalState("360 SE");
+ } catch (ex) {
+ Cu.reportError(ex);
+ }
+
+ let alternativeBookmarks = await lazy.Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ { bookmarksPath, localState }
+ );
+ if (alternativeBookmarks.resource) {
+ return alternativeBookmarks.resource;
+ }
+
+ bookmarksPath = alternativeBookmarks.path;
+ }
+
+ if (!(await IOUtils.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 IOUtils.readJSON(bookmarksPath);
+ let roots = bookmarkJSON.roots;
+
+ // Importing bookmark bar items
+ if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) {
+ // Toolbar
+ let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid;
+ let bookmarks = convertBookmarks(
+ roots.bookmark_bar.children,
+ errorGatherer
+ );
+ await MigrationUtils.insertManyBookmarksWrapper(
+ bookmarks,
+ parentGuid
+ );
+ }
+
+ // Importing Other Bookmarks items
+ if (roots.other.children && roots.other.children.length) {
+ // Other Bookmarks
+ let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid;
+ let bookmarks = convertBookmarks(roots.other.children, errorGatherer);
+ await MigrationUtils.insertManyBookmarksWrapper(
+ bookmarks,
+ parentGuid
+ );
+ }
+ if (gotErrors) {
+ throw new Error("The migration included errors.");
+ }
+ })().then(
+ () => aCallback(true),
+ () => aCallback(false)
+ );
+ },
+ };
+}
+
+async function GetHistoryResource(aProfileFolder) {
+ let historyPath = PathUtils.join(aProfileFolder, "History");
+ if (!(await IOUtils.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 = lazy.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 = lazy.PlacesUtils.history.TRANSITIONS.LINK;
+ if (row.getResultByName("typed_count") > 0) {
+ transition = lazy.PlacesUtils.history.TRANSITIONS.TYPED;
+ }
+
+ pageInfos.push({
+ title: row.getResultByName("title"),
+ url: new URL(row.getResultByName("url")),
+ visits: [
+ {
+ transition,
+ date: lazy.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 = PathUtils.join(aProfileFolder, "Cookies");
+ if (!(await IOUtils.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 =
+ lazy.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);
+ },
+ };
+}
+
+/**
+ * Chromium migrator
+ */
+export class ChromiumProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chromium";
+ }
+
+ _chromeUserDataPathSuffix = "Chromium";
+ _keychainServiceName = "Chromium Safe Storage";
+ _keychainAccountName = "Chromium";
+}
+
+/**
+ * Chrome Canary
+ * Not available on Linux
+ */
+export class CanaryProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "canary";
+ }
+
+ get _chromeUserDataPathSuffix() {
+ return "Canary";
+ }
+
+ get _keychainServiceName() {
+ return "Chromium Safe Storage";
+ }
+
+ get _keychainAccountName() {
+ return "Chromium";
+ }
+}
+
+/**
+ * Chrome Dev - Linux only (not available in Mac and Windows)
+ */
+export class ChromeDevMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chrome-dev";
+ }
+
+ _chromeUserDataPathSuffix = "Chrome Dev";
+ _keychainServiceName = "Chromium Safe Storage";
+ _keychainAccountName = "Chromium";
+}
+
+/**
+ * Chrome Beta migrator
+ */
+export class ChromeBetaMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chrome-beta";
+ }
+
+ _chromeUserDataPathSuffix = "Chrome Beta";
+ _keychainServiceName = "Chromium Safe Storage";
+ _keychainAccountName = "Chromium";
+}
+
+/**
+ * Brave migrator
+ */
+export class BraveProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "brave";
+ }
+
+ _chromeUserDataPathSuffix = "Brave";
+ _keychainServiceName = "Brave Browser Safe Storage";
+ _keychainAccountName = "Brave Browser";
+}
+
+/**
+ * Edge (Chromium-based) migrator
+ */
+export class ChromiumEdgeMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chromium-edge";
+ }
+
+ _chromeUserDataPathSuffix = "Edge";
+ _keychainServiceName = "Microsoft Edge Safe Storage";
+ _keychainAccountName = "Microsoft Edge";
+}
+
+/**
+ * Edge Beta (Chromium-based) migrator
+ */
+export class ChromiumEdgeBetaMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chromium-edge-beta";
+ }
+
+ _chromeUserDataPathSuffix = "Edge Beta";
+ _keychainServiceName = "Microsoft Edge Safe Storage";
+ _keychainAccountName = "Microsoft Edge";
+}
+
+/**
+ * Chromium 360 migrator
+ */
+export class Chromium360seMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "chromium-360se";
+ }
+
+ _chromeUserDataPathSuffix = "360 SE";
+ _keychainServiceName = "Microsoft Edge Safe Storage";
+ _keychainAccountName = "Microsoft Edge";
+}
+
+/**
+ * Opera migrator
+ */
+export class OperaProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "opera";
+ }
+
+ _chromeUserDataPathSuffix = "Opera";
+ _keychainServiceName = "Opera Safe Storage";
+ _keychainAccountName = "Opera";
+
+ getSourceProfiles() {
+ return null;
+ }
+}
+
+/**
+ * Opera GX migrator
+ */
+export class OperaGXProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "opera-gx";
+ }
+
+ _chromeUserDataPathSuffix = "Opera GX";
+ _keychainServiceName = "Opera Safe Storage";
+ _keychainAccountName = "Opera";
+
+ getSourceProfiles() {
+ return null;
+ }
+}
+
+/**
+ * Vivaldi migrator
+ */
+export class VivaldiProfileMigrator extends ChromeProfileMigrator {
+ static get key() {
+ return "vivaldi";
+ }
+
+ _chromeUserDataPathSuffix = "Vivaldi";
+ _keychainServiceName = "Vivaldi Safe Storage";
+ _keychainAccountName = "Vivaldi";
+}
diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs
new file mode 100644
index 0000000000..7ef6175c19
--- /dev/null
+++ b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs
@@ -0,0 +1,178 @@
+/* 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/. */
+
+/**
+ * Class to handle encryption and decryption of logins stored in Chrome/Chromium
+ * on Windows.
+ */
+
+import { ChromeMigrationUtils } from "resource:///modules/ChromeMigrationUtils.sys.mjs";
+
+const { OSCrypto } = ChromeUtils.import(
+ "resource://gre/modules/OSCrypto_win.jsm"
+);
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+/**
+ * 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.
+ */
+export class ChromeWindowsLoginCrypto {
+ /**
+ * @param {string} userDataPathSuffix The unique identifier for the variant of
+ * Chrome that is having its logins imported. These are the keys in the
+ * SUB_DIRECTORIES object in ChromeMigrationUtils.getDataPath.
+ */
+ 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.sys.mjs b/browser/components/migration/ESEDBReader.sys.mjs
new file mode 100644
index 0000000000..42f4a7256d
--- /dev/null
+++ b/browser/components/migration/ESEDBReader.sys.mjs
@@ -0,0 +1,799 @@
+/* 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/. */
+
+const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm");
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+const lazy = {};
+XPCOMUtils.defineLazyGetter(lazy, "log", () => {
+ let { ConsoleAPI } = ChromeUtils.importESModule(
+ "resource://gre/modules/Console.sys.mjs"
+ );
+ let consoleOptions = {
+ maxLogLevelPref: "browser.esedbreader.loglevel",
+ prefix: "ESEDBReader",
+ };
+ return new ConsoleAPI(consoleOptions);
+});
+
+// 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:
+export 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
+export 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:
+export 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
+export let gLibs = {};
+
+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) {
+ lazy.log.error("Error calling into ctypes method", methodName, ex);
+ throw ex;
+ }
+ let resultCode = parseInt(rv.toString(10), 10);
+ if (resultCode < 0) {
+ if (errorLog) {
+ lazy.log.error("Got error " + resultCode + " calling " + methodName);
+ }
+ if (shouldThrow) {
+ throw new Error(convertESEError(rv));
+ }
+ } else if (resultCode > 0 && errorLog) {
+ lazy.log.warn("Got warning " + resultCode + " calling " + methodName);
+ }
+ return resultCode;
+ };
+}
+
+export 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() {
+ lazy.log.debug("Unloading");
+ if (gOpenDBs.size) {
+ lazy.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;
+}
+
+export 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) {
+ lazy.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) {
+ lazy.log.error("Got error " + rv + " calling OpenTableW");
+ throw new Error(convertESEError(rv));
+ }
+
+ if (rv > 0) {
+ lazy.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) {
+ lazy.log.debug("close db");
+ ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0);
+ lazy.log.debug("finished close db");
+ this._opened = false;
+ }
+ if (this._attached) {
+ lazy.log.debug("detach db");
+ ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath);
+ this._attached = false;
+ }
+ if (this._sessionCreated) {
+ lazy.log.debug("end session");
+ ESE.FailSafeEndSession(this._sessionId, 0);
+ this._sessionCreated = false;
+ }
+ if (this._instanceCreated) {
+ lazy.log.debug("term");
+ ESE.FailSafeTerm(this._instanceId);
+ this._instanceCreated = false;
+ }
+ },
+
+ incrementReferenceCounter() {
+ this._references++;
+ },
+
+ decrementReferenceCounter() {
+ this._references--;
+ if (this._references <= 0) {
+ this._close();
+ }
+ },
+};
+
+export 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) {
+ const utils = Cc[
+ "@mozilla.org/profile/migrator/edgemigrationutils;1"
+ ].createInstance(Ci.nsIEdgeMigrationUtils);
+
+ const locked = await utils.isDbLocked(dbFile);
+
+ if (locked) {
+ Cu.reportError(`ESE DB at ${dbFile.path} is locked.`);
+ }
+
+ return locked;
+ },
+
+ closeDB(db) {
+ db.decrementReferenceCounter();
+ },
+
+ COLUMN_TYPES,
+};
diff --git a/browser/components/migration/EdgeProfileMigrator.sys.mjs b/browser/components/migration/EdgeProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..a1f10e36a2
--- /dev/null
+++ b/browser/components/migration/EdgeProfileMigrator.sys.mjs
@@ -0,0 +1,547 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
+import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs";
+
+const lazy = {};
+ChromeUtils.defineESModuleGetters(lazy, {
+ ESEDBReader: "resource:///modules/ESEDBReader.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+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(lazy, "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 = lazy.gEdgeDatabase,
+ filterFn = null
+) {
+ let database;
+ let rows = [];
+ try {
+ let logFile = dbFile.parent;
+ logFile.append("LogFiles");
+ database = lazy.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) {
+ lazy.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: lazy.PlacesUtils.history.TRANSITIONS.TYPED,
+ date: time ? lazy.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 lazy.gEdgeDatabase;
+ },
+
+ get exists() {
+ return !!this.db;
+ },
+
+ migrate(callback) {
+ this._migrateTypedURLsFromDB().then(
+ () => callback(true),
+ ex => {
+ Cu.reportError(ex);
+ callback(false);
+ }
+ );
+ },
+
+ async _migrateTypedURLsFromDB() {
+ if (await lazy.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: lazy.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 || lazy.gEdgeDatabase;
+ },
+
+ get exists() {
+ return !!this.db;
+ },
+
+ migrate(callback) {
+ this._migrateReadingList(lazy.PlacesUtils.bookmarks.menuGuid).then(
+ () => callback(true),
+ ex => {
+ Cu.reportError(ex);
+ callback(false);
+ }
+ );
+ },
+
+ async _migrateReadingList(parentGuid) {
+ if (await lazy.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 == lazy.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: lazy.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 || lazy.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 lazy.ESEDBReader.dbLocked(this.db)) {
+ throw new Error("Edge seems to be running - its database is locked.");
+ }
+ let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB();
+ if (toplevelBMs.length) {
+ let parentGuid = lazy.PlacesUtils.bookmarks.menuGuid;
+ await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid);
+ }
+ if (toolbarBMs.length) {
+ let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid;
+ await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid);
+ }
+ },
+
+ _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: lazy.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 };
+ },
+};
+
+/**
+ * Edge (EdgeHTML) profile migrator
+ */
+export class EdgeProfileMigrator extends MigratorBase {
+ static get key() {
+ return "edge";
+ }
+
+ getBookmarksMigratorForTesting(dbOverride) {
+ return new EdgeBookmarksMigrator(dbOverride);
+ }
+
+ getReadingListMigratorForTesting(dbOverride) {
+ return new EdgeReadingListMigrator(dbOverride);
+ }
+
+ getResources() {
+ 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);
+ }
+
+ async getLastUsedDate() {
+ // 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 || !lazy.gEdgeDatabase) {
+ return Promise.resolve(new Date(0));
+ }
+ let logFilePath = PathUtils.join(
+ lazy.gEdgeDatabase.parent.path,
+ "LogFiles",
+ "edb.log"
+ );
+ let dbPath = lazy.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 IOUtils.stat(path)
+ .then(info => info.lastModified)
+ .catch(() => 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));
+ });
+ }
+
+ /**
+ * @returns {Array|null}
+ * 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.sys.mjs for slightly more info on how sourceProfiles is used.
+ */
+ getSourceProfiles() {
+ let isWin10OrHigher = AppConstants.isPlatformAndVersionAtLeast("win", "10");
+ return isWin10OrHigher ? null : [];
+ }
+}
diff --git a/browser/components/migration/FirefoxProfileMigrator.sys.mjs b/browser/components/migration/FirefoxProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..71e8214849
--- /dev/null
+++ b/browser/components/migration/FirefoxProfileMigrator.sys.mjs
@@ -0,0 +1,362 @@
+/* -*- 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/. */
+
+/*
+ * 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.
+ */
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+
+import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs",
+ ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs",
+ SessionMigration: "resource:///modules/sessionstore/SessionMigration.sys.mjs",
+});
+
+/**
+ * Firefox profile migrator. Currently, this class only does "pave over"
+ * migrations, where various parts of an old profile overwrite a new
+ * profile. This is distinct from other migrators which attempt to import
+ * old profile data into the existing profile.
+ *
+ * This migrator is what powers the "Profile Refresh" mechanism.
+ */
+export class FirefoxProfileMigrator extends MigratorBase {
+ static get key() {
+ return "firefox";
+ }
+
+ _getAllProfiles() {
+ 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;
+ }
+
+ getSourceProfiles() {
+ let sorter = (a, b) => {
+ return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase());
+ };
+
+ return [...this._getAllProfiles().keys()]
+ .map(x => ({ id: x, name: x }))
+ .sort(sorter);
+ }
+
+ _getFileObject(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;
+ }
+
+ getResources(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);
+ }
+
+ getLastUsedDate() {
+ // 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));
+ }
+
+ _getResourcesInternal(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, [
+ lazy.PlacesBackups.profileRelativeFolderPath,
+ ]);
+ let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]);
+
+ let session;
+ if (Services.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.
+ Services.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 = lazy.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 = PathUtils.join(
+ sourceProfileDir.path,
+ "signedInUser.json"
+ );
+ let exists = await IOUtils.exists(oldPath);
+ if (exists) {
+ let data = await IOUtils.readJSON(oldPath);
+ if (data && data.accountData && data.accountData.email) {
+ let username = data.accountData.email;
+ // copy the file itself.
+ await IOUtils.copy(
+ oldPath,
+ PathUtils.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 = PathUtils.join(sourceProfileDir.path, "prefs.js");
+ if (await IOUtils.exists(oldPath)) {
+ let rawPrefs = await IOUtils.readUTF8(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 lazy.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, lazy.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);
+ }
+
+ get startupOnlyMigrator() {
+ return true;
+ }
+}
diff --git a/browser/components/migration/IEProfileMigrator.sys.mjs b/browser/components/migration/IEProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..6f039adae1
--- /dev/null
+++ b/browser/components/migration/IEProfileMigrator.sys.mjs
@@ -0,0 +1,402 @@
+/* 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/. */
+
+const kLoginsKey =
+ "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2";
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
+import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "ctypes",
+ "resource://gre/modules/ctypes.jsm"
+);
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "OSCrypto",
+ "resource://gre/modules/OSCrypto_win.jsm"
+);
+
+// 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)
+ ? lazy.PlacesUtils.history.TRANSITIONS.LINK
+ : lazy.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
+ ? lazy.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 lazy.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 lazy.ctypes.StructType("loginData", [
+ // Bytes 0-3 are not needed and not documented
+ { unknown1: lazy.ctypes.uint32_t },
+ // Bytes 4-7 are the header size
+ { headerSize: lazy.ctypes.uint32_t },
+ // Bytes 8-11 are the data size
+ { dataSize: lazy.ctypes.uint32_t },
+ // Bytes 12-19 are not needed and not documented
+ { unknown2: lazy.ctypes.uint32_t },
+ { unknown3: lazy.ctypes.uint32_t },
+ // Bytes 20-23 are the data count: each username and password is considered as a data
+ { dataMax: lazy.ctypes.uint32_t },
+ // Bytes 24-35 are not needed and not documented
+ { unknown4: lazy.ctypes.uint32_t },
+ { unknown5: lazy.ctypes.uint32_t },
+ { unknown6: lazy.ctypes.uint32_t },
+ ]);
+
+ // the structure of a IE7 decrypted login item
+ let loginItem = new lazy.ctypes.StructType("loginItem", [
+ // Bytes 0-3 are the offset of the username
+ { usernameOffset: lazy.ctypes.uint32_t },
+ // Bytes 4-11 are the date
+ { loDateTime: lazy.ctypes.uint32_t },
+ { hiDateTime: lazy.ctypes.uint32_t },
+ // Bytes 12-15 are not needed and not documented
+ { foo: lazy.ctypes.uint32_t },
+ // Bytes 16-19 are the offset of the password
+ { passwordOffset: lazy.ctypes.uint32_t },
+ // Bytes 20-31 are not needed and not documented
+ { unknown1: lazy.ctypes.uint32_t },
+ { unknown2: lazy.ctypes.uint32_t },
+ { unknown3: lazy.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 = lazy.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 = lazy.ctypes.cast(cdata, loginData);
+ let headerSize = currentLoginData.headerSize;
+ let currentInfoIndex = loginData.size;
+ // pointer to the current login item
+ let currentLoginItemPointer = lazy.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 = lazy.ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.usernameOffset
+ ),
+ lazy.ctypes.char16_t.ptr
+ )
+ .readString();
+ // The password is UTF-16 and null-terminated.
+ currentResult.password = lazy.ctypes
+ .cast(
+ cdata.addressOfElement(
+ headerSize + 12 + currentLoginItem.passwordOffset
+ ),
+ lazy.ctypes.char16_t.ptr
+ )
+ .readString();
+ results.push(currentResult);
+ // move to the next login item
+ currentLoginItemPointer = currentLoginItemPointer.increment();
+ }
+ return results;
+ },
+};
+
+/**
+ * Internet Explorer profile migrator
+ */
+export class IEProfileMigrator extends MigratorBase {
+ static get key() {
+ return "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);
+ }
+
+ 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));
+ });
+ }
+}
diff --git a/browser/components/migration/MSMigrationUtils.sys.mjs b/browser/components/migration/MSMigrationUtils.sys.mjs
new file mode 100644
index 0000000000..ce27cfb04e
--- /dev/null
+++ b/browser/components/migration/MSMigrationUtils.sys.mjs
@@ -0,0 +1,1017 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs",
+});
+const { ctypes } = ChromeUtils.import("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,
+];
+
+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 {number} aTimeHi
+ * Least significant DWORD.
+ * @param {number} aTimeLo
+ * Most significant DWORD.
+ * @returns {number} 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 {string} aHost
+ * The host to check.
+ * @returns {boolean} 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 = lazy.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;
+ },
+
+ migrate: function B_migrate(aCallback) {
+ return (async () => {
+ // Import to the bookmarks menu.
+ let folderGuid = lazy.PlacesUtils.bookmarks.menuGuid;
+ await this._migrateFolder(this._favoritesFolder, folderGuid);
+ })().then(
+ () => aCallback(true),
+ e => {
+ Cu.reportError(e);
+ aCallback(false);
+ }
+ );
+ },
+
+ async _migrateFolder(aSourceFolder, aDestFolderGuid) {
+ let { bookmarks, favicons } = await this._getBookmarksInFolder(
+ aSourceFolder
+ );
+ if (!bookmarks.length) {
+ return;
+ }
+
+ await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid);
+ MigrationUtils.insertManyFavicons(favicons);
+ },
+
+ /**
+ * Iterates through a bookmark folder to obtain whatever information from each bookmark is needed elsewhere. This function also recurses into child folders.
+ *
+ * @param {nsIFile} aSourceFolder the folder to search for bookmarks and subfolders.
+ * @returns {Promise<object>} An object with the following properties:
+ * {Object[]} bookmarks:
+ * An array of Objects with these properties:
+ * {number} type: A type mapping to one of the types in nsINavBookmarksService
+ * {string} title: The title of the bookmark
+ * {Object[]} children: An array of objects with the same structure as this one.
+ *
+ * {Object[]} favicons
+ * An array of Objects with these properties:
+ * {Uint8Array} faviconData: The binary data of a favicon
+ * {nsIURI} uri: The URI of the associated bookmark
+ */
+ 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 = [];
+ let favicons = [];
+ 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 = lazy.PlacesUtils.bookmarks.toolbarGuid;
+ await this._migrateFolder(entry, folderGuid);
+ } else if (entry.isReadable()) {
+ let {
+ bookmarks: childBookmarks,
+ favicons: childFavicons,
+ } = await this._getBookmarksInFolder(entry);
+ favicons = favicons.concat(childFavicons);
+ rv.push({
+ type: lazy.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);
+ // Silently failing in the event that the alternative data stream for the favicon doesn't exist
+ try {
+ let faviconData = await IOUtils.read(entry.path + ":favicon");
+ favicons.push({ faviconData, uri });
+ } catch {}
+
+ rv.push({ url: uri, title: matches[1] });
+ }
+ }
+ } catch (ex) {
+ Cu.reportError(
+ "Unable to import " +
+ this.importedAppLabel +
+ " favorite (" +
+ entry.leafName +
+ "): " +
+ ex
+ );
+ }
+ }
+ return { bookmarks: rv, favicons };
+ },
+};
+
+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.
+ *
+ * All the times are in FILETIME format.
+ *
+ * @param {string} aTextBuffer The text buffer to be parsed.
+ */
+ _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.
+ * @returns {boolean} 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;
+ },
+};
+
+export 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.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs
new file mode 100644
index 0000000000..4193e6d840
--- /dev/null
+++ b/browser/components/migration/MigrationUtils.sys.mjs
@@ -0,0 +1,958 @@
+/* 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/. */
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+ setTimeout: "resource://gre/modules/Timer.sys.mjs",
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "LoginHelper",
+ "resource://gre/modules/LoginHelper.jsm"
+);
+
+var gMigrators = null;
+var gProfileStartup = null;
+var gL10n = null;
+var gPreviousDefaultBrowserKey = "";
+
+let gForceExitSpinResolve = false;
+let gKeepUndoData = false;
+let gUndoData = null;
+
+function getL10n() {
+ if (!gL10n) {
+ gL10n = new Localization(["browser/migration.ftl"]);
+ }
+ return gL10n;
+}
+
+const MIGRATOR_MODULES = Object.freeze({
+ EdgeProfileMigrator: {
+ moduleURI: "resource:///modules/EdgeProfileMigrator.sys.mjs",
+ platforms: ["win"],
+ },
+ FirefoxProfileMigrator: {
+ moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ IEProfileMigrator: {
+ moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs",
+ platforms: ["win"],
+ },
+ SafariProfileMigrator: {
+ moduleURI: "resource:///modules/SafariProfileMigrator.sys.mjs",
+ platforms: ["macosx"],
+ },
+
+ // The following migrators are all variants of the ChromeProfileMigrator
+
+ BraveProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ CanaryProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["macosx", "win"],
+ },
+ ChromeProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ ChromeBetaMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "win"],
+ },
+ ChromeDevMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux"],
+ },
+ ChromiumProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ Chromium360seMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["win"],
+ },
+ ChromiumEdgeMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["macosx", "win"],
+ },
+ ChromiumEdgeBetaMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["macosx", "win"],
+ },
+ OperaProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ VivaldiProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["linux", "macosx", "win"],
+ },
+ OperaGXProfileMigrator: {
+ moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs",
+ platforms: ["macosx", "win"],
+ },
+});
+
+/**
+ * The singleton MigrationUtils service. This service is the primary mechanism
+ * by which migrations from other browsers to this browser occur. The singleton
+ * instance of this class is exported from this module as `MigrationUtils`.
+ */
+class MigrationUtils {
+ resourceTypes = Object.freeze({
+ ALL: 0x0000,
+ /* 0x01 used to be used for settings, but was removed. */
+ COOKIES: 0x0002,
+ HISTORY: 0x0004,
+ FORMDATA: 0x0008,
+ PASSWORDS: 0x0010,
+ BOOKMARKS: 0x0020,
+ OTHERDATA: 0x0040,
+ SESSION: 0x0080,
+ });
+
+ /**
+ * Helper for implementing simple asynchronous cases of migration resources'
+ * |migrate(aCallback)| (see MigratorBase). 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.
+ *
+ * @example
+ * // 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 {Function} 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 {Function} aCallback
+ * the callback function passed to |migrate|.
+ * @returns {Function}
+ * the wrapped function.
+ */
+ 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 {string} aKey
+ * The key of the id of the localization to retrieve.
+ * @param {object} [aArgs=undefined]
+ * An optional map of arguments to the id.
+ * @returns {Promise<string>}
+ * A promise that resolves to the retrieved localization.
+ */
+ getLocalizedString(aKey, aArgs) {
+ let l10n = getL10n();
+ return l10n.formatValue(aKey, aArgs);
+ }
+
+ /**
+ * 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 {string} path
+ * The file path to the database we want to open.
+ * @param {string} description
+ * A developer-readable string identifying what kind of database we're
+ * trying to open.
+ * @param {string} selectQuery
+ * The SELECT query to use to fetch the rows.
+ *
+ * @returns {Promise<object[]|Error>}
+ * 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 previousException = { message: null };
+ try {
+ db = await lazy.Sqlite.openConnection(dbOptions);
+ didOpen = true;
+ rows = await db.execute(selectQuery);
+ } catch (ex) {
+ if (previousException.message != ex.message) {
+ Cu.reportError(ex);
+ }
+ previousException = ex;
+ } finally {
+ try {
+ if (didOpen) {
+ await db.close();
+ }
+ } catch (ex) {}
+ }
+ if (previousException) {
+ await new Promise(resolve => lazy.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();
+ for (let [symbol, { moduleURI, platforms }] of Object.entries(
+ MIGRATOR_MODULES
+ )) {
+ if (platforms.includes(AppConstants.platform)) {
+ let { [symbol]: migratorClass } = ChromeUtils.importESModule(
+ moduleURI
+ );
+ if (gMigrators.has(migratorClass.key)) {
+ console.error(
+ "A pre-existing migrator exists with key " +
+ `${migratorClass.key}. Not registering.`
+ );
+ continue;
+ }
+ gMigrators.set(migratorClass.key, new migratorClass());
+ }
+ }
+ }
+ return gMigrators;
+ }
+
+ forceExitSpinResolve() {
+ gForceExitSpinResolve = true;
+ }
+
+ 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(
+ "MigrationUtils.jsm:MU_spinResolve",
+ () => 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.
+ *
+ * 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).
+ *
+ * @param {string} aKey
+ * Internal name of the migration source. See `availableMigratorKeys`
+ * for supported values by OS.
+ *
+ * @returns {MigratorBase}
+ * A profile migrator implementing nsIBrowserProfileMigrator, if it can
+ * import any data, null otherwise.
+ */
+ async getMigrator(aKey) {
+ let migrator = this.#migrators.get(aKey);
+ if (!migrator) {
+ console.error(`Could not find a migrator class for key ${aKey}`);
+ return null;
+ }
+
+ try {
+ return migrator && (await migrator.isSourceAvailable()) ? migrator : null;
+ } catch (ex) {
+ Cu.reportError(ex);
+ return null;
+ }
+ }
+
+ /**
+ * Returns true if a migrator is registered with key aKey. No check is made
+ * to determine if a profile exists that the migrator can migrate from.
+ *
+ * @param {string} aKey
+ * Internal name of the migration source. See `availableMigratorKeys`
+ * for supported values by OS.
+ * @returns {boolean}
+ */
+ migratorExists(aKey) {
+ return this.#migrators.has(aKey);
+ }
+
+ /**
+ * 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.
+ *
+ * @returns {string}
+ */
+ 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",
+ Opera: "opera",
+ Vivaldi: "vivaldi",
+ "Opera GX": "opera-gx",
+ "Brave Web Browser": "brave", // Windows, Linux
+ Brave: "brave", // OS X
+ "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": "chromium-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 = lazy.WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ kRegPath,
+ "OldDefaultBrowserCommand"
+ );
+ if (oldDefault) {
+ // Remove the key:
+ lazy.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;
+ }
+
+ /**
+ * True if we're in the process of a startup migration.
+ *
+ * @type {boolean}
+ */
+ 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
+ * @type {nsIProfileStartup|null}
+ */
+ 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 aOptions are ignored.
+ *
+ * NB: If you add new consumers, please add a migration entry point constant to
+ * MIGRATION_ENTRYPOINTS and supply that entrypoint with the entrypoint property
+ * in the aOptions argument.
+ *
+ * @param {Window} [aOpener=null]
+ * optional; the window that asks to open the wizard.
+ * @param {object} [aOptions=null]
+ * optional named arguments for the migration wizard.
+ * @param {number} [aOptions.entrypoint=undefined]
+ * migration entry point constant. See MIGRATION_ENTRYPOINTS.
+ * @param {string} [aOptions.migratorKey=undefined]
+ * The key for which migrator to use automatically. This is the key that is exposed
+ * as a static getter on the migrator class.
+ * @param {MigratorBase} [aOptions.migrator=undefined]
+ * A migrator instance to use automatically.
+ * @param {boolean} [aOptions.isStartupMigration=undefined]
+ * True if this is a startup migration.
+ * @param {boolean} [aOptions.skipSourceSelection=undefined]
+ * True if the source selection page of the wizard should be skipped.
+ * @param {string} [aOptions.profileId]
+ * An identifier for the profile to use when migrating.
+ */
+ showMigrationWizard(aOpener, aOptions) {
+ const DIALOG_URL = "chrome://browser/content/migration/migration.xhtml";
+ 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";
+ }
+
+ if (
+ Services.prefs.getBoolPref(
+ "browser.migrate.content-modal.enabled",
+ false
+ ) &&
+ aOpener &&
+ aOpener.gBrowser
+ ) {
+ const { gBrowser } = aOpener;
+ const { selectedBrowser } = gBrowser;
+ gBrowser.getTabDialogBox(selectedBrowser).open(DIALOG_URL, aOptions);
+ } else {
+ Services.ww.openWindow(aOpener, DIALOG_URL, "_blank", features, aOptions);
+ }
+ }
+
+ /**
+ * 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 {nsIProfileStartup} aProfileStartup
+ * the nsIProfileStartup instance provided to ProfileMigrator.migrate.
+ * @param {string|null} [aMigratorKey=null]
+ * 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 {string|null} [aProfileToMigrate=null]
+ * 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(aProfileStartup, aMigratorKey, aProfileToMigrate) {
+ this.spinResolve(
+ this.asyncStartupMigration(
+ aProfileStartup,
+ aMigratorKey,
+ aProfileToMigrate
+ )
+ );
+ }
+
+ async asyncStartupMigration(
+ aProfileStartup,
+ aMigratorKey,
+ aProfileToMigrate
+ ) {
+ if (!aProfileStartup) {
+ throw new Error(
+ "an profile-startup instance is required for startup-migration"
+ );
+ }
+ gProfileStartup = aProfileStartup;
+
+ let skipSourceSelection = 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;
+ skipSourceSelection = true;
+ } else {
+ let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser();
+ if (defaultBrowserKey) {
+ migrator = await this.getMigrator(defaultBrowserKey);
+ if (migrator) {
+ migratorKey = defaultBrowserKey;
+ }
+ }
+ }
+
+ if (!migrator) {
+ let migrators = await Promise.all(
+ this.availableMigratorKeys.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 &&
+ skipSourceSelection &&
+ migratorKey == AppConstants.MOZ_APP_NAME;
+
+ let entrypoint = this.MIGRATION_ENTRYPOINTS.FIRSTRUN;
+ if (isRefresh) {
+ entrypoint = this.MIGRATION_ENTRYPOINTS.FXREFRESH;
+ }
+
+ this.showMigrationWizard(null, {
+ entrypoint,
+ migratorKey,
+ migrator,
+ isStartupMigration: !!aProfileStartup,
+ skipSourceSelection,
+ profileId: aProfileToMigrate,
+ });
+ }
+
+ /**
+ * This is only pseudo-private because some tests and helper functions
+ * still expect to be able to directly access it.
+ */
+ _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 = lazy.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 = lazy.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 });
+ }
+ }
+ if (parent == lazy.PlacesUtils.bookmarks.toolbarGuid) {
+ lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility(
+ true /* aForceVisible */
+ );
+ }
+ },
+ 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 lazy.PlacesUtils.history.insertMany(pageInfos);
+ }
+
+ async insertLoginsWrapper(logins) {
+ this._importQuantities.logins += logins.length;
+ let inserted = await lazy.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 });
+ }
+ }
+ }
+
+ /**
+ * Iterates through the favicons, sniffs for a mime type,
+ * and uses the mime type to properly import the favicon.
+ *
+ * @param {object[]} favicons
+ * An array of Objects with these properties:
+ * {Uint8Array} faviconData: The binary data of a favicon
+ * {nsIURI} uri: The URI of the associated page
+ */
+ insertManyFavicons(favicons) {
+ let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance(
+ Ci.nsIContentSniffer
+ );
+ for (let faviconDataItem of favicons) {
+ let mimeType = sniffer.getMIMETypeFromContent(
+ null,
+ faviconDataItem.faviconData,
+ faviconDataItem.faviconData.length
+ );
+ let fakeFaviconURI = Services.io.newURI(
+ "fake-favicon-uri:" + faviconDataItem.uri.spec
+ );
+ lazy.PlacesUtils.favicons.replaceFaviconData(
+ fakeFaviconURI,
+ faviconDataItem.faviconData,
+ mimeType
+ );
+ lazy.PlacesUtils.favicons.setAndFetchFaviconForPage(
+ faviconDataItem.uri,
+ fakeFaviconURI,
+ true,
+ lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ }
+ }
+
+ 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 == lazy.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 lazy.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() {
+ gMigrators = null;
+ gProfileStartup = null;
+ gL10n = null;
+ }
+
+ get availableMigratorKeys() {
+ return [...this.#migrators.keys()];
+ }
+
+ /**
+ * Enum for the entrypoint that is being used to start migration.
+ * Callers can use the MIGRATION_ENTRYPOINTS getter to use these.
+ *
+ * These values are what's written into the FX_MIGRATION_ENTRY_POINT
+ * histogram after a migration.
+ *
+ * @see MIGRATION_ENTRYPOINTS
+ * @readonly
+ * @enum {number}
+ */
+ #MIGRATION_ENTRYPOINTS_ENUM = Object.freeze({
+ /** The entrypoint was not supplied */
+ UNKNOWN: 0,
+
+ /** Migration is occurring at startup */
+ FIRSTRUN: 1,
+
+ /** Migration is occurring at after a profile refresh */
+ FXREFRESH: 2,
+
+ /** Migration is being started from the Library window */
+ PLACES: 3,
+
+ /** Migration is being started from our password management UI */
+ PASSWORDS: 4,
+
+ /** Migration is being started from the default about:home/about:newtab */
+ NEWTAB: 5,
+
+ /** Migration is being started from the File menu */
+ FILE_MENU: 6,
+
+ /** Migration is being started from the Help menu */
+ HELP_MENU: 7,
+
+ /** Migration is being started from the Bookmarks Toolbar */
+ BOOKMARKS_TOOLBAR: 8,
+ });
+
+ /**
+ * Returns an enum that should be used to record the entrypoint for
+ * starting a migration.
+ *
+ * @returns {number}
+ */
+ get MIGRATION_ENTRYPOINTS() {
+ return this.#MIGRATION_ENTRYPOINTS_ENUM;
+ }
+
+ /**
+ * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER,
+ * and FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER histograms.
+ *
+ * @see getSourceIdForTelemetry
+ * @readonly
+ * @enum {number}
+ */
+ #SOURCE_NAME_TO_ID_MAPPING_ENUM = Object.freeze({
+ nothing: 1,
+ firefox: 2,
+ edge: 3,
+ ie: 4,
+ chrome: 5,
+ "chrome-beta": 5,
+ "chrome-dev": 5,
+ chromium: 6,
+ canary: 7,
+ safari: 8,
+ "chromium-360se": 9,
+ "chromium-edge": 10,
+ "chromium-edge-beta": 10,
+ brave: 11,
+ opera: 12,
+ "opera-gx": 14,
+ vivaldi: 13,
+ });
+
+ getSourceIdForTelemetry(sourceName) {
+ return this.#SOURCE_NAME_TO_ID_MAPPING_ENUM[sourceName] || 0;
+ }
+}
+
+const MigrationUtilsSingleton = new MigrationUtils();
+
+export { MigrationUtilsSingleton as MigrationUtils };
diff --git a/browser/components/migration/MigrationWizardChild.sys.mjs b/browser/components/migration/MigrationWizardChild.sys.mjs
new file mode 100644
index 0000000000..4a7db8f043
--- /dev/null
+++ b/browser/components/migration/MigrationWizardChild.sys.mjs
@@ -0,0 +1,57 @@
+/* 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/. */
+
+import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs";
+
+/**
+ * This class is responsible for updating the state of a <migration-wizard>
+ * component, and for listening for events from that component to perform
+ * various migration functions.
+ */
+export class MigrationWizardChild extends JSWindowActorChild {
+ #wizardEl = null;
+
+ /**
+ * General event handler function for events dispatched from the
+ * <migration-wizard> component.
+ *
+ * @param {Event} event
+ * The DOM event being handled.
+ * @returns {Promise}
+ */
+ async handleEvent(event) {
+ if (event.type == "MigrationWizard:Init") {
+ this.#wizardEl = event.target;
+ let migrators = await this.sendQuery("GetAvailableMigrators");
+ this.setComponentState({
+ migrators,
+ page: MigrationWizardConstants.PAGES.SELECTION,
+ });
+ }
+ }
+
+ /**
+ * Calls the `setState` method on the <migration-wizard> component. The
+ * state is cloned into the execution scope of this.#wizardEl.
+ *
+ * @param {object} state The state object that a <migration-wizard>
+ * component expects. See the documentation for the element's setState
+ * method for more details.
+ */
+ setComponentState(state) {
+ if (!this.#wizardEl) {
+ return;
+ }
+ // We waive XrayWrappers in the event that the element is embedded in
+ // a document without system privileges, like about:welcome.
+ Cu.waiveXrays(this.#wizardEl).setState(
+ Cu.cloneInto(
+ state,
+ // ownerGlobal doesn't exist in content windows.
+ // eslint-disable-next-line mozilla/use-ownerGlobal
+ this.#wizardEl.ownerDocument.defaultView
+ )
+ );
+ }
+}
diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs
new file mode 100644
index 0000000000..15120c8e09
--- /dev/null
+++ b/browser/components/migration/MigrationWizardParent.sys.mjs
@@ -0,0 +1,60 @@
+/* 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/. */
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs";
+
+/**
+ * This class is responsible for communicating with MigrationUtils to do the
+ * actual heavy-lifting of any kinds of migration work, based on messages from
+ * the associated MigrationWizardChild.
+ */
+export class MigrationWizardParent extends JSWindowActorParent {
+ /**
+ * General message handler function for messages received from the
+ * associated MigrationWizardChild JSWindowActor.
+ *
+ * @param {ReceiveMessageArgument} message
+ * The message received from the MigrationWizardChild.
+ * @returns {Promise}
+ */
+ async receiveMessage(message) {
+ // Some belt-and-suspenders here, mainly because the migration-wizard
+ // component can be embedded in less privileged content pages, so let's
+ // make sure that any messages from content are coming from the privileged
+ // about content process type.
+ if (
+ !this.browsingContext.currentWindowGlobal.isInProcess &&
+ this.browsingContext.currentRemoteType !=
+ E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE
+ ) {
+ throw new Error(
+ "MigrationWizardParent: received message from the wrong content process type."
+ );
+ }
+
+ if (message.name == "GetAvailableMigrators") {
+ let availableMigrators = new Map();
+ for (const key of MigrationUtils.availableMigratorKeys) {
+ try {
+ let migratorPromise = MigrationUtils.getMigrator(key).catch(
+ console.error
+ );
+ if (migratorPromise) {
+ availableMigrators.set(key, migratorPromise);
+ }
+ } catch (e) {
+ console.error(`Could not get migrator with key ${key}`);
+ }
+ }
+ // Wait for all getMigrator calls to resolve in parallel
+ await Promise.all(availableMigrators.values());
+ // ...and then filter out any that resolved to null.
+ return Array.from(availableMigrators.keys()).filter(key => {
+ return availableMigrators.get(key);
+ });
+ }
+ return null;
+ }
+}
diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs
new file mode 100644
index 0000000000..627ab61e5b
--- /dev/null
+++ b/browser/components/migration/MigratorBase.sys.mjs
@@ -0,0 +1,493 @@
+/* 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/. */
+
+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 lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs",
+});
+
+/**
+ * @typedef {object} MigratorResource
+ * A resource returned by a subclass of MigratorBase that can migrate
+ * data to this browser.
+ * @property {number} type
+ * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
+ * what this resource represents. A resource can represent one or more types
+ * of data, for example HISTORY and FORMDATA.
+ * @property {Function} migrate
+ * A function that will actually perform the migration of this resource's
+ * data into this browser.
+ */
+
+/**
+ * Shared prototype for migrators.
+ *
+ * To implement a migrator:
+ * 1. Import this module.
+ * 2. Create a subclass of MigratorBase for your new migrator.
+ * 3. Override the `key` static getter with a unique identifier for the browser
+ * that this migrator migrates from.
+ * 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|.
+ * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs.
+ */
+export class MigratorBase {
+ /**
+ * This must be overridden to return a simple string identifier for the
+ * migrator, for example "firefox", "chrome", "opera-gx". This key is what
+ * is used as an identifier when calling MigrationUtils.getMigrator.
+ *
+ * @type {boolean}
+ */
+ static get key() {
+ throw new Error("MigratorBase must be overridden.");
+ }
+
+ /**
+ * 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.
+ *
+ * @abstract
+ * @returns {object[]|null}
+ */
+ 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 resource types (see
+ * MigrationUtils.resourceTypes).
+ *
+ * - 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 MigrationUtils.resourceTypes, 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.
+ *
+ * @abstract
+ * @param {object|string} 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).
+ * @returns {Promise<MigratorResource[]>|MigratorResource[]}
+ */
+ // eslint-disable-next-line no-unused-vars
+ 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).
+ *
+ * If not overridden, the promise will resolve to the Unix epoch.
+ *
+ * @returns {Promise<Date>}
+ * A Promise that resolves to the last used date.
+ */
+ 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.
+ *
+ * @returns {boolean}
+ * true if the migrator is start-up only.
+ */
+ get startupOnlyMigrator() {
+ return false;
+ }
+
+ /**
+ * Returns true if the migrator is configured to be enabled. This is
+ * controlled by the `browser.migrate.<BROWSER_KEY>.enabled` boolean
+ * preference.
+ *
+ * @returns {boolean}
+ * true if the migrator should be shown in the migration wizard.
+ */
+ get enabled() {
+ let key = this.constructor.key;
+ return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false);
+ }
+
+ /**
+ * This method returns a number that is the bitwise OR of all resource
+ * types that are available in aProfile. See MigrationUtils.resourceTypes
+ * for each resource type.
+ *
+ * @param {object|string} aProfile
+ * The profile from which data may be imported, or an empty string
+ * in the case of a single-profile migrator.
+ * @returns {number}
+ */
+ async 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);
+ }
+
+ /**
+ * @see MigrationUtils
+ *
+ * @param {number} aItems
+ * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate
+ * what types of resources should be migrated.
+ * @param {boolean} aStartup
+ * True if this migration is occurring during startup.
+ * @param {object|string} aProfile
+ * The other browser profile that is being migrated from.
+ */
+ async 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 != lazy.MigrationUtils.resourceTypes.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 == lazy.MigrationUtils.resourceTypes.HISTORY) {
+ return template.replace("*", "HISTORY");
+ }
+ if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) {
+ return template.replace("*", "BOOKMARKS");
+ }
+ if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) {
+ return template.replace("*", "LOGINS");
+ }
+ return null;
+ };
+
+ let browserKey = this.constructor.key;
+
+ 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 lazy.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(
+ lazy.MigrationUtils._importQuantities
+ )) {
+ let histogramId =
+ "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY";
+ try {
+ Services.telemetry
+ .getKeyedHistogramById(histogramId)
+ .add(
+ browserKey,
+ lazy.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(
+ lazy.MigrationUtils._importQuantities
+ )) {
+ lazy.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 = lazy.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 (
+ lazy.MigrationUtils.isStartupMigration &&
+ !this.startupOnlyMigrator &&
+ Services.policies.isAllowed("defaultBookmarks")
+ ) {
+ lazy.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 lazy.BookmarkHTMLUtils.importFromURL(
+ "chrome://browser/content/default-bookmarks.html",
+ {
+ replace: true,
+ source: lazy.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();
+ }
+
+ /**
+ * Checks to see if one or more profiles exist for the browser that this
+ * migrator migrates from.
+ *
+ * @returns {Promise<boolean>}
+ * True if one or more profiles exists that this migrator can migrate
+ * resources from.
+ */
+ async isSourceAvailable() {
+ if (this.startupOnlyMigrator && !lazy.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 ***/
+
+ /**
+ * Returns resources for a particular profile and then caches them for later
+ * lookups.
+ *
+ * @param {object|string} aProfile
+ * The profile that resources are being imported from.
+ * @returns {Promise<MigrationResource[]>}
+ */
+ async #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];
+ }
+}
diff --git a/browser/components/migration/ProfileMigrator.sys.mjs b/browser/components/migration/ProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..5d3b8baba7
--- /dev/null
+++ b/browser/components/migration/ProfileMigrator.sys.mjs
@@ -0,0 +1,15 @@
+/* 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/. */
+
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+
+export 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"),
+};
diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs
new file mode 100644
index 0000000000..e27a302c56
--- /dev/null
+++ b/browser/components/migration/SafariProfileMigrator.sys.mjs
@@ -0,0 +1,467 @@
+/* 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/. */
+
+import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
+
+const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm");
+import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs";
+import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs",
+});
+
+function Bookmarks(aBookmarksFile) {
+ this._file = aBookmarksFile;
+}
+Bookmarks.prototype = {
+ type: MigrationUtils.resourceTypes.BOOKMARKS,
+
+ migrate: function B_migrate(aCallback) {
+ return (async () => {
+ let dict = await new Promise(resolve =>
+ lazy.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);
+ })().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 {object[]} aEntries
+ * The collection's children
+ * @param {number} aCollection
+ * One of the _COLLECTION 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 = lazy.PlacesUtils.bookmarks.unfiledGuid;
+ break;
+ }
+ case this.MENU_COLLECTION: {
+ folderGuid = lazy.PlacesUtils.bookmarks.menuGuid;
+ break;
+ }
+ case this.TOOLBAR_COLLECTION: {
+ folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid;
+ 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: lazy.PlacesUtils.bookmarks.menuGuid,
+ type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER,
+ title: readingListTitle,
+ })
+ ).guid;
+ 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: lazy.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) {
+ lazy.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: lazy.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.
+ *
+ * @param {nsIFile} aPreferencesFile
+ * The .plist file to be read.
+ */
+function MainPreferencesPropertyList(aPreferencesFile) {
+ this._file = aPreferencesFile;
+ this._callbacks = [];
+}
+MainPreferencesPropertyList.prototype = {
+ /**
+ * @see PropertyListUtils.read
+ * @param {Function} aCallback
+ * A callback called with an Object representing the key-value pairs
+ * read out of the .plist file.
+ */
+ read: function MPPL_read(aCallback) {
+ if ("_dict" in this) {
+ aCallback(this._dict);
+ return;
+ }
+
+ let alreadyReading = !!this._callbacks.length;
+ this._callbacks.push(aCallback);
+ if (!alreadyReading) {
+ lazy.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,
+ }));
+ lazy.FormHistory.update(changes);
+ }
+ }
+ }, aCallback)
+ );
+ },
+};
+
+/**
+ * Safari migrator
+ */
+export class SafariProfileMigrator extends MigratorBase {
+ static get key() {
+ return "safari";
+ }
+
+ 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;
+ }
+
+ 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));
+ });
+ }
+
+ async 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;
+ }
+ }
+
+ async 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;
+ }
+ }
+ return true;
+ }
+
+ 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;
+ }
+}
diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf
new file mode 100644
index 0000000000..06b2d4b446
--- /dev/null
+++ b/browser/components/migration/components.conf
@@ -0,0 +1,37 @@
+# -*- 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'],
+ 'esModule': 'resource:///modules/ProfileMigrator.sys.mjs',
+ 'constructor': 'ProfileMigrator',
+ },
+]
+
+if XP_WIN:
+ Classes += [
+ {
+ 'cid': '{c214cadc-2033-445e-8800-3fe25ee8d368}',
+ 'contract_ids': ['@mozilla.org/profile/migrator/edgemigrationutils;1'],
+ 'type': 'mozilla::nsEdgeMigrationUtils',
+ 'headers': ['nsEdgeMigrationUtils.h'],
+ },
+ ]
+
+if XP_MACOSX:
+ Classes += [
+ {
+ '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..073f172148
--- /dev/null
+++ b/browser/components/migration/content/aboutWelcomeBack.xhtml
@@ -0,0 +1,76 @@
+<?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" "http://www.w3.org/TR/xhtml1/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'" />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="welcome-back-tab-title"></title>
+ <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" media="all"/>
+ <link rel="stylesheet" href="chrome://browser/skin/aboutWelcomeBack.css" media="all"/>
+ <link rel="icon" href="chrome://global/skin/icons/info-filled.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 tab-list-tree-container">
+ <div class="description-wrapper">
+
+ <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>
+ <label class="radioRestoreContainer radio-container-with-text">
+ <input class="radioRestoreButton" id="radioRestoreAll" type="radio"
+ name="restore" checked="checked"/>
+ <span class="radioRestoreLabel" data-l10n-id="welcome-back-restore-all-label"></span>
+ </label>
+
+ <label class="radioRestoreContainer radio-container-with-text">
+ <input class="radioRestoreButton" id="radioRestoreChoose" type="radio"
+ name="restore"/>
+ <span class="radioRestoreLabel" data-l10n-id="welcome-back-restore-some-label"></span>
+ </label>
+ </div>
+ </div>
+
+ </div>
+
+ <xul:tree id="tabList" flex="1" seltype="single" hidecolumnpicker="true" hidden="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 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-dialog.html b/browser/components/migration/content/migration-dialog.html
new file mode 100644
index 0000000000..2f873460f0
--- /dev/null
+++ b/browser/components/migration/content/migration-dialog.html
@@ -0,0 +1,18 @@
+<!-- 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>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <meta name="color-scheme" content="light dark">
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; object-src 'none'; script-src chrome:; media-src chrome:; img-src chrome:; style-src chrome:;">
+ <link rel="icon" type="image/png" href="chrome://branding/content/icon32.png">
+ <link rel="stylesheet" href="chrome://global/skin/in-content/common.css">
+ <link rel="stylesheet" href="chrome://browser/skin/migration/migration-dialog.css">
+ <script src="chrome://browser/content/migration/migration-wizard.mjs" type="module"></script>
+ </head>
+ <body>
+ <migration-wizard />
+ </body>
+</html>
diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs
new file mode 100644
index 0000000000..118c8743d9
--- /dev/null
+++ b/browser/components/migration/content/migration-wizard-constants.mjs
@@ -0,0 +1,18 @@
+/* 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/. */
+
+export const MigrationWizardConstants = Object.freeze({
+ /**
+ * A mapping of a page identification string to the IDs used by the
+ * various wizard pages. These are used by MigrationWizard.setState
+ * to set the current page.
+ *
+ * @type {Object<string, string>}
+ */
+ PAGES: Object.freeze({
+ SELECTION: "selection",
+ PROGRESS: "progress",
+ SAFARI_PERMISSION: "safari-permission",
+ }),
+});
diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs
new file mode 100644
index 0000000000..74a80af277
--- /dev/null
+++ b/browser/components/migration/content/migration-wizard.mjs
@@ -0,0 +1,146 @@
+/* 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/. */
+
+// eslint-disable-next-line import/no-unassigned-import
+import "chrome://global/content/elements/moz-button-group.mjs";
+import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs";
+
+/**
+ * This component contains the UI that steps users through migrating their
+ * data from other browsers to this one. This component only contains very
+ * basic logic and structure for the UI, and most of the state management
+ * occurs in the MigrationWizardChild JSWindowActor.
+ */
+export class MigrationWizard extends HTMLElement {
+ static #template = null;
+
+ #deck = null;
+ #browserProfileSelector = null;
+
+ static get markup() {
+ return `
+ <template>
+ <link rel="stylesheet" href="chrome://browser/skin/migration/migration-wizard.css">
+ <named-deck id="wizard-deck" selected-view="page-selection">
+
+ <div name="page-selection">
+ <h3 data-l10n-id="migration-wizard-header"></h3>
+ <select id="browser-profile-selector">
+ </select>
+ <fieldset>
+ <label for="bookmarks">
+ <input type="checkbox" id="bookmarks"/><span data-l10n-id="migration-bookmarks-option-label"></span>
+ </label>
+ <label for="logins-and-passwords">
+ <input type="checkbox" id="logins-and-passwords"/><span data-l10n-id="migration-logins-and-passwords-option-label"></span>
+ </label>
+ <label for="history">
+ <input type="checkbox" id="history"/><span data-l10n-id="migration-history-option-label"></span>
+ </label>
+ <label for="form-autofill">
+ <input type="checkbox" id="form-autofill"/><span data-l10n-id="migration-form-autofill-option-label"></span>
+ </label>
+ </fieldset>
+ <moz-button-group class="buttons">
+ <button data-l10n-id="migration-cancel-button-label"></button>
+ <button class="primary" data-l10n-id="migration-import-button-label"></button>
+ </moz-button-group>
+ </div>
+
+ <div name="page-progress">
+ <h3>TODO: Progress page</h3>
+ </div>
+
+ <div name="page-safari-permission">
+ <h3>TODO: Safari permission page</h3>
+ </div>
+ </named-deck>
+ </template>
+ `;
+ }
+
+ static get fragment() {
+ if (!MigrationWizard.#template) {
+ let parser = new DOMParser();
+ let doc = parser.parseFromString(MigrationWizard.markup, "text/html");
+ MigrationWizard.#template = document.importNode(
+ doc.querySelector("template"),
+ true
+ );
+ }
+ let fragment = MigrationWizard.#template.content.cloneNode(true);
+ if (window.IS_STORYBOOK) {
+ // If we're using Storybook, load the CSS from the static local file
+ // system rather than chrome:// to take advantage of auto-reloading.
+ fragment.querySelector("link[rel=stylesheet]").href =
+ "./migration/migration-wizard.css";
+ }
+ return fragment;
+ }
+
+ constructor() {
+ super();
+ const shadow = this.attachShadow({ mode: "closed" });
+
+ if (window.MozXULElement) {
+ window.MozXULElement.insertFTLIfNeeded(
+ "locales-preview/migrationWizard.ftl"
+ );
+ }
+ document.l10n.connectRoot(shadow);
+
+ shadow.appendChild(MigrationWizard.fragment);
+
+ this.#deck = shadow.querySelector("#wizard-deck");
+ this.#browserProfileSelector = shadow.querySelector(
+ "#browser-profile-selector"
+ );
+ }
+
+ connectedCallback() {
+ this.dispatchEvent(
+ new CustomEvent("MigrationWizard:Init", { bubbles: true })
+ );
+ }
+
+ /**
+ * This is the main entrypoint for updating the state and appearance of
+ * the wizard.
+ *
+ * @param {object} state The state to be represented by the component.
+ * @param {string} state.page The page of the wizard to display. This should
+ * be one of the MigrationWizardConstants.PAGES constants.
+ */
+ setState(state) {
+ if (state.page == MigrationWizardConstants.PAGES.SELECTION) {
+ this.#onShowingSelection(state);
+ }
+
+ this.#deck.setAttribute("selected-view", `page-${state.page}`);
+ }
+
+ /**
+ * Called when showing the browser/profile selection page of the wizard.
+ *
+ * @param {object} state
+ * The state object passed into setState. The following properties are
+ * used:
+ * @param {string[]} state.migrators An array of source browser names that
+ * can be migrated from.
+ */
+ #onShowingSelection(state) {
+ this.#browserProfileSelector.textContent = "";
+
+ for (let migratorKey of state.migrators) {
+ let opt = document.createElement("option");
+ opt.value = migratorKey;
+ opt.textContent = migratorKey;
+ this.#browserProfileSelector.appendChild(opt);
+ }
+ }
+}
+
+if (globalThis.customElements) {
+ customElements.define("migration-wizard", MigrationWizard);
+}
diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js
new file mode 100644
index 0000000000..ee1efde5e2
--- /dev/null
+++ b/browser/components/migration/content/migration.js
@@ -0,0 +1,635 @@
+/* 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.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { MigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/MigrationUtils.sys.mjs"
+);
+const { MigratorBase } = ChromeUtils.importESModule(
+ "resource:///modules/MigratorBase.sys.mjs"
+);
+
+/**
+ * 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: MigrationUtils.resourceTypes.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[0]?.wrappedJSObject || {};
+ let entryPointId =
+ args.entrypoint || MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN;
+ Services.telemetry
+ .getHistogramById("FX_MIGRATION_ENTRY_POINT")
+ .add(entryPointId);
+ this.isInitialMigration =
+ entryPointId == MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN;
+
+ // Record that the uninstaller requested a profile refresh
+ if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) {
+ Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", "");
+ Services.telemetry.scalarSet(
+ "migration.uninstaller_profile_refresh",
+ true
+ );
+ }
+
+ this._source = args.migratorKey;
+ this._migrator =
+ args.migrator instanceof MigratorBase ? args.migrator : null;
+ this._autoMigrate = !!args.isStartupMigration;
+ this._skipImportSourcePage = !!args.skipSourceSelection;
+
+ if (this._migrator && args.profileId) {
+ let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles());
+ this._selectedProfile = sourceProfiles.find(
+ profile => profile.id == args.profileId
+ );
+ }
+
+ 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() {
+ 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?.enabled) {
+ // 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);
+ }
+
+ if (selectedMigrator) {
+ group.selectedItem = selectedMigrator;
+ } 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 = MigrationUtils.resourceTypes.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 = MigrationUtils.resourceTypes[itemType.toUpperCase()];
+ if (items & itemValue) {
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.id = itemValue;
+ checkbox.setAttribute("native", true);
+ 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 ||
+ this._itemsFlags == MigrationUtils.resourceTypes.ALL)
+ ) {
+ let havePermissions = this.spinResolve(this._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();
+
+ await this._migrator.getPermissions(window);
+ if (await this._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 = MigrationUtils.resourceTypes[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 MigrationUtils.resourceTypes.COOKIES:
+ type = "cookies";
+ break;
+ case MigrationUtils.resourceTypes.HISTORY:
+ type = "history";
+ break;
+ case MigrationUtils.resourceTypes.FORMDATA:
+ type = "form data";
+ break;
+ case MigrationUtils.resourceTypes.PASSWORDS:
+ type = "passwords";
+ break;
+ case MigrationUtils.resourceTypes.BOOKMARKS:
+ type = "bookmarks";
+ break;
+ case MigrationUtils.resourceTypes.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..9d42ee84ff
--- /dev/null
+++ b/browser/components/migration/content/migration.xhtml
@@ -0,0 +1,114 @@
+<?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="min-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="opera" data-l10n-id="import-from-opera"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <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="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="chromium-360se" data-l10n-id="import-from-360se"/>
+ <radio id="opera-gx" data-l10n-id="import-from-opera-gx"/>
+#elifdef XP_MACOSX
+ <radio id="safari" data-l10n-id="import-from-safari"/>
+ <radio id="opera" data-l10n-id="import-from-opera"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <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"/>
+ <radio id="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="opera-gx" data-l10n-id="import-from-opera-gx"/>
+#elifdef XP_UNIX
+ <radio id="opera" data-l10n-id="import-from-opera"/>
+ <radio id="vivaldi" data-l10n-id="import-from-vivaldi"/>
+ <radio id="brave" data-l10n-id="import-from-brave"/>
+ <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"/>
+ <radio id="opera-gx" data-l10n-id="import-from-opera-gx"/>
+#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>
+ </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/docs/index.rst b/browser/components/migration/docs/index.rst
new file mode 100644
index 0000000000..be22b951cb
--- /dev/null
+++ b/browser/components/migration/docs/index.rst
@@ -0,0 +1,16 @@
+.. _components/migration:
+
+=========
+Migration
+=========
+
+The migration component is responsible for bringing data from outside applications running on the same computer into Firefox. This is typically done via a wizard where users can choose what types of data to migrate over.
+
+The migrator is also used during a "Profile Refresh" to pave over a newly created Firefox profile with some data from an older one.
+
+.. toctree::
+ :maxdepth: 3
+
+ migration-utils
+ migrators
+ migration-wizard
diff --git a/browser/components/migration/docs/migration-utils.rst b/browser/components/migration/docs/migration-utils.rst
new file mode 100644
index 0000000000..c1ecb41d8b
--- /dev/null
+++ b/browser/components/migration/docs/migration-utils.rst
@@ -0,0 +1,5 @@
+========================
+MigrationUtils Reference
+========================
+.. js:autoclass:: MigrationUtils
+ :members:
diff --git a/browser/components/migration/docs/migration-wizard-architecture-diagram.svg b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg
new file mode 100644
index 0000000000..4c5fbf5bc5
--- /dev/null
+++ b/browser/components/migration/docs/migration-wizard-architecture-diagram.svg
@@ -0,0 +1,128 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 711 541">
+ <path d="M707.27 385.71V540H440V360h229.09Z" fill="#FFF" stroke="#8a8a8a" stroke-miterlimit="10" pointer-events="all"/>
+ <path d="M669.09 360c3.11 6.02-2 12.22-13.64 16.53L710 387Z" fill="#FFF" stroke="#8a8a8a" stroke-miterlimit="10" pointer-events="all"/>
+ <path fill="none" pointer-events="all" d="M440 500h120v30H440z"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:118px;height:1px;padding-top:515px;margin-left:441px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);">
+ <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
+ (X)HTML Document
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="500" y="519" font-family="Helvetica" font-size="12" text-anchor="middle">(X)HTML Document</text>
+ </switch>
+ <path fill="#FFF" stroke="#000" pointer-events="all" d="M480 400h180v100H480z"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:178px;height:1px;padding-top:450px;margin-left:481px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);">
+ <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
+ &lt;migration-wizard&gt;
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="570" y="454" font-family="Helvetica" font-size="12" text-anchor="middle">&lt;migration-wizard&gt;</text>
+ </switch>
+ <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M420 160h210v100H420z"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:210px;margin-left:421px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);">
+ <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
+ MigrationWizardChild
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="525" y="214" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationWizardChild</text>
+ </switch>
+ <path d="M216.37 210h197.26" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
+ <path d="m211.12 210 7-3.5-1.75 3.5 1.75 3.5ZM418.88 210l-7 3.5 1.75-3.5-1.75-3.5Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:208px;margin-left:314px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);">
+ <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">
+ JSWindowActor messages
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="314" y="211" font-family="Helvetica" font-size="11" text-anchor="middle">JSWindowActor messages</text>
+ </switch>
+ <path d="M105 153.63v-47.26" fill="none" stroke="#000" stroke-miterlimit="10" pointer-events="stroke"/>
+ <path d="m105 158.88-3.5-7 3.5 1.75 3.5-1.75ZM105 101.12l3.5 7-3.5-1.75-3.5 1.75Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:130px;margin-left:105px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);">
+ <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">
+ Direct function calls
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="105" y="133" font-family="Helvetica" font-size="11" text-anchor="middle">Direct function calls</text>
+ </switch>
+ <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M0 160h210v100H0z"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:210px;margin-left:1px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);">
+ <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
+ MigrationWizardParent
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="105" y="214" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationWizardParent</text>
+ </switch>
+ <path fill="#FFF" stroke="#8a8a8a" pointer-events="all" d="M0 0h210v100H0z"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:208px;height:1px;padding-top:50px;margin-left:1px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0);">
+ <div style="display:inline-block;font-size:12px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;white-space:normal;overflow-wrap:normal">
+ MigrationUtils
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="105" y="54" font-family="Helvetica" font-size="12" text-anchor="middle">MigrationUtils</text>
+ </switch>
+ <path d="M525 400V266.37" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
+ <path d="m525 261.12 3.5 7-3.5-1.75-3.5 1.75Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:340px;margin-left:527px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);">
+ <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">
+ DOM Events
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="527" y="343" font-family="Helvetica" font-size="11" text-anchor="middle">DOM Events</text>
+ </switch>
+ <path d="M572.22 393.64 577.5 260" fill="none" stroke="#000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="stroke"/>
+ <path d="m572.02 398.88-3.22-7.13 3.42 1.89 3.57-1.61Z" stroke="#000" stroke-miterlimit="10" pointer-events="all"/>
+ <switch transform="translate(-.5 -.5)">
+ <foreignObject style="overflow:visible;text-align:left" pointer-events="none" width="100%" height="100%" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility">
+ <div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;align-items:unsafe center;justify-content:unsafe center;width:1px;height:1px;padding-top:311px;margin-left:581px">
+ <div style="box-sizing:border-box;font-size:0;text-align:center" data-drawio-colors="color: rgb(0, 0, 0); background-color: rgb(255, 255, 255);">
+ <div style="display:inline-block;font-size:11px;font-family:Helvetica;color:#000;line-height:1.2;pointer-events:all;background-color:#fff;white-space:nowrap">
+ Direct function calls
+ </div>
+ </div>
+ </div>
+ </foreignObject>
+ <text x="581" y="314" font-family="Helvetica" font-size="11" text-anchor="middle">Direct function calls</text>
+ </switch>
+</svg>
diff --git a/browser/components/migration/docs/migration-wizard.rst b/browser/components/migration/docs/migration-wizard.rst
new file mode 100644
index 0000000000..3b4605d63b
--- /dev/null
+++ b/browser/components/migration/docs/migration-wizard.rst
@@ -0,0 +1,66 @@
+==========================
+Migration Wizard Reference
+==========================
+
+The migration wizard is the piece of UI that allows users to migrate from other browsers to Firefox. Firefox has had a legacy migration wizard for many years, and this has historically been a top-level XUL dialog window. **This documentation is not for the legacy migration wizard**, but is instead for an in-progress replacement migration wizard that can be embedded in the following contexts:
+
+1. In a top level stand-alone dialog window
+2. In a tab dialog modal
+3. Within privileged ``about:`` pages, like ``about:welcome``, and ``about:preferences``
+
+To accommodate these contexts, the new migration wizard was developed as a reusable component using pure HTML, with an architecture that decouples the control of the wizard from how the wizard is presented to the user. This architecture not only helps to ensure that the wizard can function similarly in these different contexts, but also makes the component viewable in tools like Storybook for easier development.
+
+High-level Overview
+-------------------
+
+The following diagram tries to illustrate how the pieces of the migration wizard fit together:
+
+.. image:: migration-wizard-architecture-diagram.svg
+
+``MigrationWizard`` reusable component
+======================================
+
+The ``MigrationWizard`` reusable component (``<migration-wizard>``) is a custom element that can be imported from ``migration-wizard.mjs``. The module is expected to load in a DOM window context, whereupon the custom element is automatically registered for that document.
+
+After binding to the document, the ``MigrationWizard`` dispatches a ``MigrationWizard:Init`` custom event, which causes a ``MigrationWizardChild`` to instantiate and be associated with it.
+
+Notably, the ``MigrationWizard`` does not contain any internal logic or privileged code to perform any migrations or to directly interact with the migration mechanisms. Its sole function is to accept input from the user and emit that input as events. The associated ``MigrationWizardChild`` will listen for those events, and take care of calling into the ``MigrationWizard`` to update the state of the reusable component. This means that the reusable component can be embedded in unprivileged contexts and have its states presented in a tool like Storybook.
+
+``MigrationWizardConstants``
+============================
+
+The ``MigrationWizardConstants`` module exports a single object of the same name. The properties of that object are constants that can be used to set the state of a ``MigrationWizard`` instance using ``MigrationWizard.setState``.
+
+``MigrationWizardChild``
+=========================
+
+The ``MigrationWizardChild`` is a ``JSWindowActorChild`` (see `JSActors`_) that is responsible for listening for events from a ``MigrationWizard``, and then either updating the state of that ``MigrationWizard`` immediately, or to message its paired ``MigrationWizardParent`` to perform tasks with ``MigrationUtils``.
+
+ .. note::
+ While a ``MigrationWizardChild`` can run in a content process (for out-of-process pages like ``about:welcome``), it can also run in parent-process contexts - for example, within a tab dialog or standalone window dialog. The same flow of events and messaging applies in all contexts.
+
+The ``MigrationWizardChild`` also waives Xrays so that it can directly call the ``setState`` method to update the appearance of the ``MigrationWizard``. See `XrayVision`_ for much more information on Xrays.
+
+.. js:autoclass:: MigrationWizardChild
+ :members:
+
+``MigrationWizardParent``
+=========================
+
+The ``MigrationWizardParent`` is a ``JSWindowActorParent`` (see `JSActors`_) that is responsible for listening for messages from the paired ``MigrationWizardChild`` to perform operations with ``MigrationUtils``. Effectively, all of the heavy lifting of actually performing the migrations will be kicked off by the ``MigrationWizardParent`` by calling into ``MigrationUtils``. State updates for things like migration progress will be sent back down to the ``MigrationWizardChild`` to then be reflected in the appearance of the ``MigrationWizard``.
+
+Since the ``MigrationWizard`` might be embedded in unprivileged documents, additional checks are placed in the message handler for ``MigrationWizardParent`` to ensure that the document is either running in the parent process or the privileged about content process. The `JSActors`_ registration for ``MigrationWizardParent`` and ``MigrationWizardChild`` also ensures that the actors only load for built-in documents.
+
+.. js:autoclass:: MigrationWizardParent
+ :members:
+
+``migration-dialog.html``
+=========================
+
+This document is meant for being loaded in a tab modal or window modal dialog, and embeds the ``MigrationWizard`` reusable component.
+
+Pages like ``about:preferences`` or ``about:welcome`` can embed the ``MigrationWizard`` component directly, rather than use ``migration-dialog.html``.
+
+
+.. _JSActors: /dom/ipc/jsactors.html
+.. _XrayVision: /dom/scriptSecurity/xray_vision.html
diff --git a/browser/components/migration/docs/migrators.rst b/browser/components/migration/docs/migrators.rst
new file mode 100644
index 0000000000..68479e93de
--- /dev/null
+++ b/browser/components/migration/docs/migrators.rst
@@ -0,0 +1,88 @@
+===================
+Migrators Reference
+===================
+
+MigratorBase class
+------------------
+.. js:autoclass:: MigratorBase
+ :members:
+
+Chrome and Chrome variant migrators
+-----------------------------------
+
+The ``ChromeProfileMigrator`` is subclassed ino order to provide migration capabilities for variants of the Chrome browser.
+
+ChromeProfileMigrator class
+===========================
+.. js:autoclass:: ChromeProfileMigrator
+ :members:
+
+BraveProfileMigrator class
+==========================
+.. js:autoclass:: BraveProfileMigrator
+ :members:
+
+CanaryProfileMigrator class
+===========================
+.. js:autoclass:: CanaryProfileMigrator
+ :members:
+
+ChromeBetaMigrator class
+========================
+.. js:autoclass:: ChromeBetaMigrator
+ :members:
+
+ChromeDevMigrator class
+=======================
+.. js:autoclass:: ChromeDevMigrator
+ :members:
+
+Chromium360seMigrator class
+===========================
+.. js:autoclass:: Chromium360seMigrator
+ :members:
+
+ChromiumEdgeMigrator class
+==========================
+.. js:autoclass:: ChromiumEdgeMigrator
+ :members:
+
+ChromiumEdgeBetaMigrator class
+==============================
+.. js:autoclass:: ChromiumEdgeBetaMigrator
+ :members:
+
+ChromiumProfileMigrator class
+=============================
+.. js:autoclass:: ChromiumProfileMigrator
+ :members:
+
+OperaProfileMigrator class
+==========================
+.. js:autoclass:: OperaProfileMigrator
+ :members:
+
+OperaGXProfileMigrator class
+============================
+.. js:autoclass:: OperaGXProfileMigrator
+ :members:
+
+VivaldiProfileMigrator class
+============================
+.. js:autoclass:: VivaldiProfileMigrator
+ :members:
+
+EdgeProfileMigrator class
+-------------------------
+.. js:autoclass:: EdgeProfileMigrator
+ :members:
+
+FirefoxProfileMigrator class
+----------------------------
+.. js:autoclass:: FirefoxProfileMigrator
+ :members:
+
+IEProfileMigrator class
+-----------------------
+.. js:autoclass:: IEProfileMigrator
+ :members:
diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn
new file mode 100644
index 0000000000..eab4afcd64
--- /dev/null
+++ b/browser/components/migration/jar.mn
@@ -0,0 +1,11 @@
+# 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)
+ content/browser/migration/migration-dialog.html (content/migration-dialog.html)
+ content/browser/migration/migration-wizard.mjs (content/migration-wizard.mjs)
+ content/browser/migration/migration-wizard-constants.mjs (content/migration-wizard-constants.mjs)
diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build
new file mode 100644
index 0000000000..59ff8682c4
--- /dev/null
+++ b/browser/components/migration/moz.build
@@ -0,0 +1,81 @@
+# -*- 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"]
+
+MOCHITEST_CHROME_MANIFESTS += ["tests/chrome/chrome.ini"]
+
+SPHINX_TREES["docs"] = "docs"
+
+JAR_MANIFESTS += ["jar.mn"]
+
+XPIDL_SOURCES += [
+ "nsIEdgeMigrationUtils.idl",
+]
+
+XPIDL_MODULE = "migration"
+
+EXTRA_JS_MODULES += [
+ "ChromeMigrationUtils.sys.mjs",
+ "ChromeProfileMigrator.sys.mjs",
+ "FirefoxProfileMigrator.sys.mjs",
+ "MigrationUtils.sys.mjs",
+ "MigratorBase.sys.mjs",
+ "ProfileMigrator.sys.mjs",
+]
+
+FINAL_TARGET_FILES.actors = [
+ "MigrationWizardChild.sys.mjs",
+ "MigrationWizardParent.sys.mjs",
+]
+
+if CONFIG["OS_ARCH"] == "WINNT":
+ if CONFIG["ENABLE_TESTS"]:
+ DIRS += [
+ "tests/unit/insertIEHistory",
+ ]
+ EXPORTS += [
+ "nsEdgeMigrationUtils.h",
+ ]
+ SOURCES += [
+ "nsEdgeMigrationUtils.cpp",
+ "nsIEHistoryEnumerator.cpp",
+ ]
+ EXTRA_JS_MODULES += [
+ "360seMigrationUtils.sys.mjs",
+ "ChromeWindowsLoginCrypto.sys.mjs",
+ "EdgeProfileMigrator.sys.mjs",
+ "ESEDBReader.sys.mjs",
+ "IEProfileMigrator.sys.mjs",
+ "MSMigrationUtils.sys.mjs",
+ ]
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa":
+ EXPORTS += [
+ "nsKeychainMigrationUtils.h",
+ ]
+ EXTRA_JS_MODULES += [
+ "ChromeMacOSLoginCrypto.sys.mjs",
+ "SafariProfileMigrator.sys.mjs",
+ ]
+ 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/nsEdgeMigrationUtils.cpp b/browser/components/migration/nsEdgeMigrationUtils.cpp
new file mode 100644
index 0000000000..a8d76c1405
--- /dev/null
+++ b/browser/components/migration/nsEdgeMigrationUtils.cpp
@@ -0,0 +1,61 @@
+/* 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 "nsEdgeMigrationUtils.h"
+
+#include "mozilla/dom/Promise.h"
+#include "nsCOMPtr.h"
+#include "nsIEventTarget.h"
+
+#include <windows.h>
+
+namespace mozilla {
+
+NS_IMPL_ISUPPORTS(nsEdgeMigrationUtils, nsIEdgeMigrationUtils)
+
+NS_IMETHODIMP
+nsEdgeMigrationUtils::IsDbLocked(nsIFile* aFile, JSContext* aCx,
+ dom::Promise** aPromise) {
+ NS_ENSURE_ARG_POINTER(aFile);
+
+ nsString path;
+ nsresult rv = aFile->GetPath(path);
+ NS_ENSURE_SUCCESS(rv, rv);
+
+ ErrorResult err;
+ RefPtr<dom::Promise> promise =
+ dom::Promise::Create(xpc::CurrentNativeGlobal(aCx), err);
+
+ if (MOZ_UNLIKELY(err.Failed())) {
+ return err.StealNSResult();
+ }
+
+ nsMainThreadPtrHandle<dom::Promise> promiseHolder(
+ new nsMainThreadPtrHolder<dom::Promise>("nsEdgeMigrationUtils Promise",
+ promise));
+
+ NS_DispatchBackgroundTask(NS_NewRunnableFunction(
+ __func__,
+ [promiseHolder = std::move(promiseHolder), path = std::move(path)]() {
+ HANDLE file = ::CreateFileW(path.get(), GENERIC_READ, FILE_SHARE_READ,
+ nullptr, OPEN_EXISTING, 0, nullptr);
+
+ bool locked = true;
+ if (file != INVALID_HANDLE_VALUE) {
+ locked = false;
+ ::CloseHandle(file);
+ }
+
+ NS_DispatchToMainThread(NS_NewRunnableFunction(
+ __func__, [promiseHolder = std::move(promiseHolder), locked]() {
+ promiseHolder.get()->MaybeResolve(locked);
+ }));
+ }));
+
+ promise.forget(aPromise);
+
+ return NS_OK;
+}
+
+} // namespace mozilla
diff --git a/browser/components/migration/nsEdgeMigrationUtils.h b/browser/components/migration/nsEdgeMigrationUtils.h
new file mode 100644
index 0000000000..d85fff10ad
--- /dev/null
+++ b/browser/components/migration/nsEdgeMigrationUtils.h
@@ -0,0 +1,24 @@
+
+/* 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 nsedgemigrationutils__h__
+#define nsedgemigrationutils__h__
+
+#include "nsISupportsImpl.h"
+#include "nsIEdgeMigrationUtils.h"
+
+namespace mozilla {
+
+class nsEdgeMigrationUtils final : public nsIEdgeMigrationUtils {
+ NS_DECL_ISUPPORTS
+ NS_DECL_NSIEDGEMIGRATIONUTILS
+
+ private:
+ ~nsEdgeMigrationUtils() = default;
+};
+
+} // namespace mozilla
+
+#endif
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/nsIEdgeMigrationUtils.idl b/browser/components/migration/nsIEdgeMigrationUtils.idl
new file mode 100644
index 0000000000..8c15d00251
--- /dev/null
+++ b/browser/components/migration/nsIEdgeMigrationUtils.idl
@@ -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/. */
+
+#include "nsISupports.idl"
+#include "nsIFile.idl"
+
+/**
+ * Utilities for migrating from legacy (non-Chromimum-based) Edge.
+ */
+[builtinclass, scriptable, uuid(9c7b7436-a17c-4c03-ba66-aeb5ae070126)]
+interface nsIEdgeMigrationUtils : nsISupports {
+ /**
+ * Determine if the Edge database is locked for writing.
+ *
+ * @param aFile The path to the Edge database.
+ *
+ * @returns A promise that is resolved to whether or not the given database
+ * could be opened for writing.
+ */
+ [implicit_jscontext]
+ Promise isDbLocked(in nsIFile aFile);
+};
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/chrome/chrome.ini b/browser/components/migration/tests/chrome/chrome.ini
new file mode 100644
index 0000000000..2df25e8304
--- /dev/null
+++ b/browser/components/migration/tests/chrome/chrome.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+skip-if = os == 'android'
+
+[test_migration_wizard.html]
diff --git a/browser/components/migration/tests/chrome/test_migration_wizard.html b/browser/components/migration/tests/chrome/test_migration_wizard.html
new file mode 100644
index 0000000000..50e9300aa6
--- /dev/null
+++ b/browser/components/migration/tests/chrome/test_migration_wizard.html
@@ -0,0 +1,33 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Basic tests for the Migration Wizard component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://browser/content/migration/migration-wizard.mjs" type="module"></script>
+ <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/>
+ <script>
+ "use strict";
+
+ /**
+ * Tests that the MigrationWizard:Init event is fired when the <migration-wizard>
+ * is added to the DOM.
+ */
+ add_task(async function test_init_event() {
+ let wiz = document.createElement("migration-wizard");
+ let content = document.getElementById("content");
+ let promise = new Promise(resolve => {
+ content.addEventListener("MigrationWizard:Init", resolve, { once: true });
+ })
+ content.appendChild(wiz);
+ await promise;
+ ok(true, "Saw MigrationWizard:Init event.");
+ });
+ </script>
+</head>
+<body>
+<p id="display"></p>
+<div id="content"></div>
+<pre id="test"></pre>
+</body>
+</html>
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..3ce4f52192
--- /dev/null
+++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py
@@ -0,0 +1,688 @@
+import os
+import time
+
+from marionette_driver.errors import NoAlertPresentException
+from marionette_harness import MarionetteTestCase
+
+
+# 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 resolve = arguments[arguments.length - 1];
+ global.FormHistory.update(updateDefinition).then(() => {
+ resolve(false);
+ }, error => {
+ resolve("Unexpected error in adding formhistory: " + error);
+ });
+ """,
+ 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 } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+ );
+ 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];
+ let { FxAccountsStorageManager } = ChromeUtils.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]})
+ .then(resolve);
+ """,
+ 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;
+ global.FormHistory.count({}).then(resolve);
+ """
+ )
+ 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;
+
+ window.addEventListener("SSWindowStateReady", function() {
+ window.addEventListener("SSTabRestored", function() {
+ resolve(Array.from(gBrowser.browsers, b => b.currentURI?.spec));
+ }, { capture: false, once: true });
+ }, { capture: false, once: true });
+
+ 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 });
+ }
+ };
+
+ Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", true);
+ mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true);
+ Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", false);
+ """ # NOQA: E501
+ )
+ self.assertSequenceEqual(tabURIs, self._expectedURLs)
+
+ def checkFxA(self):
+ result = self.runAsyncCode(
+ """
+ let { FxAccountsStorageManager } = ChromeUtils.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 = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+ ).Preferences;
+ global.FormHistory = ChromeUtils.import(
+ "resource://gre/modules/FormHistory.jsm"
+ ).FormHistory;
+ """ # NOQA: E501
+ )
+ self._formAutofillAvailable = self.runCode(
+ """
+ try {
+ global.formAutofillStorage = ChromeUtils.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(in_app=False, clean=True)
+ 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 prefsToKeep = Array.from(Services.prefs.getChildList("marionette."));
+ // Add all the modified preferences set from geckoinstance.py to avoid
+ // non-local connections.
+ prefsToKeep = prefsToKeep.concat(JSON.parse(
+ Services.env.get("MOZ_MARIONETTE_REQUIRED_PREFS")));
+ let prefObj = {};
+ for (let pref of prefsToKeep) {
+ prefObj[pref] = global.Preferences.get(pref);
+ }
+ Services.env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj));
+ Services.env.set("MOZ_RESET_PROFILE_RESTART", "1");
+ Services.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/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
new file mode 100644
index 0000000000..1835c33583
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
Binary files differ
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks
new file mode 100644
index 0000000000..f51195f54c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks
@@ -0,0 +1 @@
+Encrypted canonical bookmarks storage, since 360 SE 10
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb
new file mode 100644
index 0000000000..ea466a25bf
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb
@@ -0,0 +1,3 @@
+{
+ "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb
new file mode 100644
index 0000000000..ea466a25bf
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb
@@ -0,0 +1,3 @@
+{
+ "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb
new file mode 100644
index 0000000000..32b4002a32
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb
@@ -0,0 +1 @@
+Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb
new file mode 100644
index 0000000000..32b4002a32
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb
@@ -0,0 +1 @@
+Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
new file mode 100644
index 0000000000..440e7145bd
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
@@ -0,0 +1 @@
+Bookmarks storage in legacy SQLite format.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb
new file mode 100644
index 0000000000..d5d939629c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb
@@ -0,0 +1,3 @@
+{
+ "note": "Plain text bookmarks backup, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks
new file mode 100644
index 0000000000..6f47e5a55c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks
@@ -0,0 +1,3 @@
+{
+ "note": "Plain text canonical bookmarks storage, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State
new file mode 100644
index 0000000000..dd3fecce45
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State
@@ -0,0 +1,12 @@
+{
+ "profile": {
+ "info_cache": {
+ "Default": {
+ "name": "用户1"
+ }
+ }
+ },
+ "sync_login_info": {
+ "filepath": "0f3ab103a522f4463ecacc36d34eb996"
+ }
+}
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..ab1f34e419
--- /dev/null
+++ b/browser/components/migration/tests/unit/head_migration.js
@@ -0,0 +1,124 @@
+"use strict";
+
+var { MigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/MigrationUtils.sys.mjs"
+);
+var { LoginHelper } = ChromeUtils.import(
+ "resource://gre/modules/LoginHelper.jsm"
+);
+var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm");
+var { PlacesUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PlacesUtils.sys.mjs"
+);
+var { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+var { PromiseUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PromiseUtils.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { TestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TestUtils.sys.mjs"
+);
+var { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
+ Sqlite: "resource://gre/modules/Sqlite.sys.mjs",
+});
+
+// Initialize profile.
+var gProfD = do_get_profile();
+
+var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.importESModule(
+ "resource://testing-common/AppInfo.sys.mjs"
+);
+updateAppInfo();
+
+/**
+ * Migrates the requested resource and waits for the migration to be complete.
+ *
+ * @param {MigratorBase} migrator
+ * The migrator being used to migrate the data.
+ * @param {number} resourceType
+ * This is a bitfield with bits from nsIBrowserProfileMigrator flipped to indicate what
+ * resources should be migrated.
+ * @param {object|string|null} [aProfile=null]
+ * The profile to be migrated. If set to null, the default profile will be
+ * migrated.
+ * @param {boolean} succeeds
+ * True if this migration is expected to succeed.
+ * @returns {Promise<Array<string[]>>}
+ * An array of the results from each nsIObserver topics being observed to
+ * verify if the migration succeeded or failed. Those results are 2-element
+ * arrays of [subject, data].
+ */
+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.
+ *
+ * @param {string} key
+ * The nsIDirectoryService directory key to register a fake path for.
+ * For example: "AppData", "ULibDir".
+ * @param {nsIFile} file
+ * The nsIFile to map the key to. Note that this nsIFile should represent
+ * a directory and not an individual file.
+ * @see nsDirectoryServiceDefs.h for the list of directories that can be
+ * overridden.
+ */
+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..61ca96d48a
--- /dev/null
+++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build
@@ -0,0 +1,19 @@
+# -*- 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",
+ "uuid",
+]
+SOURCES += [
+ "InsertIEHistory.cpp",
+]
+
+NO_PGO = True
+DisableStlWrapping()
diff --git a/browser/components/migration/tests/unit/test_360seMigrationUtils.js b/browser/components/migration/tests/unit/test_360seMigrationUtils.js
new file mode 100644
index 0000000000..9dd7704317
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_360seMigrationUtils.js
@@ -0,0 +1,169 @@
+"use strict";
+
+const { Qihoo360seMigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/360seMigrationUtils.sys.mjs"
+);
+
+const parentPath = do_get_file("AppData/Roaming/360se6/User Data").path;
+const loggedInPath = "0f3ab103a522f4463ecacc36d34eb996";
+const loggedInBackup = PathUtils.join(
+ parentPath,
+ "Default",
+ loggedInPath,
+ "DailyBackup",
+ "360default_ori_2021_12_02.favdb"
+);
+const loggedOutBackup = PathUtils.join(
+ parentPath,
+ "Default",
+ "DailyBackup",
+ "360default_ori_2021_12_02.favdb"
+);
+
+function getSqlitePath(profileId) {
+ return PathUtils.join(parentPath, profileId, loggedInPath, "360sefav.dat");
+}
+
+add_task(async function test_360se10_logged_in() {
+ const sqlitePath = getSqlitePath("Default");
+ await IOUtils.setModificationTime(sqlitePath);
+ await IOUtils.copy(
+ PathUtils.join(parentPath, "Default", "360Bookmarks"),
+ PathUtils.join(parentPath, "Default", loggedInPath)
+ );
+ await IOUtils.copy(loggedOutBackup, loggedInBackup);
+
+ const alternativeBookmarks = await Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ {
+ bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"),
+ localState: {
+ sync_login_info: {
+ filepath: loggedInPath,
+ },
+ },
+ }
+ );
+ Assert.ok(
+ alternativeBookmarks.resource && alternativeBookmarks.resource.exists,
+ "Should return the legacy bookmark resource."
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.path,
+ undefined,
+ "Should not return any path to plain text bookmarks."
+ );
+});
+
+add_task(async function test_360se10_logged_in_outdated_sqlite() {
+ const sqlitePath = getSqlitePath("Default");
+ await IOUtils.setModificationTime(
+ sqlitePath,
+ new Date("2020-08-18").valueOf()
+ );
+
+ const alternativeBookmarks = await Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ {
+ bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"),
+ localState: {
+ sync_login_info: {
+ filepath: loggedInPath,
+ },
+ },
+ }
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.resource,
+ undefined,
+ "Should not return the outdated legacy bookmark resource."
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.path,
+ loggedInBackup,
+ "Should return path to the most recent plain text bookmarks backup."
+ );
+
+ await IOUtils.setModificationTime(sqlitePath);
+});
+
+add_task(async function test_360se10_logged_out() {
+ const alternativeBookmarks = await Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ {
+ bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"),
+ localState: {
+ sync_login_info: {
+ filepath: "",
+ },
+ },
+ }
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.resource,
+ undefined,
+ "Should not return the legacy bookmark resource."
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.path,
+ loggedOutBackup,
+ "Should return path to the most recent plain text bookmarks backup."
+ );
+});
+
+add_task(async function test_360se9_logged_in_outdated_sqlite() {
+ const sqlitePath = getSqlitePath("Default4SE9Test");
+ await IOUtils.setModificationTime(
+ sqlitePath,
+ new Date("2020-08-18").valueOf()
+ );
+
+ const alternativeBookmarks = await Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ {
+ bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"),
+ localState: {
+ sync_login_info: {
+ filepath: loggedInPath,
+ },
+ },
+ }
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.resource,
+ undefined,
+ "Should not return the legacy bookmark resource."
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.path,
+ PathUtils.join(
+ parentPath,
+ "Default4SE9Test",
+ loggedInPath,
+ "DailyBackup",
+ "360sefav_2020_08_28.favdb"
+ ),
+ "Should return path to the most recent plain text bookmarks backup."
+ );
+
+ await IOUtils.setModificationTime(sqlitePath);
+});
+
+add_task(async function test_360se9_logged_out() {
+ const alternativeBookmarks = await Qihoo360seMigrationUtils.getAlternativeBookmarks(
+ {
+ bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"),
+ localState: {
+ sync_login_info: {
+ filepath: "",
+ },
+ },
+ }
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.resource,
+ undefined,
+ "Should not return the legacy bookmark resource."
+ );
+ Assert.strictEqual(
+ alternativeBookmarks.path,
+ PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"),
+ "Should return path to the plain text canonical bookmarks."
+ );
+});
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..e976aac609
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js
@@ -0,0 +1,62 @@
+"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("chromium-360se");
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ 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.parentGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ 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..c82c959333
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js
@@ -0,0 +1,84 @@
+"use strict";
+
+const { ChromeMigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/ChromeMigrationUtils.sys.mjs"
+);
+
+// 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..f00402d95a
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js
@@ -0,0 +1,113 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { ChromeMigrationUtils } = ChromeUtils.importESModule(
+ "resource:///modules/ChromeMigrationUtils.sys.mjs"
+);
+
+function getRootPath() {
+ let dirKey;
+ if (AppConstants.platform == "win") {
+ dirKey = "LocalAppData";
+ } else if (AppConstants.platform == "macosx") {
+ dirKey = "ULibDir";
+ } else {
+ dirKey = "Home";
+ }
+ return Services.dirsvc.get(dirKey, Ci.nsIFile).path;
+}
+
+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,
+ PathUtils.join(getRootPath(), "Google", "Chrome", "User Data"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ PathUtils.join(getRootPath(), "Chromium", "User Data"),
+ "Should get the path of Chromium data directory."
+ );
+ Assert.equal(
+ canaryUserDataPath,
+ PathUtils.join(getRootPath(), "Google", "Chrome SxS", "User Data"),
+ "Should get the path of Canary data directory."
+ );
+ } else if (AppConstants.platform == "macosx") {
+ Assert.equal(
+ chromeUserDataPath,
+ PathUtils.join(getRootPath(), "Application Support", "Google", "Chrome"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ PathUtils.join(getRootPath(), "Application Support", "Chromium"),
+ "Should get the path of Chromium data directory."
+ );
+ Assert.equal(
+ canaryUserDataPath,
+ PathUtils.join(
+ getRootPath(),
+ "Application Support",
+ "Google",
+ "Chrome Canary"
+ ),
+ "Should get the path of Canary data directory."
+ );
+ } else {
+ Assert.equal(
+ chromeUserDataPath,
+ PathUtils.join(getRootPath(), ".config", "google-chrome"),
+ "Should get the path of Chrome data directory."
+ );
+ Assert.equal(
+ chromiumUserDataPath,
+ PathUtils.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 = PathUtils.join(
+ getRootPath(),
+ "Google",
+ "Chrome",
+ "User Data",
+ "Default",
+ "Extensions"
+ );
+ } else if (AppConstants.platform == "macosx") {
+ expectedPath = PathUtils.join(
+ getRootPath(),
+ "Application Support",
+ "Google",
+ "Chrome",
+ "Default",
+ "Extensions"
+ );
+ } else {
+ expectedPath = PathUtils.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..de1535cdab
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js
@@ -0,0 +1,218 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { CustomizableUI } = ChromeUtils.import(
+ "resource:///modules/CustomizableUI.jsm"
+);
+
+const { PlacesUIUtils } = ChromeUtils.importESModule(
+ "resource:///modules/PlacesUIUtils.sys.mjs"
+);
+
+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) {
+ 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());
+ }
+
+ await IOUtils.makeDirectory(target.path, {
+ createAncestor: true,
+ ignoreExisting: true,
+ });
+
+ target.append("Bookmarks");
+ await IOUtils.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 IOUtils.writeJSON(target.path, bookmarksData);
+
+ let migrator = await MigrationUtils.getMigrator(migratorKey);
+ // Sanity check for the source.
+ Assert.ok(await migrator.isSourceAvailable());
+
+ let itemsSeen = { bookmarks: 0, folders: 0 };
+ let listener = events => {
+ for (let event of events) {
+ 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");
+ const initialToolbarCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.toolbarGuid
+ );
+ const initialUnfiledCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.unfiledGuid
+ );
+ const initialmenuCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.menuGuid
+ );
+
+ await promiseMigration(
+ migrator,
+ MigrationUtils.resourceTypes.BOOKMARKS,
+ PROFILE
+ );
+ const postToolbarCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.toolbarGuid
+ );
+ const postUnfiledCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.unfiledGuid
+ );
+ const postmenuCount = await getFolderItemCount(
+ PlacesUtils.bookmarks.menuGuid
+ );
+ Assert.equal(
+ postUnfiledCount - initialUnfiledCount,
+ 105,
+ "Should have seen 105 items in unsorted bookmarks"
+ );
+ Assert.equal(
+ postToolbarCount - initialToolbarCount,
+ 105,
+ "Should have seen 105 items in toolbar"
+ );
+ Assert.equal(
+ postmenuCount - initialmenuCount,
+ 0,
+ "Should have seen 0 items in menu toolbar"
+ );
+
+ 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(
+ 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);
+});
+
+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);
+});
+
+async function getFolderItemCount(guid) {
+ let results = await PlacesUtils.promiseBookmarksTree(guid);
+
+ return results.itemsCount;
+}
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..c88a6380c2
--- /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.importESModule(
+ "resource:///modules/ChromeMigrationUtils.sys.mjs"
+);
+
+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_NOT_FOUND) {
+ 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..d98f4cd039
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js
@@ -0,0 +1,379 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+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.importESModule(
+ "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs"
+ );
+ 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.importESModule(
+ "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs"
+ );
+ 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, {
+ _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..777a348116
--- /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.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+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, {
+ _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..0bb81237cc
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js
@@ -0,0 +1,773 @@
+"use strict";
+
+const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm");
+const {
+ ESE,
+ KERNEL,
+ gLibs,
+ COLUMN_TYPES,
+ declareESEFunction,
+ loadLibraries,
+} = ChromeUtils.importESModule("resource:///modules/ESEDBReader.sys.mjs");
+const { EdgeProfileMigrator } = ChromeUtils.importESModule(
+ "resource:///modules/EdgeProfileMigrator.sys.mjs"
+);
+
+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,
+ },
+ ],
+ ])
+ );
+
+ // Manually create an EdgeProfileMigrator rather than going through
+ // MigrationUtils.getMigrator to avoid the user data availability check, since
+ // we're mocking out that stuff.
+ let migrator = new EdgeProfileMigrator();
+ let bookmarksMigrator = migrator.getBookmarksMigratorForTesting(db);
+ Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created");
+
+ 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.length,
+ MigrationUtils._importQuantities.bookmarks,
+ "Telemetry should have items"
+ );
+
+ 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);
+
+ for (let bookmark of seenBookmarks) {
+ let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title);
+ let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title);
+ if (bookmark.title == "Folder") {
+ 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.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.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.length,
+ MigrationUtils._importQuantities.bookmarks,
+ "Telemetry should have items"
+ );
+ 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..ed5f27e14f
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE7_passwords.js
@@ -0,0 +1,1371 @@
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "OSCrypto",
+ "resource://gre/modules/OSCrypto_win.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();
+ }
+}
+
+async function getFirstResourceOfType(type) {
+ let migrator = await MigrationUtils.getMigrator("ie");
+ 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")) {
+ await Assert.rejects(
+ 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 = await 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 = await 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..4df0c7737c
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js
@@ -0,0 +1,30 @@
+"use strict";
+
+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 itemCount = 0;
+ let listener = events => {
+ for (let event of events) {
+ if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) {
+ info("bookmark added: " + event.parentGuid);
+ itemCount++;
+ }
+ }
+ };
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+
+ 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."
+ );
+});
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..546b7bae88
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js
@@ -0,0 +1,25 @@
+"use strict";
+
+let tmpFile = FileUtils.getDir("TmpD", [], true);
+let dbConn;
+
+add_task(async function setup() {
+ tmpFile.append("TestDB");
+ dbConn = await Sqlite.openConnection({ path: tmpFile.path });
+
+ registerCleanupFunction(async () => {
+ await dbConn.close();
+ IOUtils.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..b316165871
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js
@@ -0,0 +1,69 @@
+"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 expectedParentGuids = [PlacesUtils.bookmarks.toolbarGuid];
+ let itemCount = 0;
+
+ let gotFolder = false;
+ let listener = events => {
+ for (let event of events) {
+ itemCount++;
+ if (
+ event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER &&
+ event.title == "Stuff"
+ ) {
+ gotFolder = true;
+ }
+ if (expectedParentGuids.length) {
+ let index = expectedParentGuids.indexOf(event.parentGuid);
+ Assert.ok(index != -1, "Found expected parent");
+ expectedParentGuids.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(!expectedParentGuids.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."
+ );
+ 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..5be6b740fc
--- /dev/null
+++ b/browser/components/migration/tests/unit/test_fx_telemetry.js
@@ -0,0 +1,269 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const { FirefoxProfileMigrator } = ChromeUtils.importESModule(
+ "resource:///modules/FirefoxProfileMigrator.sys.mjs"
+);
+
+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;
+}
+
+async function promiseMigrator(name, srcDir, targetDir) {
+ // As the FirefoxProfileMigrator is a startup-only migrator, we import its
+ // module and instantiate it directly rather than going through MigrationUtils,
+ // to bypass that availability check.
+ let migrator = new FirefoxProfileMigrator();
+ 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..7f5779ef71
--- /dev/null
+++ b/browser/components/migration/tests/unit/xpcshell.ini
@@ -0,0 +1,49 @@
+[DEFAULT]
+head = head_migration.js
+tags = condprof
+firefox-appdir = browser
+skip-if = toolkit == 'android' # bug 1730213
+prefs =
+ browser.migrate.showBookmarksToolbarAfterMigration=true
+support-files =
+ Library/**
+ AppData/**
+
+[test_360se_bookmarks.js]
+skip-if = os != "win"
+[test_360seMigrationUtils.js]
+run-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"
+ condprof # bug 1769154 - not realistic for condprof
+[test_Chrome_passwords_emptySource.js]
+skip-if = os != "win" && os != "mac"
+ condprof # bug 1769154 - not realistic for condprof
+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
+ (os == "win" && debug && bits == 32) # win10-32/debug
+[test_IE_history.js]
+skip-if =
+ os != "win"
+ os == "win" && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1807928
+[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"