diff options
Diffstat (limited to 'browser/components/migration')
67 files changed, 13578 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js new file mode 100644 index 0000000000..a0a0238764 --- /dev/null +++ b/browser/components/migration/.eslintrc.js @@ -0,0 +1,42 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + rules: { + "block-scoped-var": "error", + complexity: ["error", { max: 22 }], + "max-nested-callbacks": ["error", 3], + "no-extend-native": "error", + "no-fallthrough": [ + "error", + { + commentPattern: + ".*[Ii]ntentional(?:ly)?\\s+fall(?:ing)?[\\s-]*through.*", + }, + ], + "no-multi-str": "error", + "no-return-assign": "error", + "no-shadow": "error", + "no-unused-vars": ["error", { args: "after-used", vars: "all" }], + strict: ["error", "global"], + yoda: "error", + }, + + overrides: [ + { + files: "tests/unit/head*.js", + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/browser/components/migration/360seProfileMigrator.jsm b/browser/components/migration/360seProfileMigrator.jsm new file mode 100644 index 0000000000..940236eff9 --- /dev/null +++ b/browser/components/migration/360seProfileMigrator.jsm @@ -0,0 +1,388 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Sqlite", + "resource://gre/modules/Sqlite.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const kBookmarksFileName = "360sefav.db"; + +function copyToTempUTF8File(file, charset) { + let inputStream = Cc[ + "@mozilla.org/network/file-input-stream;1" + ].createInstance(Ci.nsIFileInputStream); + inputStream.init(file, -1, -1, 0); + let inputStr = NetUtil.readInputStreamToString( + inputStream, + inputStream.available(), + { charset } + ); + + // Use random to reduce the likelihood of a name collision in createUnique. + let rand = Math.floor(Math.random() * Math.pow(2, 15)); + let leafName = "mozilla-temp-" + rand; + let tempUTF8File = FileUtils.getFile( + "TmpD", + ["mozilla-temp-files", leafName], + true + ); + tempUTF8File.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600); + + let out = FileUtils.openAtomicFileOutputStream(tempUTF8File); + try { + let bufferedOut = Cc[ + "@mozilla.org/network/buffered-output-stream;1" + ].createInstance(Ci.nsIBufferedOutputStream); + bufferedOut.init(out, 4096); + try { + let converterOut = Cc[ + "@mozilla.org/intl/converter-output-stream;1" + ].createInstance(Ci.nsIConverterOutputStream); + converterOut.init(bufferedOut, "utf-8"); + try { + converterOut.writeString(inputStr || ""); + bufferedOut.QueryInterface(Ci.nsISafeOutputStream).finish(); + } finally { + converterOut.close(); + } + } finally { + bufferedOut.close(); + } + } finally { + out.close(); + } + + return tempUTF8File; +} + +function parseINIStrings(file) { + let factory = Cc["@mozilla.org/xpcom/ini-parser-factory;1"].getService( + Ci.nsIINIParserFactory + ); + let parser = factory.createINIParser(file); + let obj = {}; + for (let section of parser.getSections()) { + obj[section] = {}; + + for (let key of parser.getKeys(section)) { + obj[section][key] = parser.getString(section, key); + } + } + return obj; +} + +function getHash(aStr) { + // return the two-digit hexadecimal code for a byte + let toHexString = charCode => ("0" + charCode.toString(16)).slice(-2); + + let hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.MD5); + let stringStream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + stringStream.data = aStr; + hasher.updateFromStream(stringStream, -1); + + // convert the binary hash data to a hex string. + let binary = hasher.finish(false); + return Array.from(binary, (c, i) => toHexString(binary.charCodeAt(i))) + .join("") + .toLowerCase(); +} + +function Bookmarks(aProfileFolder) { + let file = aProfileFolder.clone(); + file.append(kBookmarksFileName); + + this._file = file; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return this._file.exists() && this._file.isReadable(); + }, + + migrate(aCallback) { + return (async () => { + let folderMap = new Map(); + let toolbarBMs = []; + + let connection = await Sqlite.openConnection({ + path: this._file.path, + }); + + let histogramBookmarkRoots = 0; + try { + let rows = await connection.execute( + `WITH RECURSIVE + bookmark(id, parent_id, is_folder, title, url, pos) AS ( + VALUES(0, -1, 1, '', '', 0) + UNION + SELECT f.id, f.parent_id, f.is_folder, f.title, f.url, f.pos + FROM tb_fav AS f + JOIN bookmark AS b ON f.parent_id = b.id + ORDER BY f.pos ASC + ) + SELECT id, parent_id, is_folder, title, url FROM bookmark WHERE id` + ); + + for (let row of rows) { + let id = parseInt(row.getResultByName("id"), 10); + let parent_id = parseInt(row.getResultByName("parent_id"), 10); + let is_folder = parseInt(row.getResultByName("is_folder"), 10); + let title = row.getResultByName("title"); + let url = row.getResultByName("url"); + + let bmToInsert; + + if (is_folder) { + bmToInsert = { + children: [], + title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + }; + folderMap.set(id, bmToInsert); + } else { + try { + new URL(url); + } catch (ex) { + Cu.reportError( + `Ignoring ${url} when importing from 360se because of exception: ${ex}` + ); + continue; + } + + bmToInsert = { + title, + url, + }; + } + + if (folderMap.has(parent_id)) { + folderMap.get(parent_id).children.push(bmToInsert); + } else if (parent_id === 0) { + toolbarBMs.push(bmToInsert); + } + } + } finally { + await connection.close(); + } + + if (toolbarBMs.length) { + histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder( + PlacesUtils.bookmarks.toolbarGuid + ) > PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + parentGuid = await MigrationUtils.createImportedBookmarksFolder( + "360se", + parentGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration(); + } + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS") + .add("360se", histogramBookmarkRoots); + })().then( + () => aCallback(true), + e => { + Cu.reportError(e); + aCallback(false); + } + ); + }, +}; + +function Qihoo360seProfileMigrator() { + let paths = [ + // for v6 and above + { + users: ["360se6", "apps", "data", "users"], + defaultUser: "default", + }, + // for earlier versions + { + users: ["360se"], + defaultUser: "data", + }, + ]; + this._usersDir = null; + this._defaultUserPath = null; + for (let path of paths) { + let usersDir = FileUtils.getDir("AppData", path.users, false); + if (usersDir.exists()) { + this._usersDir = usersDir; + this._defaultUserPath = path.defaultUser; + break; + } + } +} + +Qihoo360seProfileMigrator.prototype = Object.create(MigratorPrototype); + +Qihoo360seProfileMigrator.prototype.getSourceProfiles = function() { + if ("__sourceProfiles" in this) { + return this.__sourceProfiles; + } + + if (!this._usersDir) { + this.__sourceProfiles = []; + return this.__sourceProfiles; + } + + let profiles = []; + let noLoggedInUser = true; + try { + let loginIni = this._usersDir.clone(); + loginIni.append("login.ini"); + if (!loginIni.exists()) { + throw new Error("360 Secure Browser's 'login.ini' does not exist."); + } + if (!loginIni.isReadable()) { + throw new Error( + "360 Secure Browser's 'login.ini' file could not be read." + ); + } + + let loginIniInUtf8 = copyToTempUTF8File(loginIni, "GBK"); + let loginIniObj = parseINIStrings(loginIniInUtf8); + try { + loginIniInUtf8.remove(false); + } catch (ex) {} + + let nowLoginEmail = loginIniObj.NowLogin && loginIniObj.NowLogin.email; + + /* + * NowLogin section may: + * 1. be missing or without email, before any user logs in. + * 2. represents the current logged in user + * 3. represents the most recent logged in user + * + * In the second case, user represented by NowLogin should be the first + * profile; otherwise the default user should be selected by default. + */ + if (nowLoginEmail) { + if (loginIniObj.NowLogin.IsLogined === "1") { + noLoggedInUser = false; + } + + profiles.push({ + id: this._getIdFromConfig(loginIniObj.NowLogin), + name: nowLoginEmail, + }); + } + + for (let section in loginIniObj) { + if ( + !loginIniObj[section].email || + (nowLoginEmail && loginIniObj[section].email == nowLoginEmail) + ) { + continue; + } + + profiles.push({ + id: this._getIdFromConfig(loginIniObj[section]), + name: loginIniObj[section].email, + }); + } + } catch (e) { + Cu.reportError("Error detecting 360 Secure Browser profiles: " + e); + } finally { + profiles[noLoggedInUser ? "unshift" : "push"]({ + id: this._defaultUserPath, + name: "Default", + }); + } + + this.__sourceProfiles = profiles.filter(profile => { + let resources = this.getResources(profile); + return resources && !!resources.length; + }); + return this.__sourceProfiles; +}; + +Qihoo360seProfileMigrator.prototype._getIdFromConfig = function(aConfig) { + return aConfig.UserMd5 || getHash(aConfig.email); +}; + +Qihoo360seProfileMigrator.prototype.getResources = function(aProfile) { + let profileFolder = this._usersDir.clone(); + profileFolder.append(aProfile.id); + + if (!profileFolder.exists()) { + return []; + } + + let resources = [new Bookmarks(profileFolder)]; + return resources.filter(r => r.exists); +}; + +Qihoo360seProfileMigrator.prototype.getLastUsedDate = async function() { + let sourceProfiles = await this.getSourceProfiles(); + let bookmarksPaths = sourceProfiles.map(({ id }) => { + return OS.Path.join(this._usersDir.path, id, kBookmarksFileName); + }); + if (!bookmarksPaths.length) { + return new Date(0); + } + let datePromises = bookmarksPaths.map(path => { + return OS.File.stat(path) + .catch(() => null) + .then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); +}; + +Qihoo360seProfileMigrator.prototype.classDescription = + "360 Secure Browser Profile Migrator"; +Qihoo360seProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=360se"; +Qihoo360seProfileMigrator.prototype.classID = Components.ID( + "{d0037b95-296a-4a4e-94b2-c3d075d20ab1}" +); + +var EXPORTED_SYMBOLS = ["Qihoo360seProfileMigrator"]; diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.jsm b/browser/components/migration/ChromeMacOSLoginCrypto.jsm new file mode 100644 index 0000000000..f3d017615b --- /dev/null +++ b/browser/components/migration/ChromeMacOSLoginCrypto.jsm @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on macOS. + */ + +var EXPORTED_SYMBOLS = ["ChromeMacOSLoginCrypto"]; + +Cu.importGlobalProperties(["crypto"]); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyServiceGetter( + this, + "gKeychainUtils", + "@mozilla.org/profile/migrator/keychainmigrationutils;1", + "nsIKeychainMigrationUtils" +); + +const gTextEncoder = new TextEncoder(); +const gTextDecoder = new TextDecoder(); + +/** + * From macOS' CommonCrypto/CommonCryptor.h + */ +const kCCBlockSizeAES128 = 16; + +/* Chromium constants */ + +/** + * kSalt from Chromium. + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const SALT = "saltysalt"; + +/** + * kDerivedKeySizeInBits from Chromium. + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const DERIVED_KEY_SIZE_BITS = 128; + +/** + * kEncryptionIterations from Chromium. + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ITERATIONS = 1003; + +/** + * kEncryptionVersionPrefix from Chromium. + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ENCRYPTION_VERSION_PREFIX = "v10"; + +/** + * The initialization vector is 16 space characters (character code 32 in decimal). + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const IV = new Uint8Array(kCCBlockSizeAES128).fill(32); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. This isn't implemented as OSCrypto_mac.js since + * it isn't calling into encryption functions provided by macOS but instead + * relies on OS encryption key storage in Keychain. The algorithms here are + * specific to what is needed for Chrome login storage on macOS. + */ +class ChromeMacOSLoginCrypto { + /** + * @param {string} serviceName of the Keychain Item to use to derive a key. + * @param {string} accountName of the Keychain Item to use to derive a key. + * @param {string?} [testingPassphrase = null] A string to use as the passphrase + * to derive a key for testing purposes rather than retrieving + * it from the macOS Keychain since we don't yet have a way to + * mock the Keychain auth dialog. + */ + constructor(serviceName, accountName, testingPassphrase = null) { + // We still exercise the keychain migration utils code when using a + // `testingPassphrase` in order to get some test coverage for that + // component, even though it's expected to throw since a login item with the + // service name and account name usually won't be found. + let encKey = testingPassphrase; + try { + encKey = gKeychainUtils.getGenericPassword(serviceName, accountName); + } catch (ex) { + if (!testingPassphrase) { + throw ex; + } + } + + this.ALGORITHM = "AES-CBC"; + + this._keyPromise = crypto.subtle + .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [ + "deriveKey", + ]) + .then(key => { + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: gTextEncoder.encode(SALT), + iterations: ITERATIONS, + hash: "SHA-1", + }, + key, + { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS }, + false, + ["decrypt", "encrypt"] + ); + }) + .catch(Cu.reportError); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + let len = binary_string.length; + let bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {array} ciphertextArray ciphertext prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertextArray) { + let ciphertext = this.arrayToString(ciphertextArray); + if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) { + throw new Error("Unknown encryption version"); + } + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + let plaintext = await crypto.subtle.decrypt( + { name: this.ALGORITHM, iv: IV }, + key, + this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length)) + ); + return gTextDecoder.decode(plaintext); + } + + /** + * @param {USVString} plaintext to encrypt + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext) { + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + let ciphertext = await crypto.subtle.encrypt( + { name: this.ALGORITHM, iv: IV }, + key, + gTextEncoder.encode(plaintext) + ); + return ( + ENCRYPTION_VERSION_PREFIX + + String.fromCharCode(...new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ChromeMigrationUtils.jsm b/browser/components/migration/ChromeMigrationUtils.jsm new file mode 100644 index 0000000000..2abf12c12a --- /dev/null +++ b/browser/components/migration/ChromeMigrationUtils.jsm @@ -0,0 +1,449 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ChromeMigrationUtils"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +XPCOMUtils.defineLazyModuleGetters(this, { + AppConstants: "resource://gre/modules/AppConstants.jsm", + LoginHelper: "resource://gre/modules/LoginHelper.jsm", + MigrationUtils: "resource:///modules/MigrationUtils.jsm", + OS: "resource://gre/modules/osfile.jsm", + Services: "resource://gre/modules/Services.jsm", +}); + +const S100NS_FROM1601TO1970 = 0x19db1ded53e8000; +const S100NS_PER_MS = 10; + +var ChromeMigrationUtils = { + // Supported browsers with importable logins. + CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"], + + _extensionVersionDirectoryNames: {}, + + // The cache for the locale strings. + // For example, the data could be: + // { + // "profile-id-1": { + // "extension-id-1": { + // "name": { + // "message": "Fake App 1" + // } + // }, + // } + _extensionLocaleStrings: {}, + + get supportsLoginsForPlatform() { + return ["macosx", "win"].includes(AppConstants.platform); + }, + + /** + * Get all extensions installed in a specific profile. + * @param {String} profileId - A Chrome user profile ID. For example, "Profile 1". + * @returns {Array} All installed Chrome extensions information. + */ + async getExtensionList(profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let path = this.getExtensionPath(profileId); + let iterator = new OS.File.DirectoryIterator(path); + let extensionList = []; + await iterator + .forEach(async entry => { + if (entry.isDir) { + let extensionInformation = await this.getExtensionInformation( + entry.name, + profileId + ); + if (extensionInformation) { + extensionList.push(extensionInformation); + } + } + }) + .catch(ex => Cu.reportError(ex)); + return extensionList; + }, + + /** + * Get information of a specific Chrome extension. + * @param {String} extensionId - The extension ID. + * @param {String} profileId - The user profile's ID. + * @retruns {Object} The Chrome extension information. + */ + async getExtensionInformation(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionInformation = null; + try { + let manifestPath = this.getExtensionPath(profileId); + manifestPath = OS.Path.join(manifestPath, extensionId); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + let directories = await this._getSortedByVersionSubDirectoryNames( + manifestPath + ); + if (!directories[0]) { + return null; + } + + manifestPath = OS.Path.join( + manifestPath, + directories[0], + "manifest.json" + ); + let manifest = await OS.File.read(manifestPath, { encoding: "utf-8" }); + manifest = JSON.parse(manifest); + // No app attribute means this is a Chrome extension not a Chrome app. + if (!manifest.app) { + const DEFAULT_LOCALE = manifest.default_locale; + let name = await this._getLocaleString( + manifest.name, + DEFAULT_LOCALE, + extensionId, + profileId + ); + let description = await this._getLocaleString( + manifest.description, + DEFAULT_LOCALE, + extensionId, + profileId + ); + if (name) { + extensionInformation = { + id: extensionId, + name, + description, + }; + } else { + throw new Error("Cannot read the Chrome extension's name property."); + } + } + } catch (ex) { + Cu.reportError(ex); + } + return extensionInformation; + }, + + /** + * Get the manifest's locale string. + * @param {String} key - The key of a locale string, for example __MSG_name__. + * @param {String} locale - The specific language of locale string. + * @param {String} extensionId - The extension ID. + * @param {String} profileId - The user profile's ID. + * @retruns {String} The locale string. + */ + async _getLocaleString(key, locale, extensionId, profileId) { + // Return the key string if it is not a locale key. + // The key string starts with "__MSG_" and ends with "__". + // For example, "__MSG_name__". + // https://developer.chrome.com/apps/i18n + if (!key.startsWith("__MSG_") || !key.endsWith("__")) { + return key; + } + + let localeString = null; + try { + let localeFile; + if ( + this._extensionLocaleStrings[profileId] && + this._extensionLocaleStrings[profileId][extensionId] + ) { + localeFile = this._extensionLocaleStrings[profileId][extensionId]; + } else { + if (!this._extensionLocaleStrings[profileId]) { + this._extensionLocaleStrings[profileId] = {}; + } + let localeFilePath = this.getExtensionPath(profileId); + localeFilePath = OS.Path.join(localeFilePath, extensionId); + let directories = await this._getSortedByVersionSubDirectoryNames( + localeFilePath + ); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + localeFilePath = OS.Path.join( + localeFilePath, + directories[0], + "_locales", + locale, + "messages.json" + ); + localeFile = await OS.File.read(localeFilePath, { encoding: "utf-8" }); + localeFile = JSON.parse(localeFile); + this._extensionLocaleStrings[profileId][extensionId] = localeFile; + } + const PREFIX_LENGTH = 6; + const SUFFIX_LENGTH = 2; + // Get the locale key from the string with locale prefix and suffix. + // For example, it will get the "name" sub-string from the "__MSG_name__" string. + key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH); + if (localeFile[key] && localeFile[key].message) { + localeString = localeFile[key].message; + } + } catch (ex) { + Cu.reportError(ex); + } + return localeString; + }, + + /** + * Check that a specific extension is installed or not. + * @param {String} extensionId - The extension ID. + * @param {String} profileId - The user profile's ID. + * @returns {Boolean} Return true if the extension is installed otherwise return false. + */ + async isExtensionInstalled(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionPath = this.getExtensionPath(profileId); + let isInstalled = await OS.File.exists( + OS.Path.join(extensionPath, extensionId) + ); + return isInstalled; + }, + + /** + * Get the last used user profile's ID. + * @returns {String} The last used user profile's ID. + */ + async getLastUsedProfileId() { + let localState = await this.getLocalState(); + return localState ? localState.profile.last_used : "Default"; + }, + + /** + * Get the local state file content. + * @param {String} dataPath the type of Chrome data we're looking for (Chromium, Canary, etc.) + * @returns {Object} The JSON-based content. + */ + async getLocalState(dataPath = "Chrome") { + let localState = null; + try { + let localStatePath = PathUtils.join( + this.getDataPath(dataPath), + "Local State" + ); + localState = JSON.parse(await IOUtils.readUTF8(localStatePath)); + } catch (ex) { + // Don't report the error if it's just a file not existing. + if (ex.name != "NotFoundError") { + Cu.reportError(ex); + } + throw ex; + } + return localState; + }, + + /** + * Get the path of Chrome extension directory. + * @param {String} profileId - The user profile's ID. + * @returns {String} The path of Chrome extension directory. + */ + getExtensionPath(profileId) { + return PathUtils.join(this.getDataPath(), profileId, "Extensions"); + }, + + /** + * Get the path of an application data directory. + * @param {String} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc. + * Defaults to "Chrome". + * @returns {String} The path of application data directory. + */ + getDataPath(chromeProjectName = "Chrome") { + const SUB_DIRECTORIES = { + win: { + Chrome: ["Google", "Chrome"], + "Chrome Beta": ["Google", "Chrome Beta"], + Chromium: ["Chromium"], + Canary: ["Google", "Chrome SxS"], + Edge: ["Microsoft", "Edge"], + "Edge Beta": ["Microsoft", "Edge Beta"], + }, + macosx: { + Chrome: ["Google", "Chrome"], + Chromium: ["Chromium"], + Canary: ["Google", "Chrome Canary"], + Edge: ["Microsoft Edge"], + "Edge Beta": ["Microsoft Edge Beta"], + }, + linux: { + Chrome: ["google-chrome"], + "Chrome Beta": ["google-chrome-beta"], + "Chrome Dev": ["google-chrome-unstable"], + Chromium: ["chromium"], + // Canary is not available on Linux. + // Edge is not available on Linux. + }, + }; + let subfolders = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName]; + if (!subfolders) { + return null; + } + + let rootDir; + if (AppConstants.platform == "win") { + rootDir = "LocalAppData"; + subfolders = subfolders.concat(["User Data"]); + } else if (AppConstants.platform == "macosx") { + rootDir = "ULibDir"; + subfolders = ["Application Support"].concat(subfolders); + } else { + rootDir = "Home"; + subfolders = [".config"].concat(subfolders); + } + try { + let target = Services.dirsvc.get(rootDir, Ci.nsIFile); + for (let subfolder of subfolders) { + target.append(subfolder); + } + return target.path; + } catch (ex) { + // The path logic here shouldn't error, so log it: + Cu.reportError(ex); + } + return null; + }, + + /** + * Get the directory objects sorted by version number. + * @param {String} path - The path to the extension directory. + * otherwise return all file/directory object. + * @returns {Array} The file/directory object array. + */ + async _getSortedByVersionSubDirectoryNames(path) { + if (this._extensionVersionDirectoryNames[path]) { + return this._extensionVersionDirectoryNames[path]; + } + + let iterator = new OS.File.DirectoryIterator(path); + let entries = []; + await iterator + .forEach(async entry => { + if (entry.isDir) { + entries.push(entry.name); + } + }) + .catch(ex => { + Cu.reportError(ex); + entries = []; + }); + // The directory name is the version number string of the extension. + // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2. + // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again. + // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc + entries.sort((a, b) => Services.vc.compare(b, a)); + + this._extensionVersionDirectoryNames[path] = entries; + return entries; + }, + + /** + * Convert Chrome time format to Date object + * + * @param aTime + * Chrome time + * @param aFallbackValue + * a date or timestamp (valid argument for the Date constructor) + * that will be used if the chrometime value passed is invalid. + * @return converted Date object + * @note Google Chrome uses FILETIME / 10 as time. + * FILETIME is based on same structure of Windows. + */ + chromeTimeToDate(aTime, aFallbackValue) { + // The date value may be 0 in some cases. Because of the subtraction below, + // that'd generate a date before the unix epoch, which can upset consumers + // due to the unix timestamp then being negative. Catch this case: + if (!aTime) { + return new Date(aFallbackValue); + } + return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000); + }, + + /** + * Convert Date object to Chrome time format + * + * @param aDate + * Date object or integer equivalent + * @return Chrome time + * @note For details on Chrome time, see chromeTimeToDate. + */ + dateToChromeTime(aDate) { + return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS; + }, + + /** + * Returns an array of chromium browser ids that have importable logins. + */ + _importableLoginsCache: null, + async getImportableLogins(formOrigin) { + // Only provide importable if we actually support importing. + if (!this.supportsLoginsForPlatform) { + return undefined; + } + + // Lazily fill the cache with all importable login browsers. + if (!this._importableLoginsCache) { + this._importableLoginsCache = new Map(); + + // Just handle these chromium-based browsers for now. + for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + // Skip if there's no profile data. + const migrator = await MigrationUtils.getMigrator(browserId); + if (!migrator) { + continue; + } + + // Check each profile for logins. + const dataPath = await migrator.wrappedJSObject._getChromeUserDataPathIfExists(); + for (const profile of await migrator.getSourceProfiles()) { + const path = OS.Path.join(dataPath, profile.id, "Login Data"); + // Skip if login data is missing. + if (!(await OS.File.exists(path))) { + Cu.reportError(`Missing file at ${path}`); + continue; + } + + try { + for (const row of await MigrationUtils.getRowsFromDBWithoutLocks( + path, + `Importable ${browserId} logins`, + `SELECT origin_url + FROM logins + WHERE blacklisted_by_user = 0` + )) { + const url = row.getString(0); + try { + // Initialize an array if it doesn't exist for the origin yet. + const origin = LoginHelper.getLoginOrigin(url); + const entries = this._importableLoginsCache.get(origin) || []; + if (!entries.length) { + this._importableLoginsCache.set(origin, entries); + } + + // Add the browser if it doesn't exist yet. + if (!entries.includes(browserId)) { + entries.push(browserId); + } + } catch (ex) { + Cu.reportError( + `Failed to process importable url ${url} from ${browserId} ${ex}` + ); + } + } + } catch (ex) { + Cu.reportError( + `Failed to get importable logins from ${browserId} ${ex}` + ); + } + } + } + } + return this._importableLoginsCache.get(formOrigin); + }, +}; diff --git a/browser/components/migration/ChromeProfileMigrator.jsm b/browser/components/migration/ChromeProfileMigrator.jsm new file mode 100644 index 0000000000..66a1876467 --- /dev/null +++ b/browser/components/migration/ChromeProfileMigrator.jsm @@ -0,0 +1,757 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 et */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const AUTH_TYPE = { + SCHEME_HTML: 0, + SCHEME_BASIC: 1, + SCHEME_DIGEST: 2, +}; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { ChromeMigrationUtils } = ChromeUtils.import( + "resource:///modules/ChromeMigrationUtils.jsm" +); +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm" +); + +/** + * Converts an array of chrome bookmark objects into one our own places code + * understands. + * + * @param items + * bookmark items to be inserted on this parent + * @param errorAccumulator + * function that gets called with any errors thrown so we don't drop them on the floor. + */ +function convertBookmarks(items, errorAccumulator) { + let itemsToInsert = []; + for (let item of items) { + try { + if (item.type == "url") { + if (item.url.trim().startsWith("chrome:")) { + // Skip invalid internal URIs. Creating an actual URI always reports + // messages to the console because Gecko has its own concept of how + // chrome:// URIs should be formed, so we avoid doing that. + continue; + } + if (item.url.trim().startsWith("edge:")) { + // Don't import internal Microsoft Edge URIs as they won't resolve within Firefox. + continue; + } + itemsToInsert.push({ url: item.url, title: item.name }); + } else if (item.type == "folder") { + let folderItem = { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.name, + }; + folderItem.children = convertBookmarks(item.children, errorAccumulator); + itemsToInsert.push(folderItem); + } + } catch (ex) { + Cu.reportError(ex); + errorAccumulator(ex); + } + } + return itemsToInsert; +} + +function ChromeProfileMigrator() { + this._chromeUserDataPathSuffix = "Chrome"; +} + +ChromeProfileMigrator.prototype = Object.create(MigratorPrototype); + +ChromeProfileMigrator.prototype._keychainServiceName = "Chrome Safe Storage"; +ChromeProfileMigrator.prototype._keychainAccountName = "Chrome"; + +ChromeProfileMigrator.prototype._getChromeUserDataPathIfExists = async function() { + if (this._chromeUserDataPath) { + return this._chromeUserDataPath; + } + let path = ChromeMigrationUtils.getDataPath(this._chromeUserDataPathSuffix); + let exists = await OS.File.exists(path); + if (exists) { + this._chromeUserDataPath = path; + } else { + this._chromeUserDataPath = null; + } + return this._chromeUserDataPath; +}; + +ChromeProfileMigrator.prototype.getResources = async function Chrome_getResources( + aProfile +) { + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (chromeUserDataPath) { + let profileFolder = OS.Path.join(chromeUserDataPath, aProfile.id); + if (await OS.File.exists(profileFolder)) { + let localePropertySuffix = MigrationUtils._getLocalePropertyForBrowser( + this.getBrowserKey() + ).replace(/^source-name-/, ""); + let possibleResourcePromises = [ + GetBookmarksResource( + profileFolder, + localePropertySuffix, + this.getBrowserKey() + ), + GetHistoryResource(profileFolder), + GetCookiesResource(profileFolder), + ]; + if (ChromeMigrationUtils.supportsLoginsForPlatform) { + possibleResourcePromises.push( + this._GetPasswordsResource(profileFolder) + ); + } + let possibleResources = await Promise.all(possibleResourcePromises); + return possibleResources.filter(r => r != null); + } + } + return []; +}; + +ChromeProfileMigrator.prototype.getLastUsedDate = async function Chrome_getLastUsedDate() { + let sourceProfiles = await this.getSourceProfiles(); + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return new Date(0); + } + let datePromises = sourceProfiles.map(async profile => { + let basePath = OS.Path.join(chromeUserDataPath, profile.id); + let fileDatePromises = ["Bookmarks", "History", "Cookies"].map( + async leafName => { + let path = OS.Path.join(basePath, leafName); + let info = await OS.File.stat(path).catch(() => null); + return info ? info.lastModificationDate : 0; + } + ); + let dates = await Promise.all(fileDatePromises); + return Math.max(...dates); + }); + let datesOuter = await Promise.all(datePromises); + datesOuter.push(0); + return new Date(Math.max(...datesOuter)); +}; + +ChromeProfileMigrator.prototype.getSourceProfiles = async function Chrome_getSourceProfiles() { + if ("__sourceProfiles" in this) { + return this.__sourceProfiles; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return []; + } + + let localState; + let profiles = []; + try { + localState = await ChromeMigrationUtils.getLocalState( + this._chromeUserDataPathSuffix + ); + let info_cache = localState.profile.info_cache; + for (let profileFolderName in info_cache) { + profiles.push({ + id: profileFolderName, + name: info_cache[profileFolderName].name || profileFolderName, + }); + } + } catch (e) { + // Avoid reporting NotFoundErrors from trying to get local state. + if (localState || e.name != "NotFoundError") { + Cu.reportError("Error detecting Chrome profiles: " + e); + } + // If we weren't able to detect any profiles above, fallback to the Default profile. + let defaultProfilePath = PathUtils.join(chromeUserDataPath, "Default"); + if (await IOUtils.exists(defaultProfilePath)) { + profiles = [ + { + id: "Default", + name: "Default", + }, + ]; + } + } + + let profileResources = await Promise.all( + profiles.map(async profile => ({ + profile, + resources: await this.getResources(profile), + })) + ); + + // Only list profiles from which any data can be imported + this.__sourceProfiles = profileResources + .filter(({ resources }) => { + return resources && !!resources.length; + }, this) + .map(({ profile }) => profile); + return this.__sourceProfiles; +}; + +Object.defineProperty(ChromeProfileMigrator.prototype, "sourceLocked", { + get: function Chrome_sourceLocked() { + // There is an exclusive lock on some SQLite databases. Assume they are locked for now. + return true; + }, +}); + +async function GetBookmarksResource( + aProfileFolder, + aLocalePropertySuffix, + aBrowserKey +) { + let bookmarksPath = OS.Path.join(aProfileFolder, "Bookmarks"); + if (!(await OS.File.exists(bookmarksPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate(aCallback) { + return (async function() { + let gotErrors = false; + let errorGatherer = function() { + gotErrors = true; + }; + // Parse Chrome bookmark file that is JSON format + let bookmarkJSON = await OS.File.read(bookmarksPath, { + encoding: "UTF-8", + }); + let roots = JSON.parse(bookmarkJSON).roots; + let histogramBookmarkRoots = 0; + + // Importing bookmark bar items + if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) { + // Toolbar + histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = convertBookmarks( + roots.bookmark_bar.children, + errorGatherer + ); + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder( + PlacesUtils.bookmarks.toolbarGuid + ) > PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + parentGuid = await MigrationUtils.createImportedBookmarksFolder( + aLocalePropertySuffix, + parentGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration(); + } + + // Importing bookmark menu items + if (roots.other.children && roots.other.children.length) { + // Bookmark menu + histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU; + let parentGuid = PlacesUtils.bookmarks.menuGuid; + let bookmarks = convertBookmarks(roots.other.children, errorGatherer); + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(PlacesUtils.bookmarks.menuGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + parentGuid = await MigrationUtils.createImportedBookmarksFolder( + aLocalePropertySuffix, + parentGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + if (gotErrors) { + throw new Error("The migration included errors."); + } + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS") + .add(aBrowserKey, histogramBookmarkRoots); + })().then( + () => aCallback(true), + () => aCallback(false) + ); + }, + }; +} + +async function GetHistoryResource(aProfileFolder) { + let historyPath = OS.Path.join(aProfileFolder, "History"); + if (!(await OS.File.exists(historyPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + migrate(aCallback) { + (async function() { + const MAX_AGE_IN_DAYS = Services.prefs.getIntPref( + "browser.migrate.chrome.history.maxAgeInDays" + ); + const LIMIT = Services.prefs.getIntPref( + "browser.migrate.chrome.history.limit" + ); + + let query = + "SELECT url, title, last_visit_time, typed_count FROM urls WHERE hidden = 0"; + if (MAX_AGE_IN_DAYS) { + let maxAge = ChromeMigrationUtils.dateToChromeTime( + Date.now() - MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000 + ); + query += " AND last_visit_time > " + maxAge; + } + if (LIMIT) { + query += " ORDER BY last_visit_time DESC LIMIT " + LIMIT; + } + + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + query + ); + let pageInfos = []; + let fallbackVisitDate = new Date(); + for (let row of rows) { + try { + // if having typed_count, we changes transition type to typed. + let transition = PlacesUtils.history.TRANSITIONS.LINK; + if (row.getResultByName("typed_count") > 0) { + transition = PlacesUtils.history.TRANSITIONS.TYPED; + } + + pageInfos.push({ + title: row.getResultByName("title"), + url: new URL(row.getResultByName("url")), + visits: [ + { + transition, + date: ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("last_visit_time"), + fallbackVisitDate + ), + }, + ], + }); + } catch (e) { + Cu.reportError(e); + } + } + + if (pageInfos.length) { + await MigrationUtils.insertVisitsWrapper(pageInfos); + } + })().then( + () => { + aCallback(true); + }, + ex => { + Cu.reportError(ex); + aCallback(false); + } + ); + }, + }; +} + +async function GetCookiesResource(aProfileFolder) { + let cookiesPath = OS.Path.join(aProfileFolder, "Cookies"); + if (!(await OS.File.exists(cookiesPath))) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.COOKIES, + + async migrate(aCallback) { + // Get columns names and set is_sceure, is_httponly fields accordingly. + let columns = await MigrationUtils.getRowsFromDBWithoutLocks( + cookiesPath, + "Chrome cookies", + `PRAGMA table_info(cookies)` + ).catch(ex => { + Cu.reportError(ex); + aCallback(false); + }); + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!columns) { + return; + } + columns = columns.map(c => c.getResultByName("name")); + let isHttponly = columns.includes("is_httponly") + ? "is_httponly" + : "httponly"; + let isSecure = columns.includes("is_secure") ? "is_secure" : "secure"; + + let source_scheme = columns.includes("source_scheme") + ? "source_scheme" + : `"${Ci.nsICookie.SCHEME_UNSET}" as source_scheme`; + + // We don't support decrypting cookies yet so only import plaintext ones. + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + cookiesPath, + "Chrome cookies", + `SELECT host_key, name, value, path, expires_utc, ${isSecure}, ${isHttponly}, encrypted_value, ${source_scheme} + FROM cookies + WHERE length(encrypted_value) = 0` + ).catch(ex => { + Cu.reportError(ex); + aCallback(false); + }); + + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + let fallbackExpiryDate = 0; + for (let row of rows) { + let host_key = row.getResultByName("host_key"); + if (host_key.match(/^\./)) { + // 1st character of host_key may be ".", so we have to remove it + host_key = host_key.substr(1); + } + + let schemeType = Ci.nsICookie.SCHEME_UNSET; + switch (row.getResultByName("source_scheme")) { + case 1: + schemeType = Ci.nsICookie.SCHEME_HTTP; + break; + case 2: + schemeType = Ci.nsICookie.SCHEME_HTTPS; + break; + } + + try { + let expiresUtc = + ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("expires_utc"), + fallbackExpiryDate + ) / 1000; + // No point adding cookies that don't have a valid expiry. + if (!expiresUtc) { + continue; + } + + Services.cookies.add( + host_key, + row.getResultByName("path"), + row.getResultByName("name"), + row.getResultByName("value"), + row.getResultByName(isSecure), + row.getResultByName(isHttponly), + false, + parseInt(expiresUtc), + {}, + Ci.nsICookie.SAMESITE_NONE, + schemeType + ); + } catch (e) { + Cu.reportError(e); + } + } + aCallback(true); + }, + }; +} + +ChromeProfileMigrator.prototype._GetPasswordsResource = async function( + aProfileFolder +) { + let loginPath = OS.Path.join(aProfileFolder, "Login Data"); + if (!(await OS.File.exists(loginPath))) { + return null; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + + async migrate(aCallback) { + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + `SELECT origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, scheme, date_created, + times_used FROM logins WHERE blacklisted_by_user = 0` + ).catch(ex => { + Cu.reportError(ex); + aCallback(false); + }); + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + // If there are no relevant rows, return before initializing crypto and + // thus prompting for Keychain access on macOS. + if (!rows.length) { + aCallback(true); + return; + } + + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.import( + "resource:///modules/ChromeWindowsLoginCrypto.jsm" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.import( + "resource:///modules/ChromeMacOSLoginCrypto.jsm" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + Cu.reportError(ex); + aCallback(false); + return; + } + + let logins = []; + let fallbackCreationDate = new Date(); + for (let row of rows) { + try { + let origin_url = NetUtil.newURI(row.getResultByName("origin_url")); + // Ignore entries for non-http(s)/ftp URLs because we likely can't + // use them anyway. + const kValidSchemes = new Set(["https", "http", "ftp"]); + if (!kValidSchemes.has(origin_url.scheme)) { + continue; + } + let loginInfo = { + username: row.getResultByName("username_value"), + password: await crypto.decryptData( + row.getResultByName("password_value"), + null + ), + origin: origin_url.prePath, + formActionOrigin: null, + httpRealm: null, + usernameElement: row.getResultByName("username_element"), + passwordElement: row.getResultByName("password_element"), + timeCreated: ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("date_created") + 0, + fallbackCreationDate + ).getTime(), + timesUsed: row.getResultByName("times_used") + 0, + }; + + switch (row.getResultByName("scheme")) { + case AUTH_TYPE.SCHEME_HTML: + let action_url = row.getResultByName("action_url"); + if (!action_url) { + // If there is no action_url, store the wildcard "" value. + // See the `formActionOrigin` IDL comments. + loginInfo.formActionOrigin = ""; + break; + } + let action_uri = NetUtil.newURI(action_url); + if (!kValidSchemes.has(action_uri.scheme)) { + continue; // This continues the outer for loop. + } + loginInfo.formActionOrigin = action_uri.prePath; + break; + case AUTH_TYPE.SCHEME_BASIC: + case AUTH_TYPE.SCHEME_DIGEST: + // signon_realm format is URIrealm, so we need remove URI + loginInfo.httpRealm = row + .getResultByName("signon_realm") + .substring(loginInfo.origin.length + 1); + break; + default: + throw new Error( + "Login data scheme type not supported: " + + row.getResultByName("scheme") + ); + } + logins.push(loginInfo); + } catch (e) { + Cu.reportError(e); + } + } + try { + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + Cu.reportError(e); + } + if (crypto.finalize) { + crypto.finalize(); + } + aCallback(true); + }, + }; +}; + +ChromeProfileMigrator.prototype.classDescription = "Chrome Profile Migrator"; +ChromeProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chrome"; +ChromeProfileMigrator.prototype.classID = Components.ID( + "{4cec1de4-1671-4fc3-a53e-6c539dc77a26}" +); + +/** + * Chromium migration + **/ +function ChromiumProfileMigrator() { + this._chromeUserDataPathSuffix = "Chromium"; + this._keychainServiceName = "Chromium Safe Storage"; + this._keychainAccountName = "Chromium"; +} + +ChromiumProfileMigrator.prototype = Object.create( + ChromeProfileMigrator.prototype +); +ChromiumProfileMigrator.prototype.classDescription = + "Chromium Profile Migrator"; +ChromiumProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chromium"; +ChromiumProfileMigrator.prototype.classID = Components.ID( + "{8cece922-9720-42de-b7db-7cef88cb07ca}" +); + +var EXPORTED_SYMBOLS = ["ChromeProfileMigrator", "ChromiumProfileMigrator"]; + +/** + * Chrome Canary + * Not available on Linux + **/ +function CanaryProfileMigrator() { + this._chromeUserDataPathSuffix = "Canary"; +} +CanaryProfileMigrator.prototype = Object.create( + ChromeProfileMigrator.prototype +); +CanaryProfileMigrator.prototype.classDescription = + "Chrome Canary Profile Migrator"; +CanaryProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=canary"; +CanaryProfileMigrator.prototype.classID = Components.ID( + "{4bf85aa5-4e21-46ca-825f-f9c51a5e8c76}" +); + +if (AppConstants.platform == "win" || AppConstants.platform == "macosx") { + EXPORTED_SYMBOLS.push("CanaryProfileMigrator"); +} + +/** + * Chrome Dev - Linux only (not available in Mac and Windows) + */ +function ChromeDevMigrator() { + this._chromeUserDataPathSuffix = "Chrome Dev"; +} +ChromeDevMigrator.prototype = Object.create(ChromeProfileMigrator.prototype); +ChromeDevMigrator.prototype.classDescription = "Chrome Dev Profile Migrator"; +ChromeDevMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chrome-dev"; +ChromeDevMigrator.prototype.classID = Components.ID( + "{7370a02a-4886-42c3-a4ec-d48c726ec30a}" +); + +if (AppConstants.platform != "win" && AppConstants.platform != "macosx") { + EXPORTED_SYMBOLS.push("ChromeDevMigrator"); +} + +function ChromeBetaMigrator() { + this._chromeUserDataPathSuffix = "Chrome Beta"; +} +ChromeBetaMigrator.prototype = Object.create(ChromeProfileMigrator.prototype); +ChromeBetaMigrator.prototype.classDescription = "Chrome Beta Profile Migrator"; +ChromeBetaMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chrome-beta"; +ChromeBetaMigrator.prototype.classID = Components.ID( + "{47f75963-840b-4950-a1f0-d9c1864f8b8e}" +); + +if (AppConstants.platform != "macosx") { + EXPORTED_SYMBOLS.push("ChromeBetaMigrator"); +} + +function ChromiumEdgeMigrator() { + this._chromeUserDataPathSuffix = "Edge"; + this._keychainServiceName = "Microsoft Edge Safe Storage"; + this._keychainAccountName = "Microsoft Edge"; +} +ChromiumEdgeMigrator.prototype = Object.create(ChromeProfileMigrator.prototype); +ChromiumEdgeMigrator.prototype.classDescription = + "Chromium Edge Profile Migrator"; +ChromiumEdgeMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge"; +ChromiumEdgeMigrator.prototype.classID = Components.ID( + "{3c7f6b7c-baa9-4338-acfa-04bf79f1dcf1}" +); + +function ChromiumEdgeBetaMigrator() { + this._chromeUserDataPathSuffix = "Edge Beta"; + this._keychainServiceName = "Microsoft Edge Safe Storage"; + this._keychainAccountName = "Microsoft Edge"; +} +ChromiumEdgeBetaMigrator.prototype = Object.create( + ChromiumEdgeMigrator.prototype +); +ChromiumEdgeBetaMigrator.prototype.classDescription = + "Chromium Edge Beta Profile Migrator"; +ChromiumEdgeBetaMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge-beta"; +ChromiumEdgeBetaMigrator.prototype.classID = Components.ID( + "{0fc3d48a-c1c3-4871-b58f-a8b47d1555fb}" +); + +if (AppConstants.platform == "macosx" || AppConstants.platform == "win") { + EXPORTED_SYMBOLS.push("ChromiumEdgeMigrator", "ChromiumEdgeBetaMigrator"); +} diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.jsm b/browser/components/migration/ChromeWindowsLoginCrypto.jsm new file mode 100644 index 0000000000..bbe204132a --- /dev/null +++ b/browser/components/migration/ChromeWindowsLoginCrypto.jsm @@ -0,0 +1,181 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on Windows. + */ + +var EXPORTED_SYMBOLS = ["ChromeWindowsLoginCrypto"]; + +Cu.importGlobalProperties(["atob", "crypto"]); + +const { ChromeMigrationUtils } = ChromeUtils.import( + "resource:///modules/ChromeMigrationUtils.jsm" +); +const { OSCrypto } = ChromeUtils.import("resource://gre/modules/OSCrypto.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +/** + * These constants should match those from Chromium. + * @see https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_win.cc + */ +const AEAD_KEY_LENGTH = 256 / 8; +const ALGORITHM_NAME = "AES-GCM"; +const DPAPI_KEY_PREFIX = "DPAPI"; +const ENCRYPTION_VERSION_PREFIX = "v10"; +const NONCE_LENGTH = 96 / 8; + +const gTextDecoder = new TextDecoder(); +const gTextEncoder = new TextEncoder(); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. The algorithms here are + * specific to what is needed for Chrome login storage on Windows. + */ +class ChromeWindowsLoginCrypto { + /** + * @param {string} userDataPathSuffix + */ + constructor(userDataPathSuffix) { + this.osCrypto = new OSCrypto(); + + // Lazily decrypt the key from "Chrome"s local state using OSCrypto and save + // it as the master key to decrypt or encrypt passwords. + XPCOMUtils.defineLazyGetter(this, "_keyPromise", async () => { + let keyData; + try { + // NB: For testing, allow directory service to be faked before getting. + const localState = await ChromeMigrationUtils.getLocalState( + userDataPathSuffix + ); + const withHeader = atob(localState.os_crypt.encrypted_key); + if (!withHeader.startsWith(DPAPI_KEY_PREFIX)) { + throw new Error("Invalid key format"); + } + const encryptedKey = withHeader.slice(DPAPI_KEY_PREFIX.length); + keyData = this.osCrypto.decryptData(encryptedKey, null, "bytes"); + } catch (ex) { + Cu.reportError(`${userDataPathSuffix} os_crypt key: ${ex}`); + + // Use a generic key that will fail for actually encrypted data, but for + // testing it'll be consistent for both encrypting and decrypting. + keyData = AEAD_KEY_LENGTH; + } + return crypto.subtle.importKey( + "raw", + new Uint8Array(keyData), + ALGORITHM_NAME, + false, + ["decrypt", "encrypt"] + ); + }); + } + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this.osCrypto.finalize(); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {string} ciphertext ciphertext optionally prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertext) { + const ciphertextString = this.arrayToString(ciphertext); + return ciphertextString.startsWith(ENCRYPTION_VERSION_PREFIX) + ? this._decryptV10(ciphertext) + : this._decryptUnversioned(ciphertextString); + } + + async _decryptUnversioned(ciphertext) { + return this.osCrypto.decryptData(ciphertext); + } + + async _decryptV10(ciphertext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + + // Split the nonce/iv from the rest of the encrypted value and decrypt. + const nonceIndex = ENCRYPTION_VERSION_PREFIX.length; + const cipherIndex = nonceIndex + NONCE_LENGTH; + const iv = new Uint8Array(ciphertext.slice(nonceIndex, cipherIndex)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const cipherArray = new Uint8Array(ciphertext.slice(cipherIndex)); + const plaintext = await crypto.subtle.decrypt(algorithm, key, cipherArray); + return gTextDecoder.decode(new Uint8Array(plaintext)); + } + + /** + * @param {USVString} plaintext to encrypt + * @param {?string} version to encrypt default unversioned + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext, version = undefined) { + return version === ENCRYPTION_VERSION_PREFIX + ? this._encryptV10(plaintext) + : this._encryptUnversioned(plaintext); + } + + async _encryptUnversioned(plaintext) { + return this.osCrypto.encryptData(plaintext); + } + + async _encryptV10(plaintext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + // Encrypt and concatenate the prefix, nonce/iv and encrypted value. + const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const plainArray = gTextEncoder.encode(plaintext); + const ciphertext = await crypto.subtle.encrypt(algorithm, key, plainArray); + return ( + ENCRYPTION_VERSION_PREFIX + + this.arrayToString(iv) + + this.arrayToString(new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ESEDBReader.jsm b/browser/components/migration/ESEDBReader.jsm new file mode 100644 index 0000000000..acebe5ee82 --- /dev/null +++ b/browser/components/migration/ESEDBReader.jsm @@ -0,0 +1,810 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["ESEDBReader"]; /* exported ESEDBReader */ + +const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyGetter(this, "log", () => { + let ConsoleAPI = ChromeUtils.import("resource://gre/modules/Console.jsm", {}) + .ConsoleAPI; + let consoleOptions = { + maxLogLevelPref: "browser.esedbreader.loglevel", + prefix: "ESEDBReader", + }; + return new ConsoleAPI(consoleOptions); +}); + +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +// We have a globally unique identifier for ESE instances. A new one +// is used for each different database opened. +let gESEInstanceCounter = 0; + +// We limit the length of strings that we read from databases. +const MAX_STR_LENGTH = 64 * 1024; + +// Kernel-related types: +const KERNEL = {}; +KERNEL.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: ctypes.uint32_t }, + { dwHighDateTime: ctypes.uint32_t }, +]); +KERNEL.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: ctypes.uint16_t }, + { wMonth: ctypes.uint16_t }, + { wDayOfWeek: ctypes.uint16_t }, + { wDay: ctypes.uint16_t }, + { wHour: ctypes.uint16_t }, + { wMinute: ctypes.uint16_t }, + { wSecond: ctypes.uint16_t }, + { wMilliseconds: ctypes.uint16_t }, +]); + +// DB column types, cribbed from the ESE header +var COLUMN_TYPES = { + JET_coltypBit: 1 /* True, False, or NULL */, + JET_coltypUnsignedByte: 2 /* 1-byte integer, unsigned */, + JET_coltypShort: 3 /* 2-byte integer, signed */, + JET_coltypLong: 4 /* 4-byte integer, signed */, + JET_coltypCurrency: 5 /* 8 byte integer, signed */, + JET_coltypIEEESingle: 6 /* 4-byte IEEE single precision */, + JET_coltypIEEEDouble: 7 /* 8-byte IEEE double precision */, + JET_coltypDateTime: 8 /* Integral date, fractional time */, + JET_coltypBinary: 9 /* Binary data, < 255 bytes */, + JET_coltypText: 10 /* ANSI text, case insensitive, < 255 bytes */, + JET_coltypLongBinary: 11 /* Binary data, long value */, + JET_coltypLongText: 12 /* ANSI text, long value */, + + JET_coltypUnsignedLong: 14 /* 4-byte unsigned integer */, + JET_coltypLongLong: 15 /* 8-byte signed integer */, + JET_coltypGUID: 16 /* 16-byte globally unique identifier */, +}; + +// Not very efficient, but only used for error messages +function getColTypeName(numericValue) { + return ( + Object.keys(COLUMN_TYPES).find(t => COLUMN_TYPES[t] == numericValue) || + "unknown" + ); +} + +// All type constants and method wrappers go on this object: +const ESE = {}; +ESE.JET_ERR = ctypes.long; +ESE.JET_PCWSTR = ctypes.char16_t.ptr; +// The ESE header calls this JET_API_PTR, but because it isn't ever used as a +// pointer and because OS.File code implies that the name you give a type +// matters, I opted for a different name. +// Note that this is defined differently on 32 vs. 64-bit in the header. +ESE.JET_API_ITEM = + ctypes.voidptr_t.size == 4 ? ctypes.unsigned_long : ctypes.uint64_t; +ESE.JET_INSTANCE = ESE.JET_API_ITEM; +ESE.JET_SESID = ESE.JET_API_ITEM; +ESE.JET_TABLEID = ESE.JET_API_ITEM; +ESE.JET_COLUMNID = ctypes.unsigned_long; +ESE.JET_GRBIT = ctypes.unsigned_long; +ESE.JET_COLTYP = ctypes.unsigned_long; +ESE.JET_DBID = ctypes.unsigned_long; + +ESE.JET_COLUMNDEF = new ctypes.StructType("JET_COLUMNDEF", [ + { cbStruct: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { coltyp: ESE.JET_COLTYP }, + { wCountry: ctypes.unsigned_short }, // sepcifies the country/region for the column definition + { langid: ctypes.unsigned_short }, + { cp: ctypes.unsigned_short }, + { wCollate: ctypes.unsigned_short } /* Must be 0 */, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, +]); + +// Track open databases +let gOpenDBs = new Map(); + +// Track open libraries +let gLibs = {}; +this.ESE = ESE; // Required for tests. +this.KERNEL = KERNEL; // ditto +this.gLibs = gLibs; // ditto + +function convertESEError(errorCode) { + switch (errorCode) { + case -1213 /* JET_errPageSizeMismatch */: + case -1002 /* JET_errInvalidName*/: + case -1507 /* JET_errColumnNotFound */: + // The DB format has changed and we haven't updated this migration code: + return "The database format has changed, error code: " + errorCode; + case -1032 /* JET_errFileAccessDenied */: + case -1207 /* JET_errDatabaseLocked */: + case -1302 /* JET_errTableLocked */: + return "The database or table is locked, error code: " + errorCode; + case -1305 /* JET_errObjectNotFound */: + return "The table/object was not found."; + case -1809 /* JET_errPermissionDenied*/: + case -1907 /* JET_errAccessDenied */: + return "Access or permission denied, error code: " + errorCode; + case -1044 /* JET_errInvalidFilename */: + return "Invalid file name"; + case -1811 /* JET_errFileNotFound */: + return "File not found"; + case -550 /* JET_errDatabaseDirtyShutdown */: + return "Database in dirty shutdown state (without the requisite logs?)"; + case -514 /* JET_errBadLogVersion */: + return "Database log version does not match the version of ESE in use."; + default: + return "Unknown error: " + errorCode; + } +} + +function handleESEError( + method, + methodName, + shouldThrow = true, + errorLog = true +) { + return function() { + let rv; + try { + rv = method.apply(null, arguments); + } catch (ex) { + log.error("Error calling into ctypes method", methodName, ex); + throw ex; + } + let resultCode = parseInt(rv.toString(10), 10); + if (resultCode < 0) { + if (errorLog) { + log.error("Got error " + resultCode + " calling " + methodName); + } + if (shouldThrow) { + throw new Error(convertESEError(rv)); + } + } else if (resultCode > 0 && errorLog) { + log.warn("Got warning " + resultCode + " calling " + methodName); + } + return resultCode; + }; +} + +function declareESEFunction(methodName, ...args) { + let declaration = ["Jet" + methodName, ctypes.winapi_abi, ESE.JET_ERR].concat( + args + ); + let ctypeMethod = gLibs.ese.declare.apply(gLibs.ese, declaration); + ESE[methodName] = handleESEError(ctypeMethod, methodName); + ESE["FailSafe" + methodName] = handleESEError(ctypeMethod, methodName, false); + ESE["Manual" + methodName] = handleESEError( + ctypeMethod, + methodName, + false, + false + ); +} + +function declareESEFunctions() { + declareESEFunction( + "GetDatabaseFileInfoW", + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "GetSystemParameterW", + ESE.JET_INSTANCE, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM.ptr, + ESE.JET_PCWSTR, + ctypes.unsigned_long + ); + declareESEFunction( + "SetSystemParameterW", + ESE.JET_INSTANCE.ptr, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM, + ESE.JET_PCWSTR + ); + declareESEFunction("CreateInstanceW", ESE.JET_INSTANCE.ptr, ESE.JET_PCWSTR); + declareESEFunction("Init", ESE.JET_INSTANCE.ptr); + + declareESEFunction( + "BeginSessionW", + ESE.JET_INSTANCE, + ESE.JET_SESID.ptr, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR + ); + declareESEFunction( + "AttachDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_GRBIT + ); + declareESEFunction("DetachDatabaseW", ESE.JET_SESID, ESE.JET_PCWSTR); + declareESEFunction( + "OpenDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "OpenTableW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_TABLEID.ptr + ); + + declareESEFunction( + "GetColumnInfoW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "Move", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.long, + ESE.JET_GRBIT + ); + + declareESEFunction( + "RetrieveColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr, + ESE.JET_GRBIT, + ctypes.voidptr_t + ); + + declareESEFunction("CloseTable", ESE.JET_SESID, ESE.JET_TABLEID); + declareESEFunction( + "CloseDatabase", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_GRBIT + ); + + declareESEFunction("EndSession", ESE.JET_SESID, ESE.JET_GRBIT); + + declareESEFunction("Term", ESE.JET_INSTANCE); +} + +function unloadLibraries() { + log.debug("Unloading"); + if (gOpenDBs.size) { + log.error("Shouldn't unload libraries before DBs are closed!"); + for (let db of gOpenDBs.values()) { + db._close(); + } + } + for (let k of Object.keys(ESE)) { + delete ESE[k]; + } + gLibs.ese.close(); + gLibs.kernel.close(); + delete gLibs.ese; + delete gLibs.kernel; +} + +function loadLibraries() { + Services.obs.addObserver(unloadLibraries, "xpcom-shutdown"); + gLibs.ese = ctypes.open("esent.dll"); + gLibs.kernel = ctypes.open("kernel32.dll"); + KERNEL.FileTimeToSystemTime = gLibs.kernel.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + ctypes.int, + KERNEL.FILETIME.ptr, + KERNEL.SYSTEMTIME.ptr + ); + + declareESEFunctions(); +} + +function ESEDB(rootPath, dbPath, logPath) { + log.info("Created db"); + this.rootPath = rootPath; + this.dbPath = dbPath; + this.logPath = logPath; + this._references = 0; + this._init(); +} + +ESEDB.prototype = { + rootPath: null, + dbPath: null, + logPath: null, + _opened: false, + _attached: false, + _sessionCreated: false, + _instanceCreated: false, + _dbId: null, + _sessionId: null, + _instanceId: null, + + _init() { + if (!gLibs.ese) { + loadLibraries(); + } + this.incrementReferenceCounter(); + this._internalOpen(); + }, + + _internalOpen() { + try { + let dbinfo = new ctypes.unsigned_long(); + ESE.GetDatabaseFileInfoW( + this.dbPath, + dbinfo.address(), + ctypes.unsigned_long.size, + 17 + ); + + let pageSize = ctypes.UInt64.lo(dbinfo.value); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + pageSize, + null + ); + + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbreader-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + this.logPath + ); + + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + const JET_bitDbReadOnly = 1; + ESE.AttachDatabaseW(this._sessionId, this.dbPath, JET_bitDbReadOnly); + this._attached = true; + this._dbId = new ESE.JET_DBID(); + ESE.OpenDatabaseW( + this._sessionId, + this.dbPath, + null, + this._dbId.address(), + JET_bitDbReadOnly + ); + this._opened = true; + } catch (ex) { + try { + this._close(); + } catch (innerException) { + Cu.reportError(innerException); + } + // Make sure caller knows we failed. + throw ex; + } + gOpenDBs.set(this.dbPath, this); + }, + + checkForColumn(tableName, columnName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let columnInfo; + try { + columnInfo = this._getColumnInfo(tableName, [{ name: columnName }]); + } catch (ex) { + return null; + } + return columnInfo[0]; + }, + + tableExists(tableName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableId = new ESE.JET_TABLEID(); + let rv = ESE.ManualOpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + if (rv == -1305 /* JET_errObjectNotFound */) { + return false; + } + if (rv < 0) { + log.error("Got error " + rv + " calling OpenTableW"); + throw new Error(convertESEError(rv)); + } + + if (rv > 0) { + log.error("Got warning " + rv + " calling OpenTableW"); + } + ESE.FailSafeCloseTable(this._sessionId, tableId); + return true; + }, + + *tableItems(tableName, columns) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableOpened = false; + let tableId; + try { + tableId = this._openTable(tableName); + tableOpened = true; + + let columnInfo = this._getColumnInfo(tableName, columns); + + let rv = ESE.ManualMove( + this._sessionId, + tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + if (rv == -1603 /* JET_errNoCurrentRecord */) { + // There are no rows in the table. + this._closeTable(tableId); + return; + } + if (rv != 0) { + throw new Error(convertESEError(rv)); + } + + do { + let rowContents = {}; + for (let column of columnInfo) { + let [buffer, bufferSize] = this._getBufferForColumn(column); + // We handle errors manually so we accurately deal with NULL values. + let err = ESE.ManualRetrieveColumn( + this._sessionId, + tableId, + column.id, + buffer.address(), + bufferSize, + null, + 0, + null + ); + rowContents[column.name] = this._convertResult(column, buffer, err); + } + yield rowContents; + } while ( + ESE.ManualMove(this._sessionId, tableId, 1 /* JET_MoveNext */, 0) === 0 + ); + } catch (ex) { + if (tableOpened) { + this._closeTable(tableId); + } + throw ex; + } + this._closeTable(tableId); + }, + + _openTable(tableName) { + let tableId = new ESE.JET_TABLEID(); + ESE.OpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + return tableId; + }, + + _getBufferForColumn(column) { + let buffer; + if (column.type == "string") { + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + // size on the column is in bytes, 2 bytes to a wchar, so: + let charCount = column.dbSize >> 1; + buffer = new wchar_tArray(charCount); + } else if (column.type == "boolean") { + buffer = new ctypes.uint8_t(); + } else if (column.type == "date") { + buffer = new KERNEL.FILETIME(); + } else if (column.type == "guid") { + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(column.dbSize); + } else { + throw new Error("Unknown type " + column.type); + } + return [buffer, buffer.constructor.size]; + }, + + _convertResult(column, buffer, err) { + if (err != 0) { + if (err == 1004) { + // Deal with null values: + buffer = null; + } else { + Cu.reportError( + "Unexpected JET error: " + + err + + "; retrieving value for column " + + column.name + ); + throw new Error(convertESEError(err)); + } + } + if (column.type == "string") { + return buffer ? buffer.readString() : ""; + } + if (column.type == "boolean") { + return buffer ? buffer.value == 255 : false; + } + if (column.type == "guid") { + if (buffer.length != 16) { + Cu.reportError( + "Buffer size for guid field " + column.id + " should have been 16!" + ); + return ""; + } + let rv = "{"; + for (let i = 0; i < 16; i++) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + rv += "-"; + } + let byteValue = buffer.addressOfElement(i).contents; + // Ensure there's a leading 0 + rv += ("0" + byteValue.toString(16)).substr(-2); + } + return rv + "}"; + } + if (column.type == "date") { + if (!buffer) { + return null; + } + let systemTime = new KERNEL.SYSTEMTIME(); + let result = KERNEL.FileTimeToSystemTime( + buffer.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return new Date( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) + ); + } + return undefined; + }, + + _getColumnInfo(tableName, columns) { + let rv = []; + for (let column of columns) { + let columnInfoFromDB = new ESE.JET_COLUMNDEF(); + ESE.GetColumnInfoW( + this._sessionId, + this._dbId, + tableName, + column.name, + columnInfoFromDB.address(), + ESE.JET_COLUMNDEF.size, + 0 /* JET_ColInfo */ + ); + let dbType = parseInt(columnInfoFromDB.coltyp.toString(10), 10); + let dbSize = parseInt(columnInfoFromDB.cbMax.toString(10), 10); + if (column.type == "string") { + if ( + dbType != COLUMN_TYPES.JET_coltypLongText && + dbType != COLUMN_TYPES.JET_coltypText + ) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected text type, got type " + + getColTypeName(dbType) + ); + } + if (dbSize > MAX_STR_LENGTH) { + throw new Error( + "Column " + + column.name + + " has more than 64k data in it. This API is not designed to handle data that large." + ); + } + } else if (column.type == "boolean") { + if (dbType != COLUMN_TYPES.JET_coltypBit) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected bit type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "date") { + if (dbType != COLUMN_TYPES.JET_coltypLongLong) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected long long type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "guid") { + if (dbType != COLUMN_TYPES.JET_coltypGUID) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected guid type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type) { + throw new Error( + "Unknown column type " + + column.type + + " requested for column " + + column.name + + ", don't know what to do." + ); + } + + rv.push({ + name: column.name, + id: columnInfoFromDB.columnid, + type: column.type, + dbSize, + dbType, + }); + } + return rv; + }, + + _closeTable(tableId) { + ESE.FailSafeCloseTable(this._sessionId, tableId); + }, + + _close() { + this._internalClose(); + gOpenDBs.delete(this.dbPath); + }, + + _internalClose() { + if (this._opened) { + log.debug("close db"); + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + log.debug("finished close db"); + this._opened = false; + } + if (this._attached) { + log.debug("detach db"); + ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath); + this._attached = false; + } + if (this._sessionCreated) { + log.debug("end session"); + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + log.debug("term"); + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, + + incrementReferenceCounter() { + this._references++; + }, + + decrementReferenceCounter() { + this._references--; + if (this._references <= 0) { + this._close(); + } + }, +}; + +let ESEDBReader = { + openDB(rootDir, dbFile, logDir) { + let dbFilePath = dbFile.path; + if (gOpenDBs.has(dbFilePath)) { + let db = gOpenDBs.get(dbFilePath); + db.incrementReferenceCounter(); + return db; + } + // ESE is really picky about the trailing slashes according to the docs, + // so we do as we're told and ensure those are there: + return new ESEDB(rootDir.path + "\\", dbFilePath, logDir.path + "\\"); + }, + + async dbLocked(dbFile) { + let options = { winShare: OS.Constants.Win.FILE_SHARE_READ }; + let locked = true; + await OS.File.open(dbFile.path, { read: true }, options).then( + fileHandle => { + locked = false; + // Return the close promise so we wait for the file to be closed again. + // Otherwise the file might still be kept open by this handle by the time + // that we try to use the ESE APIs to access it. + return fileHandle.close(); + }, + () => { + Cu.reportError("ESE DB at " + dbFile.path + " is locked."); + } + ); + return locked; + }, + + closeDB(db) { + db.decrementReferenceCounter(); + }, + + COLUMN_TYPES, +}; diff --git a/browser/components/migration/EdgeProfileMigrator.jsm b/browser/components/migration/EdgeProfileMigrator.jsm new file mode 100644 index 0000000000..25d5408f4a --- /dev/null +++ b/browser/components/migration/EdgeProfileMigrator.jsm @@ -0,0 +1,612 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); +const { MSMigrationUtils } = ChromeUtils.import( + "resource:///modules/MSMigrationUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ESEDBReader", + "resource:///modules/ESEDBReader.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +const kEdgeRegistryRoot = + "SOFTWARE\\Classes\\Local Settings\\Software\\" + + "Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\" + + "microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge"; +const kEdgeDatabasePath = "AC\\MicrosoftEdge\\User\\Default\\DataStore\\Data\\"; + +XPCOMUtils.defineLazyGetter(this, "gEdgeDatabase", function() { + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (!edgeDir) { + return null; + } + edgeDir.appendRelativePath(kEdgeDatabasePath); + if (!edgeDir.exists() || !edgeDir.isReadable() || !edgeDir.isDirectory()) { + return null; + } + let expectedLocation = edgeDir.clone(); + expectedLocation.appendRelativePath( + "nouser1\\120712-0049\\DBStore\\spartan.edb" + ); + if ( + expectedLocation.exists() && + expectedLocation.isReadable() && + expectedLocation.isFile() + ) { + expectedLocation.normalize(); + return expectedLocation; + } + // We used to recurse into arbitrary subdirectories here, but that code + // went unused, so it likely isn't necessary, even if we don't understand + // where the magic folders above come from, they seem to be the same for + // everyone. Just return null if they're not there: + return null; +}); + +/** + * Get rows from a table in the Edge DB as an array of JS objects. + * + * @param {String} tableName the name of the table to read. + * @param {String[]|function} columns a list of column specifiers + * (see ESEDBReader.jsm) or a function that + * generates them based on the database + * reference once opened. + * @param {nsIFile} dbFile the database file to use. Defaults to + * the main Edge database. + * @param {function} filterFn Optional. A function that is called for each row. + * Only rows for which it returns a truthy + * value are included in the result. + * @returns {Array} An array of row objects. + */ +function readTableFromEdgeDB( + tableName, + columns, + dbFile = gEdgeDatabase, + filterFn = null +) { + let database; + let rows = []; + try { + let logFile = dbFile.parent; + logFile.append("LogFiles"); + database = ESEDBReader.openDB(dbFile.parent, dbFile, logFile); + + if (typeof columns == "function") { + columns = columns(database); + } + + let tableReader = database.tableItems(tableName, columns); + for (let row of tableReader) { + if (!filterFn || filterFn(row)) { + rows.push(row); + } + } + } catch (ex) { + Cu.reportError( + "Failed to extract items from table " + + tableName + + " in Edge database at " + + dbFile.path + + " due to the following error: " + + ex + ); + // Deliberately make this fail so we expose failure in the UI: + throw ex; + } finally { + if (database) { + ESEDBReader.closeDB(database); + } + } + return rows; +} + +function EdgeTypedURLMigrator() {} + +EdgeTypedURLMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get _typedURLs() { + if (!this.__typedURLs) { + this.__typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } + return this.__typedURLs; + }, + + get exists() { + return this._typedURLs.size > 0; + }, + + migrate(aCallback) { + let typedURLs = this._typedURLs; + let pageInfos = []; + for (let [urlString, time] of typedURLs) { + let url; + try { + url = new URL(urlString); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + } catch (ex) { + Cu.reportError(ex); + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: PlacesUtils.history.TRANSITIONS.TYPED, + date: time ? PlacesUtils.toDate(time) : new Date(), + }, + ], + }); + } + + if (!pageInfos.length) { + aCallback(typedURLs.size == 0); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +function EdgeTypedURLDBMigrator() {} + +EdgeTypedURLDBMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get db() { + return gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateTypedURLsFromDB().then( + () => callback(true), + ex => { + Cu.reportError(ex); + callback(false); + } + ); + }, + + async _migrateTypedURLsFromDB() { + if (await ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columns = [ + { name: "URL", type: "string" }, + { name: "AccessDateTimeUTC", type: "date" }, + ]; + + let typedUrls = []; + try { + typedUrls = readTableFromEdgeDB("TypedUrls", columns, this.db); + } catch (ex) { + // Maybe the table doesn't exist (older versions of Win10). + // Just fall through and we'll return because there's no data. + // The `readTableFromEdgeDB` helper will report errors to the + // console anyway. + } + if (!typedUrls.length) { + return; + } + + let pageInfos = []; + // Sometimes the values are bogus (e.g. 0 becomes some date in 1600), + // and places will throw *everything* away, not just the bogus ones, + // so deal with that by having a cutoff date. Also, there's not much + // point importing really old entries. The cut-off date is related to + // Edge's launch date. + const kDateCutOff = new Date("2016", 0, 1); + for (let typedUrlInfo of typedUrls) { + try { + let url = new URL(typedUrlInfo.URL); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + + let date = typedUrlInfo.AccessDateTimeUTC; + if (!date) { + date = kDateCutOff; + } else if (date < kDateCutOff) { + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: PlacesUtils.history.TRANSITIONS.TYPED, + date, + }, + ], + }); + } catch (ex) { + Cu.reportError(ex); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + }, +}; + +function EdgeReadingListMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeReadingListMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateReadingList(PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + Cu.reportError(ex); + callback(false); + } + ); + }, + + async _migrateReadingList(parentGuid) { + if (await ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columnFn = db => { + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "AddedDate", type: "date" }, + ]; + + // Later versions have an IsDeleted column: + let isDeletedColumn = db.checkForColumn("ReadingList", "IsDeleted"); + if ( + isDeletedColumn && + isDeletedColumn.dbType == ESEDBReader.COLUMN_TYPES.JET_coltypBit + ) { + columns.push({ name: "IsDeleted", type: "boolean" }); + } + return columns; + }; + + let filterFn = row => { + return !row.IsDeleted; + }; + + let readingListItems = readTableFromEdgeDB( + "ReadingList", + columnFn, + this.db, + filterFn + ); + if (!readingListItems.length) { + return; + } + + let destFolderGuid = await this._ensureReadingListFolder(parentGuid); + let bookmarks = []; + for (let item of readingListItems) { + let dateAdded = item.AddedDate || new Date(); + // Avoid including broken URLs: + try { + new URL(item.URL); + } catch (ex) { + continue; + } + bookmarks.push({ url: item.URL, title: item.Title, dateAdded }); + } + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, destFolderGuid); + }, + + async _ensureReadingListFolder(parentGuid) { + if (!this.__readingListFolderGuid) { + let folderTitle = await MigrationUtils.getLocalizedString( + "imported-edge-reading-list" + ); + let folderSpec = { + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid, + title: folderTitle, + }; + this.__readingListFolderGuid = ( + await MigrationUtils.insertBookmarkWrapper(folderSpec) + ).guid; + } + return this.__readingListFolderGuid; + }, +}; + +function EdgeBookmarksMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeBookmarksMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || gEdgeDatabase; + }, + + get TABLE_NAME() { + return "Favorites"; + }, + + get exists() { + if (!("_exists" in this)) { + this._exists = !!this.db; + } + return this._exists; + }, + + migrate(callback) { + this._migrateBookmarks().then( + () => callback(true), + ex => { + Cu.reportError(ex); + callback(false); + } + ); + }, + + async _migrateBookmarks() { + if (await ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB(); + let histogramBookmarkRoots = 0; + if (toplevelBMs.length) { + histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU; + let parentGuid = PlacesUtils.bookmarks.menuGuid; + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(parentGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + parentGuid = await MigrationUtils.createImportedBookmarksFolder( + "Edge", + parentGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid); + } + if (toolbarBMs.length) { + histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(parentGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + parentGuid = await MigrationUtils.createImportedBookmarksFolder( + "Edge", + parentGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration(); + } + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS") + .add("edge", histogramBookmarkRoots); + }, + + _fetchBookmarksFromDB() { + let folderMap = new Map(); + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "DateUpdated", type: "date" }, + { name: "IsFolder", type: "boolean" }, + { name: "IsDeleted", type: "boolean" }, + { name: "ParentId", type: "guid" }, + { name: "ItemId", type: "guid" }, + ]; + let filterFn = row => { + if (row.IsDeleted) { + return false; + } + if (row.IsFolder) { + folderMap.set(row.ItemId, row); + } + return true; + }; + let bookmarks = readTableFromEdgeDB( + this.TABLE_NAME, + columns, + this.db, + filterFn + ); + let toplevelBMs = [], + toolbarBMs = []; + for (let bookmark of bookmarks) { + let bmToInsert; + // Ignore invalid URLs: + if (!bookmark.IsFolder) { + try { + new URL(bookmark.URL); + } catch (ex) { + Cu.reportError( + `Ignoring ${bookmark.URL} when importing from Edge because of exception: ${ex}` + ); + continue; + } + bmToInsert = { + dateAdded: bookmark.DateUpdated || new Date(), + title: bookmark.Title, + url: bookmark.URL, + }; + } /* bookmark.IsFolder */ else { + // Ignore the favorites bar bookmark itself. + if (bookmark.Title == "_Favorites_Bar_") { + continue; + } + if (!bookmark._childrenRef) { + bookmark._childrenRef = []; + } + bmToInsert = { + title: bookmark.Title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded: bookmark.DateUpdated || new Date(), + children: bookmark._childrenRef, + }; + } + + if (!folderMap.has(bookmark.ParentId)) { + toplevelBMs.push(bmToInsert); + } else { + let parent = folderMap.get(bookmark.ParentId); + if (parent.Title == "_Favorites_Bar_") { + toolbarBMs.push(bmToInsert); + continue; + } + if (!parent._childrenRef) { + parent._childrenRef = []; + } + parent._childrenRef.push(bmToInsert); + } + } + return { toplevelBMs, toolbarBMs }; + }, +}; + +function EdgeProfileMigrator() { + this.wrappedJSObject = this; +} + +EdgeProfileMigrator.prototype = Object.create(MigratorPrototype); + +EdgeProfileMigrator.prototype.getBookmarksMigratorForTesting = function( + dbOverride +) { + return new EdgeBookmarksMigrator(dbOverride); +}; + +EdgeProfileMigrator.prototype.getReadingListMigratorForTesting = function( + dbOverride +) { + return new EdgeReadingListMigrator(dbOverride); +}; + +EdgeProfileMigrator.prototype.getResources = function() { + let resources = [ + new EdgeBookmarksMigrator(), + MSMigrationUtils.getCookiesMigrator(MSMigrationUtils.MIGRATION_TYPE_EDGE), + new EdgeTypedURLMigrator(), + new EdgeTypedURLDBMigrator(), + new EdgeReadingListMigrator(), + ]; + let windowsVaultFormPasswordsMigrator = MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); +}; + +EdgeProfileMigrator.prototype.getLastUsedDate = async function() { + // Don't do this if we don't have a single profile (see the comment for + // sourceProfiles) or if we can't find the database file: + let sourceProfiles = await this.getSourceProfiles(); + if (sourceProfiles !== null || !gEdgeDatabase) { + return Promise.resolve(new Date(0)); + } + let logFilePath = OS.Path.join( + gEdgeDatabase.parent.path, + "LogFiles", + "edb.log" + ); + let dbPath = gEdgeDatabase.path; + let cookieMigrator = MSMigrationUtils.getCookiesMigrator( + MSMigrationUtils.MIGRATION_TYPE_EDGE + ); + let cookiePaths = cookieMigrator._cookiesFolders.map(f => f.path); + let datePromises = [logFilePath, dbPath, ...cookiePaths].map(path => { + return OS.File.stat(path) + .catch(() => null) + .then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + datePromises.push( + new Promise(resolve => { + let typedURLs = new Map(); + try { + typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } catch (ex) {} + let times = [0, ...typedURLs.values()]; + // dates is an array of PRTimes, which are in microseconds - convert to milliseconds + resolve(Math.max.apply(Math, times) / 1000); + }) + ); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); +}; + +/* Somewhat counterintuitively, this returns: + * - |null| to indicate "There is only 1 (default) profile" (on win10+) + * - |[]| to indicate "There are no profiles" (on <=win8.1) which will avoid using this migrator. + * See MigrationUtils.jsm for slightly more info on how sourceProfiles is used. + */ +EdgeProfileMigrator.prototype.getSourceProfiles = function() { + let isWin10OrHigher = AppConstants.isPlatformAndVersionAtLeast("win", "10"); + return isWin10OrHigher ? null : []; +}; + +EdgeProfileMigrator.prototype.__defineGetter__("sourceLocked", function() { + // There is an exclusive lock on some databases. Assume they are locked for now. + return true; +}); + +EdgeProfileMigrator.prototype.classDescription = "Edge Profile Migrator"; +EdgeProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=edge"; +EdgeProfileMigrator.prototype.classID = Components.ID( + "{62e8834b-2d17-49f5-96ff-56344903a2ae}" +); + +var EXPORTED_SYMBOLS = ["EdgeProfileMigrator"]; diff --git a/browser/components/migration/FirefoxProfileMigrator.jsm b/browser/components/migration/FirefoxProfileMigrator.jsm new file mode 100644 index 0000000000..bba617374f --- /dev/null +++ b/browser/components/migration/FirefoxProfileMigrator.jsm @@ -0,0 +1,383 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sw=2 ts=2 sts=2 et */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* + * Migrates from a Firefox profile in a lossy manner in order to clean up a + * user's profile. Data is only migrated where the benefits outweigh the + * potential problems caused by importing undesired/invalid configurations + * from the source profile. + */ + +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "PlacesBackups", + "resource://gre/modules/PlacesBackups.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "SessionMigration", + "resource:///modules/sessionstore/SessionMigration.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ProfileAge", + "resource://gre/modules/ProfileAge.jsm" +); + +function FirefoxProfileMigrator() { + this.wrappedJSObject = this; // for testing... +} + +FirefoxProfileMigrator.prototype = Object.create(MigratorPrototype); + +FirefoxProfileMigrator.prototype._getAllProfiles = function() { + let allProfiles = new Map(); + let profileService = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + for (let profile of profileService.profiles) { + let rootDir = profile.rootDir; + + if ( + rootDir.exists() && + rootDir.isReadable() && + !rootDir.equals(MigrationUtils.profileStartup.directory) + ) { + allProfiles.set(profile.name, rootDir); + } + } + return allProfiles; +}; + +function sorter(a, b) { + return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase()); +} + +FirefoxProfileMigrator.prototype.getSourceProfiles = function() { + return [...this._getAllProfiles().keys()] + .map(x => ({ id: x, name: x })) + .sort(sorter); +}; + +FirefoxProfileMigrator.prototype._getFileObject = function(dir, fileName) { + let file = dir.clone(); + file.append(fileName); + + // File resources are monolithic. We don't make partial copies since + // they are not expected to work alone. Return null to avoid trying to + // copy non-existing files. + return file.exists() ? file : null; +}; + +FirefoxProfileMigrator.prototype.getResources = function(aProfile) { + let sourceProfileDir = aProfile + ? this._getAllProfiles().get(aProfile.id) + : Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ).defaultProfile.rootDir; + if ( + !sourceProfileDir || + !sourceProfileDir.exists() || + !sourceProfileDir.isReadable() + ) { + return null; + } + + // Being a startup-only migrator, we can rely on + // MigrationUtils.profileStartup being set. + let currentProfileDir = MigrationUtils.profileStartup.directory; + + // Surely data cannot be imported from the current profile. + if (sourceProfileDir.equals(currentProfileDir)) { + return null; + } + + return this._getResourcesInternal(sourceProfileDir, currentProfileDir); +}; + +FirefoxProfileMigrator.prototype.getLastUsedDate = function() { + // We always pretend we're really old, so that we don't mess + // up the determination of which browser is the most 'recent' + // to import from. + return Promise.resolve(new Date(0)); +}; + +FirefoxProfileMigrator.prototype._getResourcesInternal = function( + sourceProfileDir, + currentProfileDir +) { + let getFileResource = (aMigrationType, aFileNames) => { + let files = []; + for (let fileName of aFileNames) { + let file = this._getFileObject(sourceProfileDir, fileName); + if (file) { + files.push(file); + } + } + if (!files.length) { + return null; + } + return { + type: aMigrationType, + migrate(aCallback) { + for (let file of files) { + file.copyTo(currentProfileDir, ""); + } + aCallback(true); + }, + }; + }; + + function savePrefs() { + // If we've used the pref service to write prefs for the new profile, it's too + // early in startup for the service to have a profile directory, so we have to + // manually tell it where to save the prefs file. + let newPrefsFile = currentProfileDir.clone(); + newPrefsFile.append("prefs.js"); + Services.prefs.savePrefFile(newPrefsFile); + } + + let types = MigrationUtils.resourceTypes; + let places = getFileResource(types.HISTORY, [ + "places.sqlite", + "places.sqlite-wal", + ]); + let favicons = getFileResource(types.HISTORY, [ + "favicons.sqlite", + "favicons.sqlite-wal", + ]); + let cookies = getFileResource(types.COOKIES, [ + "cookies.sqlite", + "cookies.sqlite-wal", + ]); + let passwords = getFileResource(types.PASSWORDS, [ + "signons.sqlite", + "logins.json", + "key3.db", + "key4.db", + ]); + let formData = getFileResource(types.FORMDATA, [ + "formhistory.sqlite", + "autofill-profiles.json", + ]); + let bookmarksBackups = getFileResource(types.OTHERDATA, [ + PlacesBackups.profileRelativeFolderPath, + ]); + let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]); + + let session; + let env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.get("MOZ_RESET_PROFILE_MIGRATE_SESSION")) { + // We only want to restore the previous firefox session if the profile refresh was + // triggered by user. The MOZ_RESET_PROFILE_MIGRATE_SESSION would be set when a user-triggered + // profile refresh happened in nsAppRunner.cpp. Hence, we detect the MOZ_RESET_PROFILE_MIGRATE_SESSION + // to see if session data migration is required. + env.set("MOZ_RESET_PROFILE_MIGRATE_SESSION", ""); + let sessionCheckpoints = this._getFileObject( + sourceProfileDir, + "sessionCheckpoints.json" + ); + let sessionFile = this._getFileObject( + sourceProfileDir, + "sessionstore.jsonlz4" + ); + if (sessionFile) { + session = { + type: types.SESSION, + migrate(aCallback) { + sessionCheckpoints.copyTo( + currentProfileDir, + "sessionCheckpoints.json" + ); + let newSessionFile = currentProfileDir.clone(); + newSessionFile.append("sessionstore.jsonlz4"); + let migrationPromise = SessionMigration.migrate( + sessionFile.path, + newSessionFile.path + ); + migrationPromise.then( + function() { + let buildID = Services.appinfo.platformBuildID; + let mstone = Services.appinfo.platformVersion; + // Force the browser to one-off resume the session that we give it: + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + true + ); + // Reset the homepage_override prefs so that the browser doesn't override our + // session with the "what's new" page: + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + mstone + ); + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + savePrefs(); + aCallback(true); + }, + function() { + aCallback(false); + } + ); + }, + }; + } + } + + // Sync/FxA related data + let sync = { + name: "sync", // name is used only by tests. + type: types.OTHERDATA, + migrate: async aCallback => { + // Try and parse a signedInUser.json file from the source directory and + // if we can, copy it to the new profile and set sync's username pref + // (which acts as a de-facto flag to indicate if sync is configured) + try { + let oldPath = OS.Path.join(sourceProfileDir.path, "signedInUser.json"); + let exists = await OS.File.exists(oldPath); + if (exists) { + let raw = await OS.File.read(oldPath, { encoding: "utf-8" }); + let data = JSON.parse(raw); + if (data && data.accountData && data.accountData.email) { + let username = data.accountData.email; + // copy the file itself. + await OS.File.copy( + oldPath, + OS.Path.join(currentProfileDir.path, "signedInUser.json") + ); + // Now we need to know whether Sync is actually configured for this + // user. The only way we know is by looking at the prefs file from + // the old profile. We avoid trying to do a full parse of the prefs + // file and even avoid parsing the single string value we care + // about. + let prefsPath = OS.Path.join(sourceProfileDir.path, "prefs.js"); + if (await OS.File.exists(oldPath)) { + let rawPrefs = await OS.File.read(prefsPath, { + encoding: "utf-8", + }); + if (/^user_pref\("services\.sync\.username"/m.test(rawPrefs)) { + // sync's configured in the source profile - ensure it is in the + // new profile too. + // Write it to prefs.js and flush the file. + Services.prefs.setStringPref( + "services.sync.username", + username + ); + savePrefs(); + } + } + } + } + } catch (ex) { + aCallback(false); + return; + } + aCallback(true); + }, + }; + + // Telemetry related migrations. + let times = { + name: "times", // name is used only by tests. + type: types.OTHERDATA, + migrate: aCallback => { + let file = this._getFileObject(sourceProfileDir, "times.json"); + if (file) { + file.copyTo(currentProfileDir, ""); + } + // And record the fact a migration (ie, a reset) happened. + let recordMigration = async () => { + try { + let profileTimes = await ProfileAge(currentProfileDir.path); + await profileTimes.recordProfileReset(); + aCallback(true); + } catch (e) { + aCallback(false); + } + }; + + recordMigration(); + }, + }; + let telemetry = { + name: "telemetry", // name is used only by tests... + type: types.OTHERDATA, + migrate: aCallback => { + let createSubDir = name => { + let dir = currentProfileDir.clone(); + dir.append(name); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return dir; + }; + + // If the 'datareporting' directory exists we migrate files from it. + let dataReportingDir = this._getFileObject( + sourceProfileDir, + "datareporting" + ); + if (dataReportingDir && dataReportingDir.isDirectory()) { + // Copy only specific files. + let toCopy = ["state.json", "session-state.json"]; + + let dest = createSubDir("datareporting"); + let enumerator = dataReportingDir.directoryEntries; + while (enumerator.hasMoreElements()) { + let file = enumerator.nextFile; + if (file.isDirectory() || !toCopy.includes(file.leafName)) { + continue; + } + file.copyTo(dest, ""); + } + } + + aCallback(true); + }, + }; + + return [ + places, + cookies, + passwords, + formData, + dictionary, + bookmarksBackups, + session, + sync, + times, + telemetry, + favicons, + ].filter(r => r); +}; + +Object.defineProperty(FirefoxProfileMigrator.prototype, "startupOnlyMigrator", { + get: () => true, +}); + +FirefoxProfileMigrator.prototype.classDescription = "Firefox Profile Migrator"; +FirefoxProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=firefox"; +FirefoxProfileMigrator.prototype.classID = Components.ID( + "{91185366-ba97-4438-acba-48deaca63386}" +); + +var EXPORTED_SYMBOLS = ["FirefoxProfileMigrator"]; diff --git a/browser/components/migration/IEProfileMigrator.jsm b/browser/components/migration/IEProfileMigrator.jsm new file mode 100644 index 0000000000..ca02b40acf --- /dev/null +++ b/browser/components/migration/IEProfileMigrator.jsm @@ -0,0 +1,416 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kLoginsKey = + "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); +const { MSMigrationUtils } = ChromeUtils.import( + "resource:///modules/MSMigrationUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ctypes", + "resource://gre/modules/ctypes.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "OSCrypto", + "resource://gre/modules/OSCrypto.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +// Resources + +function History() {} + +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get exists() { + return true; + }, + + migrate: function H_migrate(aCallback) { + let pageInfos = []; + let typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let url = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, + // like HTMLHelp and others. Since we don't properly map handling for + // all of them we just avoid importing them. + if (!["http", "https", "ftp", "file"].includes(url.scheme)) { + continue; + } + + let title = entry.get("title"); + // Embed visits have no title and don't need to be imported. + if (!title.length) { + continue; + } + + // The typed urls are already fixed-up, so we can use them for comparison. + let transition = typedURLs.has(url.spec) + ? PlacesUtils.history.TRANSITIONS.LINK + : PlacesUtils.history.TRANSITIONS.TYPED; + // use the current date if we have no visits for this entry. + let time = entry.get("time"); + + pageInfos.push({ + url, + title, + visits: [ + { + transition, + date: time ? PlacesUtils.toDate(entry.get("time")) : new Date(), + }, + ], + }); + } + + // Check whether there is any history to import. + if (!pageInfos.length) { + aCallback(true); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +// IE form password migrator supporting windows from XP until 7 and IE from 7 until 11 +function IE7FormPasswords() { + // used to distinguish between this migrator and other passwords migrators in tests. + this.name = "IE7FormPasswords"; +} + +IE7FormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // work only on windows until 7 + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + return false; + } + + try { + let nsIWindowsRegKey = Ci.nsIWindowsRegKey; + let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + key.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kLoginsKey, + nsIWindowsRegKey.ACCESS_READ + ); + let count = key.valueCount; + key.close(); + return count > 0; + } catch (e) { + return false; + } + }, + + async migrate(aCallback) { + let uris = []; // the uris of the websites that are going to be migrated + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let uri = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, like HTMLHelp + // and others. Since we are not going to import the logins that are performed in these URLs + // we can just skip them. + if (!["http", "https", "ftp"].includes(uri.scheme)) { + continue; + } + + uris.push(uri); + } + await this._migrateURIs(uris); + aCallback(true); + }, + + /** + * Migrate the logins that were saved for the uris arguments. + * @param {nsIURI[]} uris - the uris that are going to be migrated. + */ + async _migrateURIs(uris) { + this.ctypesKernelHelpers = new MSMigrationUtils.CtypesKernelHelpers(); + this._crypto = new OSCrypto(); + let nsIWindowsRegKey = Ci.nsIWindowsRegKey; + let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + key.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kLoginsKey, + nsIWindowsRegKey.ACCESS_READ + ); + + let urlsSet = new Set(); // set of the already processed urls. + // number of the successfully decrypted registry values + let successfullyDecryptedValues = 0; + /* The logins are stored in the registry, where the key is a hashed URL and its + * value contains the encrypted details for all logins for that URL. + * + * First iterate through IE history, hashing each URL and looking for a match. If + * found, decrypt the value, using the URL as a salt. Finally add any found logins + * to the Firefox password manager. + */ + + let logins = []; + for (let uri of uris) { + try { + // remove the query and the ref parts of the URL + let urlObject = new URL(uri.spec); + let url = urlObject.origin + urlObject.pathname; + // if the current url is already processed, it should be skipped + if (urlsSet.has(url)) { + continue; + } + urlsSet.add(url); + // hash value of the current uri + let hashStr = this._crypto.getIELoginHash(url); + if (!key.hasValue(hashStr)) { + continue; + } + let value = key.readBinaryValue(hashStr); + // if no value was found, the uri is skipped + if (value == null) { + continue; + } + let data; + try { + // the url is used as salt to decrypt the registry value + data = this._crypto.decryptData(value, url); + } catch (e) { + continue; + } + // extract the login details from the decrypted data + let ieLogins = this._extractDetails(data, uri); + // if at least a credential was found in the current data, successfullyDecryptedValues should + // be incremented by one + if (ieLogins.length) { + successfullyDecryptedValues++; + } + for (let ieLogin of ieLogins) { + logins.push({ + username: ieLogin.username, + password: ieLogin.password, + origin: ieLogin.url, + timeCreated: ieLogin.creation, + }); + } + } catch (e) { + Cu.reportError( + "Error while importing logins for " + uri.spec + ": " + e + ); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + + // if the number of the imported values is less than the number of values in the key, it means + // that not all the values were imported and an error should be reported + if (successfullyDecryptedValues < key.valueCount) { + Cu.reportError( + "We failed to decrypt and import some logins. " + + "This is likely because we didn't find the URLs where these " + + "passwords were submitted in the IE history and which are needed to be used " + + "as keys in the decryption." + ); + } + + key.close(); + this._crypto.finalize(); + this.ctypesKernelHelpers.finalize(); + }, + + _crypto: null, + + /** + * Extract the details of one or more logins from the raw decrypted data. + * @param {string} data - the decrypted data containing raw information. + * @param {nsURI} uri - the nsURI of page where the login has occur. + * @returns {Object[]} array of objects where each of them contains the username, password, URL, + * and creation time representing all the logins found in the data arguments. + */ + _extractDetails(data, uri) { + // the structure of the header of the IE7 decrypted data for all the logins sharing the same URL + let loginData = new ctypes.StructType("loginData", [ + // Bytes 0-3 are not needed and not documented + { unknown1: ctypes.uint32_t }, + // Bytes 4-7 are the header size + { headerSize: ctypes.uint32_t }, + // Bytes 8-11 are the data size + { dataSize: ctypes.uint32_t }, + // Bytes 12-19 are not needed and not documented + { unknown2: ctypes.uint32_t }, + { unknown3: ctypes.uint32_t }, + // Bytes 20-23 are the data count: each username and password is considered as a data + { dataMax: ctypes.uint32_t }, + // Bytes 24-35 are not needed and not documented + { unknown4: ctypes.uint32_t }, + { unknown5: ctypes.uint32_t }, + { unknown6: ctypes.uint32_t }, + ]); + + // the structure of a IE7 decrypted login item + let loginItem = new ctypes.StructType("loginItem", [ + // Bytes 0-3 are the offset of the username + { usernameOffset: ctypes.uint32_t }, + // Bytes 4-11 are the date + { loDateTime: ctypes.uint32_t }, + { hiDateTime: ctypes.uint32_t }, + // Bytes 12-15 are not needed and not documented + { foo: ctypes.uint32_t }, + // Bytes 16-19 are the offset of the password + { passwordOffset: ctypes.uint32_t }, + // Bytes 20-31 are not needed and not documented + { unknown1: ctypes.uint32_t }, + { unknown2: ctypes.uint32_t }, + { unknown3: ctypes.uint32_t }, + ]); + + let url = uri.prePath; + let results = []; + let arr = this._crypto.stringToArray(data); + // convert data to ctypes.unsigned_char.array(arr.length) + let cdata = ctypes.unsigned_char.array(arr.length)(arr); + // Bytes 0-35 contain the loginData data structure for all the logins sharing the same URL + let currentLoginData = ctypes.cast(cdata, loginData); + let headerSize = currentLoginData.headerSize; + let currentInfoIndex = loginData.size; + // pointer to the current login item + let currentLoginItemPointer = ctypes.cast( + cdata.addressOfElement(currentInfoIndex), + loginItem.ptr + ); + // currentLoginData.dataMax is the data count: each username and password is considered as + // a data. So, the number of logins is the number of data dived by 2 + let numLogins = currentLoginData.dataMax / 2; + for (let n = 0; n < numLogins; n++) { + // Bytes 0-31 starting from currentInfoIndex contain the loginItem data structure for the + // current login + let currentLoginItem = currentLoginItemPointer.contents; + let creation = + this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + currentLoginItem.hiDateTime, + currentLoginItem.loDateTime + ) * 1000; + let currentResult = { + creation, + url, + }; + // The username is UTF-16 and null-terminated. + currentResult.username = ctypes + .cast( + cdata.addressOfElement( + headerSize + 12 + currentLoginItem.usernameOffset + ), + ctypes.char16_t.ptr + ) + .readString(); + // The password is UTF-16 and null-terminated. + currentResult.password = ctypes + .cast( + cdata.addressOfElement( + headerSize + 12 + currentLoginItem.passwordOffset + ), + ctypes.char16_t.ptr + ) + .readString(); + results.push(currentResult); + // move to the next login item + currentLoginItemPointer = currentLoginItemPointer.increment(); + } + return results; + }, +}; + +function IEProfileMigrator() { + this.wrappedJSObject = this; // export this to be able to use it in the unittest. +} + +IEProfileMigrator.prototype = Object.create(MigratorPrototype); + +IEProfileMigrator.prototype.getResources = function IE_getResources() { + let resources = [ + MSMigrationUtils.getBookmarksMigrator(), + new History(), + MSMigrationUtils.getCookiesMigrator(), + ]; + // Only support the form password migrator for Windows XP to 7. + if (AppConstants.isPlatformAndVersionAtMost("win", "6.1")) { + resources.push(new IE7FormPasswords()); + } + let windowsVaultFormPasswordsMigrator = MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "IEVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); +}; + +IEProfileMigrator.prototype.getLastUsedDate = function IE_getLastUsedDate() { + let datePromises = ["Favs", "CookD"].map(dirId => { + let { path } = Services.dirsvc.get(dirId, Ci.nsIFile); + return OS.File.stat(path) + .catch(() => null) + .then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + datePromises.push( + new Promise(resolve => { + let typedURLs = new Map(); + try { + typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + } catch (ex) {} + let dates = [0, ...typedURLs.values()]; + // dates is an array of PRTimes, which are in microseconds - convert to milliseconds + resolve(Math.max.apply(Math, dates) / 1000); + }) + ); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); +}; + +IEProfileMigrator.prototype.classDescription = "IE Profile Migrator"; +IEProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=ie"; +IEProfileMigrator.prototype.classID = Components.ID( + "{3d2532e3-4932-4774-b7ba-968f5899d3a4}" +); + +var EXPORTED_SYMBOLS = ["IEProfileMigrator"]; diff --git a/browser/components/migration/MSMigrationUtils.jsm b/browser/components/migration/MSMigrationUtils.jsm new file mode 100644 index 0000000000..8826b2ea25 --- /dev/null +++ b/browser/components/migration/MSMigrationUtils.jsm @@ -0,0 +1,1041 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["MSMigrationUtils"]; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { MigrationUtils } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["FileReader"]); + +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ctypes", + "resource://gre/modules/ctypes.jsm" +); + +const EDGE_COOKIE_PATH_OPTIONS = ["", "#!001\\", "#!002\\"]; +const EDGE_COOKIES_SUFFIX = "MicrosoftEdge\\Cookies"; +const EDGE_FAVORITES = "AC\\MicrosoftEdge\\User\\Default\\Favorites"; +const FREE_CLOSE_FAILED = 0; +const INTERNET_EXPLORER_EDGE_GUID = [ + 0x3ccd5499, + 0x4b1087a8, + 0x886015a2, + 0x553bdd88, +]; +const RESULT_SUCCESS = 0; +const VAULT_ENUMERATE_ALL_ITEMS = 512; +const WEB_CREDENTIALS_VAULT_ID = [ + 0x4bf4c442, + 0x41a09b8a, + 0x4add80b3, + 0x28db4d70, +]; + +XPCOMUtils.defineLazyGlobalGetters(this, ["File"]); + +const wintypes = { + BOOL: ctypes.int, + DWORD: ctypes.uint32_t, + DWORDLONG: ctypes.uint64_t, + CHAR: ctypes.char, + PCHAR: ctypes.char.ptr, + LPCWSTR: ctypes.char16_t.ptr, + PDWORD: ctypes.uint32_t.ptr, + VOIDP: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +// TODO: Bug 1202978 - Refactor MSMigrationUtils ctypes helpers +function CtypesKernelHelpers() { + this._structs = {}; + this._functions = {}; + this._libs = {}; + + this._structs.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: wintypes.WORD }, + { wMonth: wintypes.WORD }, + { wDayOfWeek: wintypes.WORD }, + { wDay: wintypes.WORD }, + { wHour: wintypes.WORD }, + { wMinute: wintypes.WORD }, + { wSecond: wintypes.WORD }, + { wMilliseconds: wintypes.WORD }, + ]); + + this._structs.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: wintypes.DWORD }, + { dwHighDateTime: wintypes.DWORD }, + ]); + + try { + this._libs.kernel32 = ctypes.open("Kernel32"); + + this._functions.FileTimeToSystemTime = this._libs.kernel32.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + wintypes.BOOL, + this._structs.FILETIME.ptr, + this._structs.SYSTEMTIME.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesKernelHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + for (let key in this._libs) { + let lib = this._libs[key]; + try { + lib.close(); + } catch (ex) {} + } + this._libs = {}; + }, + + /** + * Converts a FILETIME struct (2 DWORDS), to a SYSTEMTIME struct, + * and then deduces the number of seconds since the epoch (which + * is the data we want for the cookie expiry date). + * + * @param aTimeHi + * Least significant DWORD. + * @param aTimeLo + * Most significant DWORD. + * @return the number of seconds since the epoch + */ + fileTimeToSecondsSinceEpoch(aTimeHi, aTimeLo) { + let fileTime = this._structs.FILETIME(); + fileTime.dwLowDateTime = aTimeLo; + fileTime.dwHighDateTime = aTimeHi; + let systemTime = this._structs.SYSTEMTIME(); + let result = this._functions.FileTimeToSystemTime( + fileTime.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return Math.floor( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) / 1000 + ); + }, +}; + +function CtypesVaultHelpers() { + this._structs = {}; + this._functions = {}; + + this._structs.GUID = new ctypes.StructType("GUID", [ + { id: wintypes.DWORD.array(4) }, + ]); + + this._structs.VAULT_ITEM_ELEMENT = new ctypes.StructType( + "VAULT_ITEM_ELEMENT", + [ + // not documented + { schemaElementId: wintypes.DWORD }, + // not documented + { unknown1: wintypes.DWORD }, + // vault type + { type: wintypes.DWORD }, + // not documented + { unknown2: wintypes.DWORD }, + // value of the item + { itemValue: wintypes.LPCWSTR }, + // not documented + { unknown3: wintypes.CHAR.array(12) }, + ] + ); + + this._structs.VAULT_ELEMENT = new ctypes.StructType("VAULT_ELEMENT", [ + // vault item schemaId + { schemaId: this._structs.GUID }, + // a pointer to the name of the browser VAULT_ITEM_ELEMENT + { pszCredentialFriendlyName: wintypes.LPCWSTR }, + // a pointer to the url VAULT_ITEM_ELEMENT + { pResourceElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // a pointer to the username VAULT_ITEM_ELEMENT + { pIdentityElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pAuthenticatorElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pPackageSid: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // time stamp in local format + { lowLastModified: wintypes.DWORD }, + { highLastModified: wintypes.DWORD }, + // not documented + { flags: wintypes.DWORD }, + // not documented + { dwPropertiesCount: wintypes.DWORD }, + // not documented + { pPropertyElements: this._structs.VAULT_ITEM_ELEMENT.ptr }, + ]); + + try { + this._vaultcliLib = ctypes.open("vaultcli.dll"); + + this._functions.VaultOpenVault = this._vaultcliLib.declare( + "VaultOpenVault", + ctypes.winapi_abi, + wintypes.DWORD, + // GUID + this._structs.GUID.ptr, + // Flags + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP.ptr + ); + this._functions.VaultEnumerateItems = this._vaultcliLib.declare( + "VaultEnumerateItems", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Flags + wintypes.DWORD, + // Items Count + wintypes.PDWORD, + // Items + ctypes.voidptr_t + ); + this._functions.VaultCloseVault = this._vaultcliLib.declare( + "VaultCloseVault", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP + ); + this._functions.VaultGetItem = this._vaultcliLib.declare( + "VaultGetItem", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Schema Id + this._structs.GUID.ptr, + // Resource + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Identity + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Package Sid + this._structs.VAULT_ITEM_ELEMENT.ptr, + // HWND Owner + wintypes.DWORD, + // Flags + wintypes.DWORD, + // Items + this._structs.VAULT_ELEMENT.ptr.ptr + ); + this._functions.VaultFree = this._vaultcliLib.declare( + "VaultFree", + ctypes.winapi_abi, + wintypes.DWORD, + // Memory + this._structs.VAULT_ELEMENT.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesVaultHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + try { + this._vaultcliLib.close(); + } catch (ex) {} + this._vaultcliLib = null; + }, +}; + +/** + * Checks whether an host is an IP (v4 or v6) address. + * + * @param aHost + * The host to check. + * @return whether aHost is an IP address. + */ +function hostIsIPAddress(aHost) { + try { + Services.eTLD.getBaseDomainFromHost(aHost); + } catch (e) { + return e.result == Cr.NS_ERROR_HOST_IS_IP_ADDRESS; + } + return false; +} + +var gEdgeDir; +function getEdgeLocalDataFolder() { + if (gEdgeDir) { + return gEdgeDir.clone(); + } + let packages = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + packages.append("Packages"); + let edgeDir = packages.clone(); + edgeDir.append("Microsoft.MicrosoftEdge_8wekyb3d8bbwe"); + try { + if (edgeDir.exists() && edgeDir.isReadable() && edgeDir.isDirectory()) { + gEdgeDir = edgeDir; + return edgeDir.clone(); + } + + // Let's try the long way: + let dirEntries = packages.directoryEntries; + while (dirEntries.hasMoreElements()) { + let subDir = dirEntries.nextFile; + if ( + subDir.leafName.startsWith("Microsoft.MicrosoftEdge") && + subDir.isReadable() && + subDir.isDirectory() + ) { + gEdgeDir = subDir; + return subDir.clone(); + } + } + } catch (ex) { + Cu.reportError( + "Exception trying to find the Edge favorites directory: " + ex + ); + } + return null; +} + +function Bookmarks(migrationType) { + this._migrationType = migrationType; +} + +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return !!this._favoritesFolder; + }, + + get importedAppLabel() { + return this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE + ? "IE" + : "Edge"; + }, + + __favoritesFolder: null, + get _favoritesFolder() { + if (!this.__favoritesFolder) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + let favoritesFolder = Services.dirsvc.get("Favs", Ci.nsIFile); + if (favoritesFolder.exists() && favoritesFolder.isReadable()) { + this.__favoritesFolder = favoritesFolder; + } + } else if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE) { + let edgeDir = getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.appendRelativePath(EDGE_FAVORITES); + if ( + edgeDir.exists() && + edgeDir.isReadable() && + edgeDir.isDirectory() + ) { + this.__favoritesFolder = edgeDir; + } + } + } + } + return this.__favoritesFolder; + }, + + __toolbarFolderName: null, + get _toolbarFolderName() { + if (!this.__toolbarFolderName) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + // Retrieve the name of IE's favorites subfolder that holds the bookmarks + // in the toolbar. This was previously stored in the registry and changed + // in IE7 to always be called "Links". + let folderName = WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Internet Explorer\\Toolbar", + "LinksFolderName" + ); + this.__toolbarFolderName = folderName || "Links"; + } else { + this.__toolbarFolderName = "Links"; + } + } + return this.__toolbarFolderName; + }, + + _histogramBookmarkRoots: 0, + migrate: function B_migrate(aCallback) { + return (async () => { + // Import to the bookmarks menu. + let folderGuid = PlacesUtils.bookmarks.menuGuid; + await this._migrateFolder(this._favoritesFolder, folderGuid); + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS") + .add( + this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE + ? "ie" + : "edge", + this._histogramBookmarkRoots + ); + })().then( + () => aCallback(true), + e => { + Cu.reportError(e); + aCallback(false); + } + ); + }, + + async _migrateFolder(aSourceFolder, aDestFolderGuid) { + let bookmarks = await this._getBookmarksInFolder(aSourceFolder); + if (!bookmarks.length) { + return; + } + + if (aDestFolderGuid == PlacesUtils.bookmarks.menuGuid) { + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU; + } else if (aDestFolderGuid == PlacesUtils.bookmarks.toolbarGuid) { + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + } + + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(aDestFolderGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + aDestFolderGuid = await MigrationUtils.createImportedBookmarksFolder( + this.importedAppLabel, + aDestFolderGuid + ); + } + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid); + }, + + async _getBookmarksInFolder(aSourceFolder) { + // TODO (bug 741993): the favorites order is stored in the Registry, at + // HCU\Software\Microsoft\Windows\CurrentVersion\Explorer\MenuOrder\Favorites + // for IE, and in a similar location for Edge. + // Until we support it, bookmarks are imported in alphabetical order. + let entries = aSourceFolder.directoryEntries; + let rv = []; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + try { + // Make sure that entry.path == entry.target to not follow .lnk folder + // shortcuts which could lead to infinite cycles. + // Don't use isSymlink(), since it would throw for invalid + // lnk files pointing to URLs or to unresolvable paths. + if (entry.path == entry.target && entry.isDirectory()) { + let isBookmarksFolder = + entry.leafName == this._toolbarFolderName && + entry.parent.equals(this._favoritesFolder); + if (isBookmarksFolder && entry.isReadable()) { + // Import to the bookmarks toolbar. + let folderGuid = PlacesUtils.bookmarks.toolbarGuid; + await this._migrateFolder(entry, folderGuid); + PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration(); + } else if (entry.isReadable()) { + let childBookmarks = await this._getBookmarksInFolder(entry); + rv.push({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: entry.leafName, + children: childBookmarks, + }); + } + } else { + // Strip the .url extension, to both check this is a valid link file, + // and get the associated title. + let matches = entry.leafName.match(/(.+)\.url$/i); + if (matches) { + let fileHandler = Cc[ + "@mozilla.org/network/protocol;1?name=file" + ].getService(Ci.nsIFileProtocolHandler); + let uri = fileHandler.readURLFile(entry); + rv.push({ url: uri, title: matches[1] }); + } + } + } catch (ex) { + Cu.reportError( + "Unable to import " + + this.importedAppLabel + + " favorite (" + + entry.leafName + + "): " + + ex + ); + } + } + return rv; + }, +}; + +function Cookies(migrationType) { + this._migrationType = migrationType; +} + +Cookies.prototype = { + type: MigrationUtils.resourceTypes.COOKIES, + + get exists() { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + return !!this._cookiesFolder; + } + return !!this._cookiesFolders; + }, + + __cookiesFolder: null, + get _cookiesFolder() { + // Edge stores cookies in a number of places, and this shouldn't get called: + if (this._migrationType != MSMigrationUtils.MIGRATION_TYPE_IE) { + throw new Error( + "Shouldn't be looking for a single cookie folder unless we're migrating IE" + ); + } + + // Cookies are stored in txt files, in a Cookies folder whose path varies + // across the different OS versions. CookD takes care of most of these + // cases, though, in Windows Vista/7, UAC makes a difference. + // If UAC is enabled, the most common destination is CookD/Low. Though, + // if the user runs the application in administrator mode or disables UAC, + // cookies are stored in the original CookD destination. Cause running the + // browser in administrator mode is unsafe and discouraged, we just care + // about the UAC state. + if (!this.__cookiesFolder) { + let cookiesFolder = Services.dirsvc.get("CookD", Ci.nsIFile); + if (cookiesFolder.exists() && cookiesFolder.isReadable()) { + // In versions up to Windows 7, check if UAC is enabled. + if ( + AppConstants.isPlatformAndVersionAtMost("win", "6.1") && + Services.appinfo.QueryInterface(Ci.nsIWinAppHelper).userCanElevate + ) { + cookiesFolder.append("Low"); + } + this.__cookiesFolder = cookiesFolder; + } + } + return this.__cookiesFolder; + }, + + __cookiesFolders: null, + get _cookiesFolders() { + if (this._migrationType != MSMigrationUtils.MIGRATION_TYPE_EDGE) { + throw new Error( + "Shouldn't be looking for multiple cookie folders unless we're migrating Edge" + ); + } + + let folders = []; + let edgeDir = getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.append("AC"); + for (let path of EDGE_COOKIE_PATH_OPTIONS) { + let folder = edgeDir.clone(); + let fullPath = path + EDGE_COOKIES_SUFFIX; + folder.appendRelativePath(fullPath); + if (folder.exists() && folder.isReadable() && folder.isDirectory()) { + folders.push(folder); + } + } + } + this.__cookiesFolders = folders.length ? folders : null; + return this.__cookiesFolders; + }, + + migrate(aCallback) { + this.ctypesKernelHelpers = new CtypesKernelHelpers(); + + let cookiesGenerator = function* genCookie() { + let success = false; + let folders = + this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE + ? this.__cookiesFolders + : [this.__cookiesFolder]; + for (let folder of folders) { + let entries = folder.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + // Skip eventual bogus entries. + if (!entry.isFile() || !/\.(cookie|txt)$/.test(entry.leafName)) { + continue; + } + + this._readCookieFile(entry, function(aSuccess) { + // Importing even a single cookie file is considered a success. + if (aSuccess) { + success = true; + } + try { + cookiesGenerator.next(); + } catch (ex) {} + }); + + yield undefined; + } + } + + this.ctypesKernelHelpers.finalize(); + + aCallback(success); + }.apply(this); + cookiesGenerator.next(); + }, + + _readCookieFile(aFile, aCallback) { + File.createFromNsIFile(aFile).then( + file => { + let fileReader = new FileReader(); + let onLoadEnd = () => { + fileReader.removeEventListener("loadend", onLoadEnd); + + if (fileReader.readyState != fileReader.DONE) { + Cu.reportError( + "Could not read cookie contents: " + fileReader.error + ); + aCallback(false); + return; + } + + let success = true; + try { + this._parseCookieBuffer(fileReader.result); + } catch (ex) { + Cu.reportError("Unable to migrate cookie: " + ex); + success = false; + } finally { + aCallback(success); + } + }; + fileReader.addEventListener("loadend", onLoadEnd); + fileReader.readAsText(file); + }, + () => { + aCallback(false); + } + ); + }, + + /** + * Parses a cookie file buffer and returns an array of the contained cookies. + * + * The cookie file format is a newline-separated-values with a "*" used as + * delimeter between multiple records. + * Each cookie has the following fields: + * - name + * - value + * - host/path + * - flags + * - Expiration time most significant integer + * - Expiration time least significant integer + * - Creation time most significant integer + * - Creation time least significant integer + * - Record delimiter "*" + * + * Unfortunately, "*" can also occur inside the value of the cookie, so we + * can't rely exclusively on it as a record separator. + * + * @note All the times are in FILETIME format. + */ + _parseCookieBuffer(aTextBuffer) { + // Note the last record is an empty string... + let records = []; + let lines = aTextBuffer.split("\n"); + while (lines.length) { + let record = lines.splice(0, 9); + // ... which means this is going to be a 1-element array for that record + if (record.length > 1) { + records.push(record); + } + } + for (let record of records) { + let [name, value, hostpath, flags, expireTimeLo, expireTimeHi] = record; + + // IE stores deleted cookies with a zero-length value, skip them. + if (!value.length) { + continue; + } + + // IE sometimes has cookies created by apps that use "~~local~~/local/file/path" + // as the hostpath, ignore those: + if (hostpath.startsWith("~~local~~")) { + continue; + } + + let hostLen = hostpath.indexOf("/"); + let host = hostpath.substr(0, hostLen); + let path = hostpath.substr(hostLen); + + // For a non-null domain, assume it's what Mozilla considers + // a domain cookie. See bug 222343. + if (host.length) { + // Fist delete any possible extant matching host cookie. + Services.cookies.remove(host, name, path, {}); + // Now make it a domain cookie. + if (host[0] != "." && !hostIsIPAddress(host)) { + host = "." + host; + } + } + + // Fallback: expire in 1h (NB: time is in seconds since epoch, so we have + // to divide the result of Date.now() (which is in milliseconds) by 1000). + let expireTime = Math.floor(Date.now() / 1000) + 3600; + try { + expireTime = this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + Number(expireTimeHi), + Number(expireTimeLo) + ); + } catch (ex) { + Cu.reportError("Failed to get expiry time for cookie for " + host); + } + + Services.cookies.add( + host, + path, + name, + value, + Number(flags) & 0x1, // secure + false, // httpOnly + false, // session + expireTime, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_UNSET + ); + } + }, +}; + +function getTypedURLs(registryKeyPath) { + // The list of typed URLs is a sort of annotation stored in the registry. + // The number of entries stored is not UI-configurable, but has changed + // between different Windows versions. We just keep reading up to the first + // non-existing entry to support different limits / states of the registry. + let typedURLs = new Map(); + let typedURLKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let typedURLTimeKey = Cc[ + "@mozilla.org/windows-registry-key;1" + ].createInstance(Ci.nsIWindowsRegKey); + let cTypes = new CtypesKernelHelpers(); + try { + try { + typedURLKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLs", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + // Ignore errors opening this registry key - if it doesn't work, there's + // no way we can get useful info here. + return typedURLs; + } + try { + typedURLTimeKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLsTime", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + typedURLTimeKey = null; + } + let entryName; + for ( + let entry = 1; + typedURLKey.hasValue((entryName = "url" + entry)); + entry++ + ) { + let url = typedURLKey.readStringValue(entryName); + // If we can't get a date for whatever reason, default to 6 months ago + let timeTyped = Date.now() - 31536000 / 2; + if (typedURLTimeKey && typedURLTimeKey.hasValue(entryName)) { + let urlTime = ""; + try { + urlTime = typedURLTimeKey.readBinaryValue(entryName); + } catch (ex) { + Cu.reportError("Couldn't read url time for " + entryName); + } + if (urlTime.length == 8) { + let urlTimeHex = []; + for (let i = 0; i < 8; i++) { + let c = urlTime.charCodeAt(i).toString(16); + if (c.length == 1) { + c = "0" + c; + } + urlTimeHex.unshift(c); + } + try { + let hi = parseInt(urlTimeHex.slice(0, 4).join(""), 16); + let lo = parseInt(urlTimeHex.slice(4, 8).join(""), 16); + // Convert to seconds since epoch: + let secondsSinceEpoch = cTypes.fileTimeToSecondsSinceEpoch(hi, lo); + + // If the date is very far in the past, just use the default + if (secondsSinceEpoch > Date.now() / 1000000) { + // Callers expect PRTime, which is microseconds since epoch: + timeTyped = secondsSinceEpoch * 1000; + } + } catch (ex) { + // Ignore conversion exceptions. Callers will have to deal + // with the fallback value. + } + } + } + typedURLs.set(url, timeTyped * 1000); + } + } catch (ex) { + Cu.reportError("Error reading typed URL history: " + ex); + } finally { + if (typedURLKey) { + typedURLKey.close(); + } + if (typedURLTimeKey) { + typedURLTimeKey.close(); + } + cTypes.finalize(); + } + return typedURLs; +} + +// Migrator for form passwords on Windows 8 and higher. +function WindowsVaultFormPasswords() {} + +WindowsVaultFormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // work only on windows 8+ + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + // check if there are passwords available for migration. + return this.migrate(() => {}, true); + } + return false; + }, + + /** + * If aOnlyCheckExists is false, import the form passwords on Windows 8 and higher from the vault + * and then call the aCallback. + * Otherwise, check if there are passwords in the vault. + * @param {function} aCallback - a callback called when the migration is done. + * @param {boolean} [aOnlyCheckExists=false] - if aOnlyCheckExists is true, just check if there are some + * passwords to migrate. Import the passwords from the vault and call aCallback otherwise. + * @return true if there are passwords in the vault and aOnlyCheckExists is set to true, + * false if there is no password in the vault and aOnlyCheckExists is set to true, undefined if + * aOnlyCheckExists is set to false. + */ + async migrate(aCallback, aOnlyCheckExists = false) { + // check if the vault item is an IE/Edge one + function _isIEOrEdgePassword(id) { + return ( + id[0] == INTERNET_EXPLORER_EDGE_GUID[0] && + id[1] == INTERNET_EXPLORER_EDGE_GUID[1] && + id[2] == INTERNET_EXPLORER_EDGE_GUID[2] && + id[3] == INTERNET_EXPLORER_EDGE_GUID[3] + ); + } + + let ctypesVaultHelpers = new CtypesVaultHelpers(); + let ctypesKernelHelpers = new CtypesKernelHelpers(); + let migrationSucceeded = true; + let successfulVaultOpen = false; + let error, vault; + try { + // web credentials vault id + let vaultGuid = new ctypesVaultHelpers._structs.GUID( + WEB_CREDENTIALS_VAULT_ID + ); + error = new wintypes.DWORD(); + // web credentials vault + vault = new wintypes.VOIDP(); + // open the current vault using the vaultGuid + error = ctypesVaultHelpers._functions.VaultOpenVault( + vaultGuid.address(), + 0, + vault.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to open Vault: " + error); + } + successfulVaultOpen = true; + + let item = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + let itemCount = new wintypes.DWORD(); + // enumerate all the available items. This api is going to return a table of all the + // available items and item is going to point to the first element of this table. + error = ctypesVaultHelpers._functions.VaultEnumerateItems( + vault, + VAULT_ENUMERATE_ALL_ITEMS, + itemCount.address(), + item.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to enumerate Vault items: " + error); + } + + let logins = []; + for (let j = 0; j < itemCount.value; j++) { + try { + // if it's not an ie/edge password, skip it + if (!_isIEOrEdgePassword(item.contents.schemaId.id)) { + continue; + } + let url = item.contents.pResourceElement.contents.itemValue.readString(); + let realURL; + try { + realURL = Services.io.newURI(url); + } catch (ex) { + /* leave realURL as null */ + } + if (!realURL || !["http", "https", "ftp"].includes(realURL.scheme)) { + // Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP + continue; + } + + // if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at + // least a password which is true in this case because a password was by now already found + if (aOnlyCheckExists) { + return true; + } + let username = item.contents.pIdentityElement.contents.itemValue.readString(); + // the current login credential object + let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + error = ctypesVaultHelpers._functions.VaultGetItem( + vault, + item.contents.schemaId.address(), + item.contents.pResourceElement, + item.contents.pIdentityElement, + null, + 0, + 0, + credential.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to get item: " + error); + } + + let password = credential.contents.pAuthenticatorElement.contents.itemValue.readString(); + let creation = Date.now(); + try { + // login manager wants time in milliseconds since epoch, so convert + // to seconds since epoch and multiply to get milliseconds: + creation = + ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + item.contents.highLastModified, + item.contents.lowLastModified + ) * 1000; + } catch (ex) { + // Ignore exceptions in the dates and just create the login for right now. + } + // create a new login + logins.push({ + username, + password, + origin: realURL.prePath, + timeCreated: creation, + }); + + // close current item + error = ctypesVaultHelpers._functions.VaultFree(credential); + if (error == FREE_CLOSE_FAILED) { + throw new Error("Unable to free item: " + error); + } + } catch (e) { + migrationSucceeded = false; + Cu.reportError(e); + } finally { + // move to next item in the table returned by VaultEnumerateItems + item = item.increment(); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + Cu.reportError(e); + migrationSucceeded = false; + } finally { + if (successfulVaultOpen) { + // close current vault + error = ctypesVaultHelpers._functions.VaultCloseVault(vault); + if (error == FREE_CLOSE_FAILED) { + Cu.reportError("Unable to close vault: " + error); + } + } + ctypesKernelHelpers.finalize(); + ctypesVaultHelpers.finalize(); + aCallback(migrationSucceeded); + } + if (aOnlyCheckExists) { + return false; + } + return undefined; + }, +}; + +var MSMigrationUtils = { + MIGRATION_TYPE_IE: 1, + MIGRATION_TYPE_EDGE: 2, + CtypesKernelHelpers, + getBookmarksMigrator(migrationType = this.MIGRATION_TYPE_IE) { + return new Bookmarks(migrationType); + }, + getCookiesMigrator(migrationType = this.MIGRATION_TYPE_IE) { + return new Cookies(migrationType); + }, + getWindowsVaultFormPasswordsMigrator() { + return new WindowsVaultFormPasswords(); + }, + getTypedURLs, + getEdgeLocalDataFolder, +}; diff --git a/browser/components/migration/MigrationUtils.jsm b/browser/components/migration/MigrationUtils.jsm new file mode 100644 index 0000000000..8d695f9001 --- /dev/null +++ b/browser/components/migration/MigrationUtils.jsm @@ -0,0 +1,1326 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["MigrationUtils", "MigratorPrototype"]; + +const TOPIC_WILL_IMPORT_BOOKMARKS = + "initial-migration-will-import-default-bookmarks"; +const TOPIC_DID_IMPORT_BOOKMARKS = + "initial-migration-did-import-default-bookmarks"; +const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +ChromeUtils.defineModuleGetter( + this, + "BookmarkHTMLUtils", + "resource://gre/modules/BookmarkHTMLUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "LoginHelper", + "resource://gre/modules/LoginHelper.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ResponsivenessMonitor", + "resource://gre/modules/ResponsivenessMonitor.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Sqlite", + "resource://gre/modules/Sqlite.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "setTimeout", + "resource://gre/modules/Timer.jsm" +); + +var gMigrators = null; +var gProfileStartup = null; +var gL10n = null; +var gPreviousDefaultBrowserKey = ""; + +let gForceExitSpinResolve = false; +let gKeepUndoData = false; +let gUndoData = null; + +XPCOMUtils.defineLazyGetter(this, "gAvailableMigratorKeys", function() { + if (AppConstants.platform == "win") { + return [ + "firefox", + "edge", + "ie", + "chrome", + "chromium-edge", + "chromium-edge-beta", + "chrome-beta", + "chromium", + "360se", + "canary", + ]; + } + if (AppConstants.platform == "macosx") { + return [ + "firefox", + "safari", + "chrome", + "chromium-edge", + "chromium-edge-beta", + "chromium", + "canary", + ]; + } + if (AppConstants.XP_UNIX) { + return ["firefox", "chrome", "chrome-beta", "chrome-dev", "chromium"]; + } + return []; +}); + +function getL10n() { + if (!gL10n) { + gL10n = new Localization(["browser/migration.ftl"]); + } + return gL10n; +} + +/** + * Shared prototype for migrators, implementing nsIBrowserProfileMigrator. + * + * To implement a migrator: + * 1. Import this module. + * 2. Create the prototype for the migrator, extending MigratorPrototype. + * Namely: MosaicMigrator.prototype = Object.create(MigratorPrototype); + * 3. Set classDescription, contractID and classID for your migrator, and set + * NSGetFactory appropriately. + * 4. If the migrator supports multiple profiles, override the sourceProfiles + * Here we default for single-profile migrator. + * 5. Implement getResources(aProfile) (see below). + * 6. For startup-only migrators, override |startupOnlyMigrator|. + */ +var MigratorPrototype = { + QueryInterface: ChromeUtils.generateQI(["nsIBrowserProfileMigrator"]), + + /** + * OVERRIDE IF AND ONLY IF the source supports multiple profiles. + * + * Returns array of profile objects from which data may be imported. The object + * should have the following keys: + * id - a unique string identifier for the profile + * name - a pretty name to display to the user in the UI + * + * Only profiles from which data can be imported should be listed. Otherwise + * the behavior of the migration wizard isn't well-defined. + * + * For a single-profile source (e.g. safari, ie), this returns null, + * and not an empty array. That is the default implementation. + */ + getSourceProfiles() { + return null; + }, + + /** + * MUST BE OVERRIDDEN. + * + * Returns an array of "migration resources" objects for the given profile, + * or for the "default" profile, if the migrator does not support multiple + * profiles. + * + * Each migration resource should provide: + * - a |type| getter, returning any of the migration types (see + * nsIBrowserProfileMigrator). + * + * - a |migrate| method, taking a single argument, aCallback(bool success), + * for migrating the data for this resource. It may do its job + * synchronously or asynchronously. Either way, it must call + * aCallback(bool aSuccess) when it's done. In the case of an exception + * thrown from |migrate|, it's taken as if aCallback(false) is called. + * + * Note: In the case of a simple asynchronous implementation, you may find + * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily. + * + * For each migration type listed in nsIBrowserProfileMigrator, multiple + * migration resources may be provided. This practice is useful when the + * data for a certain migration type is independently stored in few + * locations. For example, the mac version of Safari stores its "reading list" + * bookmarks in a separate property list. + * + * Note that the importation of a particular migration type is reported as + * successful if _any_ of its resources succeeded to import (that is, called, + * |aCallback(true)|). However, completion-status for a particular migration + * type is reported to the UI only once all of its migrators have called + * aCallback. + * + * @note The returned array should only include resources from which data + * can be imported. So, for example, before adding a resource for the + * BOOKMARKS migration type, you should check if you should check that the + * bookmarks file exists. + * + * @param aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * In the case of multiple-profiles migrator, it is guaranteed that + * aProfile is a value returned by the sourceProfiles getter (see + * above). + */ + getResources: function MP_getResources(/* aProfile */) { + throw new Error("getResources must be overridden"); + }, + + /** + * OVERRIDE in order to provide an estimate of when the last time was + * that somebody used the browser. It is OK that this is somewhat fuzzy - + * history may not be available (or be wiped or not present due to e.g. + * incognito mode). + * + * @return a Promise that resolves to the last used date. + * + * @note If not overridden, the promise will resolve to the unix epoch. + */ + getLastUsedDate() { + return Promise.resolve(new Date(0)); + }, + + /** + * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now, + * that is just the Firefox migrator, see bug 737381). Default: false. + * + * Startup-only migrators are different in two ways: + * - they may only be used during startup. + * - the user-profile is half baked during migration. The folder exists, + * but it's only accessible through MigrationUtils.profileStartup. + * The migrator can call MigrationUtils.profileStartup.doStartup + * at any point in order to initialize the profile. + */ + get startupOnlyMigrator() { + return false; + }, + + /** + * Override if the data to migrate is locked/in-use and the user should + * probably shutdown the source browser. + */ + get sourceLocked() { + return false; + }, + + /** + * DO NOT OVERRIDE - After deCOMing migration, the UI will just call + * getResources. + * + * @see nsIBrowserProfileMigrator + */ + getMigrateData: async function MP_getMigrateData(aProfile) { + let resources = await this._getMaybeCachedResources(aProfile); + if (!resources) { + return 0; + } + let types = resources.map(r => r.type); + return types.reduce((a, b) => { + a |= b; + return a; + }, 0); + }, + + getBrowserKey: function MP_getBrowserKey() { + return this.contractID.match(/\=([^\=]+)$/)[1]; + }, + + /** + * DO NOT OVERRIDE - After deCOMing migration, the UI will just call + * migrate for each resource. + * + * @see nsIBrowserProfileMigrator + */ + migrate: async function MP_migrate(aItems, aStartup, aProfile) { + let resources = await this._getMaybeCachedResources(aProfile); + if (!resources.length) { + throw new Error("migrate called for a non-existent source"); + } + + if (aItems != Ci.nsIBrowserProfileMigrator.ALL) { + resources = resources.filter(r => aItems & r.type); + } + + // Used to periodically give back control to the main-thread loop. + let unblockMainThread = function() { + return new Promise(resolve => { + Services.tm.dispatchToMainThread(resolve); + }); + }; + + let getHistogramIdForResourceType = (resourceType, template) => { + if (resourceType == MigrationUtils.resourceTypes.HISTORY) { + return template.replace("*", "HISTORY"); + } + if (resourceType == MigrationUtils.resourceTypes.BOOKMARKS) { + return template.replace("*", "BOOKMARKS"); + } + if (resourceType == MigrationUtils.resourceTypes.PASSWORDS) { + return template.replace("*", "LOGINS"); + } + return null; + }; + + let browserKey = this.getBrowserKey(); + + let maybeStartTelemetryStopwatch = resourceType => { + let histogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_IMPORT_MS" + ); + if (histogramId) { + TelemetryStopwatch.startKeyed(histogramId, browserKey); + } + return histogramId; + }; + + let maybeStartResponsivenessMonitor = resourceType => { + let responsivenessMonitor; + let responsivenessHistogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_JANK_MS" + ); + if (responsivenessHistogramId) { + responsivenessMonitor = new ResponsivenessMonitor(); + } + return { responsivenessMonitor, responsivenessHistogramId }; + }; + + let maybeFinishResponsivenessMonitor = ( + responsivenessMonitor, + histogramId + ) => { + if (responsivenessMonitor) { + let accumulatedDelay = responsivenessMonitor.finish(); + if (histogramId) { + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add(browserKey, accumulatedDelay); + } catch (ex) { + Cu.reportError(histogramId + ": " + ex); + } + } + } + }; + + let collectQuantityTelemetry = () => { + for (let resourceType of Object.keys(MigrationUtils._importQuantities)) { + let histogramId = + "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY"; + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add(browserKey, MigrationUtils._importQuantities[resourceType]); + } catch (ex) { + Cu.reportError(histogramId + ": " + ex); + } + } + }; + + // Called either directly or through the bookmarks import callback. + let doMigrate = async function() { + let resourcesGroupedByItems = new Map(); + resources.forEach(function(resource) { + if (!resourcesGroupedByItems.has(resource.type)) { + resourcesGroupedByItems.set(resource.type, new Set()); + } + resourcesGroupedByItems.get(resource.type).add(resource); + }); + + if (resourcesGroupedByItems.size == 0) { + throw new Error("No items to import"); + } + + let notify = function(aMsg, aItemType) { + Services.obs.notifyObservers(null, aMsg, aItemType); + }; + + for (let resourceType of Object.keys(MigrationUtils._importQuantities)) { + MigrationUtils._importQuantities[resourceType] = 0; + } + notify("Migration:Started"); + for (let [migrationType, itemResources] of resourcesGroupedByItems) { + notify("Migration:ItemBeforeMigrate", migrationType); + + let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType); + + let { + responsivenessMonitor, + responsivenessHistogramId, + } = maybeStartResponsivenessMonitor(migrationType); + + let itemSuccess = false; + for (let res of itemResources) { + let completeDeferred = PromiseUtils.defer(); + let resourceDone = function(aSuccess) { + itemResources.delete(res); + itemSuccess |= aSuccess; + if (itemResources.size == 0) { + notify( + itemSuccess + ? "Migration:ItemAfterMigrate" + : "Migration:ItemError", + migrationType + ); + resourcesGroupedByItems.delete(migrationType); + + if (stopwatchHistogramId) { + TelemetryStopwatch.finishKeyed( + stopwatchHistogramId, + browserKey + ); + } + + maybeFinishResponsivenessMonitor( + responsivenessMonitor, + responsivenessHistogramId + ); + + if (resourcesGroupedByItems.size == 0) { + collectQuantityTelemetry(); + notify("Migration:Ended"); + } + } + completeDeferred.resolve(); + }; + + // If migrate throws, an error occurred, and the callback + // (itemMayBeDone) might haven't been called. + try { + res.migrate(resourceDone); + } catch (ex) { + Cu.reportError(ex); + resourceDone(false); + } + + await completeDeferred.promise; + await unblockMainThread(); + } + } + }; + + if ( + MigrationUtils.isStartupMigration && + !this.startupOnlyMigrator && + Services.policies.isAllowed("defaultBookmarks") + ) { + MigrationUtils.profileStartup.doStartup(); + // First import the default bookmarks. + // Note: We do not need to do so for the Firefox migrator + // (=startupOnlyMigrator), as it just copies over the places database + // from another profile. + (async function() { + // Tell nsBrowserGlue we're importing default bookmarks. + let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, ""); + + // Import the default bookmarks. We ignore whether or not we succeed. + await BookmarkHTMLUtils.importFromURL( + "chrome://browser/locale/bookmarks.html", + { + replace: true, + source: PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + } + ).catch(Cu.reportError); + + // We'll tell nsBrowserGlue we've imported bookmarks, but before that + // we need to make sure we're going to know when it's finished + // initializing places: + let placesInitedPromise = new Promise(resolve => { + let onPlacesInited = function() { + Services.obs.removeObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + resolve(); + }; + Services.obs.addObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + }); + browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, ""); + await placesInitedPromise; + doMigrate(); + })(); + return; + } + doMigrate(); + }, + + /** + * DO NOT OVERRIDE - After deCOMing migration, this code + * won't be part of the migrator itself. + * + * @see nsIBrowserProfileMigrator + */ + async isSourceAvailable() { + if (this.startupOnlyMigrator && !MigrationUtils.isStartupMigration) { + return false; + } + + // For a single-profile source, check if any data is available. + // For multiple-profiles source, make sure that at least one + // profile is available. + let exists = false; + try { + let profiles = await this.getSourceProfiles(); + if (!profiles) { + let resources = await this._getMaybeCachedResources(""); + if (resources && resources.length) { + exists = true; + } + } else { + exists = !!profiles.length; + } + } catch (ex) { + Cu.reportError(ex); + } + return exists; + }, + + /** * PRIVATE STUFF - DO NOT OVERRIDE ***/ + _getMaybeCachedResources: async function PMB__getMaybeCachedResources( + aProfile + ) { + let profileKey = aProfile ? aProfile.id : ""; + if (this._resourcesByProfile) { + if (profileKey in this._resourcesByProfile) { + return this._resourcesByProfile[profileKey]; + } + } else { + this._resourcesByProfile = {}; + } + this._resourcesByProfile[profileKey] = await this.getResources(aProfile); + return this._resourcesByProfile[profileKey]; + }, +}; + +var MigrationUtils = Object.seal({ + resourceTypes: { + COOKIES: Ci.nsIBrowserProfileMigrator.COOKIES, + HISTORY: Ci.nsIBrowserProfileMigrator.HISTORY, + FORMDATA: Ci.nsIBrowserProfileMigrator.FORMDATA, + PASSWORDS: Ci.nsIBrowserProfileMigrator.PASSWORDS, + BOOKMARKS: Ci.nsIBrowserProfileMigrator.BOOKMARKS, + OTHERDATA: Ci.nsIBrowserProfileMigrator.OTHERDATA, + SESSION: Ci.nsIBrowserProfileMigrator.SESSION, + }, + + /** + * Helper for implementing simple asynchronous cases of migration resources' + * |migrate(aCallback)| (see MigratorPrototype). If your |migrate| method + * just waits for some file to be read, for example, and then migrates + * everything right away, you can wrap the async-function with this helper + * and not worry about notifying the callback. + * + * For example, instead of writing: + * setTimeout(function() { + * try { + * .... + * aCallback(true); + * } + * catch() { + * aCallback(false); + * } + * }, 0); + * + * You may write: + * setTimeout(MigrationUtils.wrapMigrateFunction(function() { + * if (importingFromMosaic) + * throw Cr.NS_ERROR_UNEXPECTED; + * }, aCallback), 0); + * + * ... and aCallback will be called with aSuccess=false when importing + * from Mosaic, or with aSuccess=true otherwise. + * + * @param aFunction + * the function that will be called sometime later. If aFunction + * throws when it's called, aCallback(false) is called, otherwise + * aCallback(true) is called. + * @param aCallback + * the callback function passed to |migrate|. + * @return the wrapped function. + */ + wrapMigrateFunction: function MU_wrapMigrateFunction(aFunction, aCallback) { + return function() { + let success = false; + try { + aFunction.apply(null, arguments); + success = true; + } catch (ex) { + Cu.reportError(ex); + } + // Do not change this to call aCallback directly in try try & catch + // blocks, because if aCallback throws, we may end up calling aCallback + // twice. + aCallback(success); + }; + }, + + /** + * Gets localized string corresponding to l10n-id + * + * @param aKey + * The key of the id of the localization to retrieve. + * @param aArgs + * [optional] map of arguments to the id. + * @return A promise that resolves to the retrieved localization. + */ + getLocalizedString: function MU_getLocalizedString(aKey, aArgs) { + let l10n = getL10n(); + return l10n.formatValue(aKey, aArgs); + }, + + _getLocalePropertyForBrowser(browserId) { + switch (browserId) { + case "chromium-edge": + case "edge": + return "source-name-edge"; + case "ie": + return "source-name-ie"; + case "safari": + return "source-name-safari"; + case "canary": + return "source-name-canary"; + case "chrome": + return "source-name-chrome"; + case "chrome-beta": + return "source-name-chrome-beta"; + case "chrome-dev": + return "source-name-chrome-dev"; + case "chromium": + return "source-name-chromium"; + case "chromium-edge-beta": + return "source-name-chromium-edge-beta"; + case "firefox": + return "source-name-firefox"; + case "360se": + return "source-name-360se"; + } + return null; + }, + + /** + * Helper for creating a folder for imported bookmarks from a particular + * migration source. The folder is created at the end of the given folder. + * + * @param sourceNameStr + * the source name (first letter capitalized). This is used + * for reading the localized source name from the migration + * bundle (e.g. if aSourceNameStr is Mosaic, this will try to read + * sourceNameMosaic from the migration bundle). + * @param parentGuid + * the GUID of the folder in which the new folder should be created. + * @return the GUID of the new folder. + */ + async createImportedBookmarksFolder(sourceNameStr, parentGuid) { + let source = await this.getLocalizedString( + "source-name-" + sourceNameStr.toLowerCase() + ); + let title = await this.getLocalizedString("imported-bookmarks-source", { + source, + }); + return ( + await PlacesUtils.bookmarks.insert({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid, + title, + }) + ).guid; + }, + + /** + * Get all the rows corresponding to a select query from a database, without + * requiring a lock on the database. If fetching data fails (because someone + * else tried to write to the DB at the same time, for example), we will + * retry the fetch after a 100ms timeout, up to 10 times. + * + * @param path + * the file path to the database we want to open. + * @param description + * a developer-readable string identifying what kind of database we're + * trying to open. + * @param selectQuery + * the SELECT query to use to fetch the rows. + * + * @return a promise that resolves to an array of rows. The promise will be + * rejected if the read/fetch failed even after retrying. + */ + getRowsFromDBWithoutLocks(path, description, selectQuery) { + let dbOptions = { + readOnly: true, + ignoreLockingMode: true, + path, + }; + + const RETRYLIMIT = 10; + const RETRYINTERVAL = 100; + return (async function innerGetRows() { + let rows = null; + for (let retryCount = RETRYLIMIT; retryCount && !rows; retryCount--) { + // Attempt to get the rows. If this succeeds, we will bail out of the loop, + // close the database in a failsafe way, and pass the rows back. + // If fetching the rows throws, we will wait RETRYINTERVAL ms + // and try again. This will repeat a maximum of RETRYLIMIT times. + let db; + let didOpen = false; + let exceptionSeen; + try { + db = await Sqlite.openConnection(dbOptions); + didOpen = true; + rows = await db.execute(selectQuery); + } catch (ex) { + if (!exceptionSeen) { + Cu.reportError(ex); + } + exceptionSeen = ex; + } finally { + try { + if (didOpen) { + await db.close(); + } + } catch (ex) {} + } + if (exceptionSeen) { + await new Promise(resolve => setTimeout(resolve, RETRYINTERVAL)); + } + } + if (!rows) { + throw new Error( + "Couldn't get rows from the " + description + " database." + ); + } + return rows; + })(); + }, + + get _migrators() { + if (!gMigrators) { + gMigrators = new Map(); + } + return gMigrators; + }, + + forceExitSpinResolve: function MU_forceExitSpinResolve() { + gForceExitSpinResolve = true; + }, + + spinResolve: function MU_spinResolve(promise) { + if (!(promise instanceof Promise)) { + return promise; + } + let done = false; + let result = null; + let error = null; + gForceExitSpinResolve = false; + promise + .catch(e => { + error = e; + }) + .then(r => { + result = r; + done = true; + }); + + Services.tm.spinEventLoopUntil(() => done || gForceExitSpinResolve); + if (!done) { + throw new Error("Forcefully exited event loop."); + } else if (error) { + throw error; + } else { + return result; + } + }, + + /* + * Returns the migrator for the given source, if any data is available + * for this source, or null otherwise. + * + * @param aKey internal name of the migration source. + * See `gAvailableMigratorKeys` for supported values by OS. + * + * If null is returned, either no data can be imported + * for the given migrator, or aMigratorKey is invalid (e.g. ie on mac, + * or mosaic everywhere). This method should be used rather than direct + * getService for future compatibility (see bug 718280). + * + * @return profile migrator implementing nsIBrowserProfileMigrator, if it can + * import any data, null otherwise. + */ + getMigrator: async function MU_getMigrator(aKey) { + let migrator = null; + if (this._migrators.has(aKey)) { + migrator = this._migrators.get(aKey); + } else { + try { + migrator = Cc[ + "@mozilla.org/profile/migrator;1?app=browser&type=" + aKey + ].createInstance(Ci.nsIBrowserProfileMigrator); + } catch (ex) { + Cu.reportError(ex); + } + this._migrators.set(aKey, migrator); + } + + try { + return migrator && (await migrator.isSourceAvailable()) ? migrator : null; + } catch (ex) { + Cu.reportError(ex); + return null; + } + }, + + /** + * Figure out what is the default browser, and if there is a migrator + * for it, return that migrator's internal name. + * For the time being, the "internal name" of a migrator is its contract-id + * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie), + * but it will soon be exposed properly. + */ + getMigratorKeyForDefaultBrowser() { + // Canary uses the same description as Chrome so we can't distinguish them. + // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication. + const APP_DESC_TO_KEY = { + "Internet Explorer": "ie", + "Microsoft Edge": "edge", + Safari: "safari", + Firefox: "firefox", + Nightly: "firefox", + "Google Chrome": "chrome", // Windows, Linux + Chrome: "chrome", // OS X + Chromium: "chromium", // Windows, OS X + "Chromium Web Browser": "chromium", // Linux + "360\u5b89\u5168\u6d4f\u89c8\u5668": "360se", + }; + + let key = ""; + try { + let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .getApplicationDescription("http"); + key = APP_DESC_TO_KEY[browserDesc] || ""; + // Handle devedition, as well as "FirefoxNightly" on OS X. + if (!key && browserDesc.startsWith("Firefox")) { + key = "firefox"; + } + } catch (ex) { + Cu.reportError("Could not detect default browser: " + ex); + } + + // "firefox" is the least useful entry here, and might just be because we've set + // ourselves as the default (on Windows 7 and below). In that case, check if we + // have a registry key that tells us where to go: + if ( + key == "firefox" && + AppConstants.isPlatformAndVersionAtMost("win", "6.2") + ) { + // Because we remove the registry key, reading the registry key only works once. + // We save the value for subsequent calls to avoid hard-to-trace bugs when multiple + // consumers ask for this key. + if (gPreviousDefaultBrowserKey) { + key = gPreviousDefaultBrowserKey; + } else { + // We didn't have a saved value, so check the registry. + const kRegPath = "Software\\Mozilla\\Firefox"; + let oldDefault = WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kRegPath, + "OldDefaultBrowserCommand" + ); + if (oldDefault) { + // Remove the key: + WindowsRegistry.removeRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kRegPath, + "OldDefaultBrowserCommand" + ); + try { + let file = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsILocalFileWin + ); + file.initWithCommandLine(oldDefault); + key = + APP_DESC_TO_KEY[file.getVersionInfoField("FileDescription")] || + key; + // Save the value for future callers. + gPreviousDefaultBrowserKey = key; + } catch (ex) { + Cu.reportError( + "Could not convert old default browser value to description." + ); + } + } + } + } + return key; + }, + + // Whether or not we're in the process of startup migration + get isStartupMigration() { + return gProfileStartup != null; + }, + + /** + * In the case of startup migration, this is set to the nsIProfileStartup + * instance passed to ProfileMigrator's migrate. + * + * @see showMigrationWizard + */ + get profileStartup() { + return gProfileStartup; + }, + + /** + * Show the migration wizard. On mac, this may just focus the wizard if it's + * already running, in which case aOpener and aParams are ignored. + * + * @param {Window} [aOpener] + * optional; the window that asks to open the wizard. + * @param {Array} [aParams] + * optional arguments for the migration wizard, in the form of an array + * This is passed as-is for the params argument of + * nsIWindowWatcher.openWindow. The array elements we expect are, in + * order: + * - {Number} migration entry point constant (see below) + * - {String} source browser identifier + * - {nsIBrowserProfileMigrator} actual migrator object + * - {Boolean} whether this is a startup migration + * - {Boolean} whether to skip the 'source' page + * - {String} an identifier for the profile to use when migrating + * NB: If you add new consumers, please add a migration entry point + * constant below, and specify at least the first element of the array + * (the migration entry point for purposes of telemetry). + */ + showMigrationWizard: function MU_showMigrationWizard(aOpener, aParams) { + let features = "chrome,dialog,modal,centerscreen,titlebar,resizable=no"; + if (AppConstants.platform == "macosx" && !this.isStartupMigration) { + let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard"); + if (win) { + win.focus(); + return; + } + // On mac, the migration wiazrd should only be modal in the case of + // startup-migration. + features = "centerscreen,chrome,resizable=no"; + } + + // nsIWindowWatcher doesn't deal with raw arrays, so we convert the input + let params; + if (Array.isArray(aParams)) { + params = Cc["@mozilla.org/array;1"].createInstance(Ci.nsIMutableArray); + for (let item of aParams) { + let comtaminatedVal; + if (item && item instanceof Ci.nsISupports) { + comtaminatedVal = item; + } else { + switch (typeof item) { + case "boolean": + comtaminatedVal = Cc[ + "@mozilla.org/supports-PRBool;1" + ].createInstance(Ci.nsISupportsPRBool); + comtaminatedVal.data = item; + break; + case "number": + comtaminatedVal = Cc[ + "@mozilla.org/supports-PRUint32;1" + ].createInstance(Ci.nsISupportsPRUint32); + comtaminatedVal.data = item; + break; + case "string": + comtaminatedVal = Cc[ + "@mozilla.org/supports-cstring;1" + ].createInstance(Ci.nsISupportsCString); + comtaminatedVal.data = item; + break; + + case "undefined": + case "object": + if (!item) { + comtaminatedVal = null; + break; + } + /* intentionally falling through to error out here for + non-null/undefined things: */ + default: + throw new Error( + "Unexpected parameter type " + typeof item + ": " + item + ); + } + } + params.appendElement(comtaminatedVal); + } + } else { + params = aParams; + } + + Services.ww.openWindow( + aOpener, + "chrome://browser/content/migration/migration.xhtml", + "_blank", + features, + params + ); + }, + + /** + * Show the migration wizard for startup-migration. This should only be + * called by ProfileMigrator (see ProfileMigrator.js), which implements + * nsIProfileMigrator. This runs asynchronously if we are running an + * automigration. + * + * @param aProfileStartup + * the nsIProfileStartup instance provided to ProfileMigrator.migrate. + * @param [optional] aMigratorKey + * If set, the migration wizard will import from the corresponding + * migrator, bypassing the source-selection page. Otherwise, the + * source-selection page will be displayed, either with the default + * browser selected, if it could be detected and if there is a + * migrator for it, or with the first option selected as a fallback + * (The first option is hardcoded to be the most common browser for + * the OS we run on. See migration.xhtml). + * @param [optional] aProfileToMigrate + * If set, the migration wizard will import from the profile indicated. + * @throws if aMigratorKey is invalid or if it points to a non-existent + * source. + */ + startupMigration: function MU_startupMigrator( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) { + this.spinResolve( + this.asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) + ); + }, + + asyncStartupMigration: async function MU_asyncStartupMigrator( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) { + if (!aProfileStartup) { + throw new Error( + "an profile-startup instance is required for startup-migration" + ); + } + gProfileStartup = aProfileStartup; + + let skipSourcePage = false, + migrator = null, + migratorKey = ""; + if (aMigratorKey) { + migrator = await this.getMigrator(aMigratorKey); + if (!migrator) { + // aMigratorKey must point to a valid source, so, if it doesn't + // cleanup and throw. + this.finishMigration(); + throw new Error( + "startMigration was asked to open auto-migrate from " + + "a non-existent source: " + + aMigratorKey + ); + } + migratorKey = aMigratorKey; + skipSourcePage = true; + } else { + let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser(); + if (defaultBrowserKey) { + migrator = await this.getMigrator(defaultBrowserKey); + if (migrator) { + migratorKey = defaultBrowserKey; + } + } + } + + if (!migrator) { + let migrators = await Promise.all( + gAvailableMigratorKeys.map(key => this.getMigrator(key)) + ); + // If there's no migrator set so far, ensure that there is at least one + // migrator available before opening the wizard. + // Note that we don't need to check the default browser first, because + // if that one existed we would have used it in the block above this one. + if (!migrators.some(m => m)) { + // None of the keys produced a usable migrator, so finish up here: + this.finishMigration(); + return; + } + } + + let isRefresh = + migrator && skipSourcePage && migratorKey == AppConstants.MOZ_APP_NAME; + + let migrationEntryPoint = this.MIGRATION_ENTRYPOINT_FIRSTRUN; + if (isRefresh) { + migrationEntryPoint = this.MIGRATION_ENTRYPOINT_FXREFRESH; + } + + let params = [ + migrationEntryPoint, + migratorKey, + migrator, + aProfileStartup, + skipSourcePage, + aProfileToMigrate, + ]; + this.showMigrationWizard(null, params); + }, + + _importQuantities: { + bookmarks: 0, + logins: 0, + history: 0, + }, + + getImportedCount(type) { + if (!this._importQuantities.hasOwnProperty(type)) { + throw new Error( + `Unknown import data type "${type}" passed to getImportedCount` + ); + } + return this._importQuantities[type]; + }, + + insertBookmarkWrapper(bookmark) { + this._importQuantities.bookmarks++; + let insertionPromise = PlacesUtils.bookmarks.insert(bookmark); + if (!gKeepUndoData) { + return insertionPromise; + } + // If we keep undo data, add a promise handler that stores the undo data once + // the bookmark has been inserted in the DB, and then returns the bookmark. + let { parentGuid } = bookmark; + return insertionPromise.then(bm => { + let { guid, lastModified, type } = bm; + gUndoData.get("bookmarks").push({ + parentGuid, + guid, + lastModified, + type, + }); + return bm; + }); + }, + + insertManyBookmarksWrapper(bookmarks, parent) { + let insertionPromise = PlacesUtils.bookmarks.insertTree({ + guid: parent, + children: bookmarks, + }); + return insertionPromise.then( + insertedItems => { + this._importQuantities.bookmarks += insertedItems.length; + if (gKeepUndoData) { + let bmData = gUndoData.get("bookmarks"); + for (let bm of insertedItems) { + let { parentGuid, guid, lastModified, type } = bm; + bmData.push({ parentGuid, guid, lastModified, type }); + } + } + }, + ex => Cu.reportError(ex) + ); + }, + + insertVisitsWrapper(pageInfos) { + let now = new Date(); + // Ensure that none of the dates are in the future. If they are, rewrite + // them to be now. This means we don't loose history entries, but they will + // be valid for the history store. + for (let pageInfo of pageInfos) { + for (let visit of pageInfo.visits) { + if (visit.date && visit.date > now) { + visit.date = now; + } + } + } + this._importQuantities.history += pageInfos.length; + if (gKeepUndoData) { + this._updateHistoryUndo(pageInfos); + } + return PlacesUtils.history.insertMany(pageInfos); + }, + + async insertLoginsWrapper(logins) { + this._importQuantities.logins += logins.length; + let inserted = await LoginHelper.maybeImportLogins(logins); + // Note that this means that if we import a login that has a newer password + // than we know about, we will update the login, and an undo of the import + // will not revert this. This seems preferable over removing the login + // outright or storing the old password in the undo file. + if (gKeepUndoData) { + for (let { guid, timePasswordChanged } of inserted) { + gUndoData.get("logins").push({ guid, timePasswordChanged }); + } + } + }, + + initializeUndoData() { + gKeepUndoData = true; + gUndoData = new Map([ + ["bookmarks", []], + ["visits", []], + ["logins", []], + ]); + }, + + async _postProcessUndoData(state) { + if (!state) { + return state; + } + let bookmarkFolders = state + .get("bookmarks") + .filter(b => b.type == PlacesUtils.bookmarks.TYPE_FOLDER); + + let bookmarkFolderData = []; + let bmPromises = bookmarkFolders.map(({ guid }) => { + // Ignore bookmarks where the promise doesn't resolve (ie that are missing) + // Also check that the bookmark fetch returns isn't null before adding it. + return PlacesUtils.bookmarks.fetch(guid).then( + bm => bm && bookmarkFolderData.push(bm), + () => {} + ); + }); + + await Promise.all(bmPromises); + let folderLMMap = new Map( + bookmarkFolderData.map(b => [b.guid, b.lastModified]) + ); + for (let bookmark of bookmarkFolders) { + let lastModified = folderLMMap.get(bookmark.guid); + // If the bookmark was deleted, the map will be returning null, so check: + if (lastModified) { + bookmark.lastModified = lastModified; + } + } + return state; + }, + + stopAndRetrieveUndoData() { + let undoData = gUndoData; + gUndoData = null; + gKeepUndoData = false; + return this._postProcessUndoData(undoData); + }, + + _updateHistoryUndo(pageInfos) { + let visits = gUndoData.get("visits"); + let visitMap = new Map(visits.map(v => [v.url, v])); + for (let pageInfo of pageInfos) { + let visitCount = pageInfo.visits.length; + let first, last; + if (visitCount > 1) { + let dates = pageInfo.visits.map(v => v.date); + first = Math.min.apply(Math, dates); + last = Math.max.apply(Math, dates); + } else { + first = last = pageInfo.visits[0].date; + } + let url = pageInfo.url; + if (url instanceof Ci.nsIURI) { + url = pageInfo.url.spec; + } else if (typeof url != "string") { + pageInfo.url.href; + } + + try { + new URL(url); + } catch (ex) { + // This won't save and we won't need to 'undo' it, so ignore this URL. + continue; + } + if (!visitMap.has(url)) { + visitMap.set(url, { url, visitCount, first, last }); + } else { + let currentData = visitMap.get(url); + currentData.visitCount += visitCount; + currentData.first = Math.min(currentData.first, first); + currentData.last = Math.max(currentData.last, last); + } + } + gUndoData.set("visits", Array.from(visitMap.values())); + }, + + /** + * Cleans up references to migrators and nsIProfileInstance instances. + */ + finishMigration: function MU_finishMigration() { + gMigrators = null; + gProfileStartup = null; + gL10n = null; + }, + + gAvailableMigratorKeys, + + MIGRATION_ENTRYPOINT_UNKNOWN: 0, + MIGRATION_ENTRYPOINT_FIRSTRUN: 1, + MIGRATION_ENTRYPOINT_FXREFRESH: 2, + MIGRATION_ENTRYPOINT_PLACES: 3, + MIGRATION_ENTRYPOINT_PASSWORDS: 4, + MIGRATION_ENTRYPOINT_NEWTAB: 5, + MIGRATION_ENTRYPOINT_FILE_MENU: 6, + MIGRATION_ENTRYPOINT_HELP_MENU: 7, + MIGRATION_ENTRYPOINT_BOOKMARKS_TOOLBAR: 8, + + _sourceNameToIdMapping: { + nothing: 1, + firefox: 2, + edge: 3, + ie: 4, + chrome: 5, + "chrome-beta": 5, + "chrome-dev": 5, + chromium: 6, + canary: 7, + safari: 8, + "360se": 9, + "chromium-edge": 10, + "chromium-edge-beta": 10, + }, + getSourceIdForTelemetry(sourceName) { + return this._sourceNameToIdMapping[sourceName] || 0; + }, + + /* Enum of locations where bookmarks were found in the + source browser that we import from */ + SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR: 1, + SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU: 2, + SOURCE_BOOKMARK_ROOTS_READING_LIST: 4, + SOURCE_BOOKMARK_ROOTS_UNFILED: 8, +}); diff --git a/browser/components/migration/ProfileMigrator.jsm b/browser/components/migration/ProfileMigrator.jsm new file mode 100644 index 0000000000..ab35a8f021 --- /dev/null +++ b/browser/components/migration/ProfileMigrator.jsm @@ -0,0 +1,21 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { MigrationUtils } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +function ProfileMigrator() {} + +ProfileMigrator.prototype = { + migrate: MigrationUtils.startupMigration.bind(MigrationUtils), + QueryInterface: ChromeUtils.generateQI(["nsIProfileMigrator"]), + classDescription: "Profile Migrator", + contractID: "@mozilla.org/toolkit/profile-migrator;1", + classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE"), +}; + +var EXPORTED_SYMBOLS = ["ProfileMigrator"]; diff --git a/browser/components/migration/SafariProfileMigrator.jsm b/browser/components/migration/SafariProfileMigrator.jsm new file mode 100644 index 0000000000..184713ad82 --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.jsm @@ -0,0 +1,535 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "PropertyListUtils", + "resource://gre/modules/PropertyListUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PlacesUIUtils", + "resource:///modules/PlacesUIUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "FormHistory", + "resource://gre/modules/FormHistory.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; + this._histogramBookmarkRoots = 0; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return (async () => { + let dict = await new Promise(resolve => + PropertyListUtils.read(this._file, resolve) + ); + if (!dict) { + throw new Error("Could not read Bookmarks.plist"); + } + let children = dict.get("Children"); + if (!children) { + throw new Error("Invalid Bookmarks.plist format"); + } + + let collection = + dict.get("Title") == "com.apple.ReadingList" + ? this.READING_LIST_COLLECTION + : this.ROOT_COLLECTION; + await this._migrateCollection(children, collection); + if ( + this._histogramBookmarkRoots & + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR + ) { + PlacesUIUtils.maybeToggleBookmarkToolbarVisibilityAfterMigration(); + } + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_BOOKMARKS_ROOTS") + .add("safari", this._histogramBookmarkRoots); + })().then( + () => aCallback(true), + e => { + Cu.reportError(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param aEntries + * the collection's children + * @param aCollection + * one of the values above. + */ + async _migrateCollection(aEntries, aCollection) { + // A collection of bookmarks in Safari resembles places roots. In the + // property list files (Bookmarks.plist, ReadingList.plist) they are + // stored as regular bookmarks folders, and thus can only be distinguished + // from by their names and places in the hierarchy. + + let entriesFiltered = []; + if (aCollection == this.ROOT_COLLECTION) { + for (let entry of aEntries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let children = entry.get("Children"); + if (title == "BookmarksBar") { + await this._migrateCollection(children, this.TOOLBAR_COLLECTION); + } else if (title == "BookmarksMenu") { + await this._migrateCollection(children, this.MENU_COLLECTION); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION + ); + } else if (entry.get("ShouldOmitFromUI") !== true) { + entriesFiltered.push(entry); + } + } else if (type == "WebBookmarkTypeLeaf") { + entriesFiltered.push(entry); + } + } + } else { + entriesFiltered = aEntries; + } + + if (!entriesFiltered.length) { + return; + } + + let folderGuid = -1; + switch (aCollection) { + case this.ROOT_COLLECTION: { + // In Safari, it is possible (though quite cumbersome) to move + // bookmarks to the bookmarks root, which is the parent folder of + // all bookmarks "collections". That is somewhat in parallel with + // both the places root and the unfiled-bookmarks root. + // Because the former is only an implementation detail in our UI, + // the unfiled root seems to be the best choice. + folderGuid = PlacesUtils.bookmarks.unfiledGuid; + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_UNFILED; + break; + } + case this.MENU_COLLECTION: { + folderGuid = PlacesUtils.bookmarks.menuGuid; + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(folderGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + folderGuid = await MigrationUtils.createImportedBookmarksFolder( + "Safari", + folderGuid + ); + } + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU; + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = PlacesUtils.bookmarks.toolbarGuid; + if ( + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020") && + !MigrationUtils.isStartupMigration && + PlacesUtils.getChildCountForFolder(folderGuid) > + PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + ) { + folderGuid = await MigrationUtils.createImportedBookmarksFolder( + "Safari", + folderGuid + ); + } + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + break; + } + case this.READING_LIST_COLLECTION: { + // Reading list items are imported as regular bookmarks. + // They are imported under their own folder, created either under the + // bookmarks menu (in the case of startup migration). + let readingListTitle = await MigrationUtils.getLocalizedString( + "imported-safari-reading-list" + ); + folderGuid = ( + await MigrationUtils.insertBookmarkWrapper({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: readingListTitle, + }) + ).guid; + this._histogramBookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_READING_LIST; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) { + throw new Error("Invalid folder GUID"); + } + + await this._migrateEntries(entriesFiltered, folderGuid); + }, + + // migrate the given array of safari bookmarks to the given places + // folder. + _migrateEntries(entries, parentGuid) { + let convertedEntries = this._convertEntries(entries); + return MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + }, + + _convertEntries(entries) { + return entries + .map(function(entry) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + return { + title: entry.get("Title"), + type: PlacesUtils.bookmarks.TYPE_FOLDER, + children: this._convertEntries(entry.get("Children")), + }; + } + if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + // Check we understand this URL before adding it: + let url = entry.get("URLString"); + try { + new URL(url); + } catch (ex) { + Cu.reportError( + `Ignoring ${url} when importing from Safari because of exception: ${ex}` + ); + return null; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + return { url, title }; + } + return null; + }, this) + .filter(e => !!e); + }, +}; + +function History(aHistoryFile) { + this._file = aHistoryFile; +} +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + // Helper method for converting the visit date property to a PRTime value. + // The visit date is stored as a string, so it's not read as a Date + // object by PropertyListUtils. + _parseCocoaDate: function H___parseCocoaDate(aCocoaDateStr) { + let asDouble = parseFloat(aCocoaDateStr); + if (!isNaN(asDouble)) { + // reference date of NSDate. + let date = new Date("1 January 2001, GMT"); + date.setMilliseconds(asDouble * 1000); + return date; + } + return new Date(); + }, + + migrate: function H_migrate(aCallback) { + PropertyListUtils.read(this._file, aDict => { + try { + if (!aDict) { + throw new Error("Could not read history property list"); + } + if (!aDict.has("WebHistoryDates")) { + throw new Error("Unexpected history-property list format"); + } + + let pageInfos = []; + let entries = aDict.get("WebHistoryDates"); + let failedOnce = false; + for (let entry of entries) { + if (entry.has("lastVisitedDate")) { + let date = this._parseCocoaDate(entry.get("lastVisitedDate")); + try { + pageInfos.push({ + url: new URL(entry.get("")), + title: entry.get("title"), + visits: [ + { + // Safari's History file contains only top-level urls. It does not + // distinguish between typed urls and linked urls. + transition: PlacesUtils.history.TRANSITIONS.LINK, + date, + }, + ], + }); + } catch (ex) { + // Safari's History file may contain malformed URIs which + // will be ignored. + Cu.reportError(ex); + failedOnce = true; + } + } + } + if (!pageInfos.length) { + // If we failed at least once, then we didn't succeed in importing, + // otherwise we didn't actually have anything to import, so we'll + // report it as a success. + aCallback(!failedOnce); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + } catch (ex) { + Cu.reportError(ex); + aCallback(false); + } + }); + }, +}; + +/** + * Safari's preferences property list is independently used for three purposes: + * (a) importation of preferences + * (b) importation of search strings + * (c) retrieving the home page. + * + * So, rather than reading it three times, it's cached and managed here. + */ +function MainPreferencesPropertyList(aPreferencesFile) { + this._file = aPreferencesFile; + this._callbacks = []; +} +MainPreferencesPropertyList.prototype = { + /** + * @see PropertyListUtils.read + */ + read: function MPPL_read(aCallback) { + if ("_dict" in this) { + aCallback(this._dict); + return; + } + + let alreadyReading = !!this._callbacks.length; + this._callbacks.push(aCallback); + if (!alreadyReading) { + PropertyListUtils.read(this._file, aDict => { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } catch (ex) { + Cu.reportError(ex); + } + } + this._callbacks.splice(0); + }); + } + }, +}; + +function SearchStrings(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +SearchStrings.prototype = { + type: MigrationUtils.resourceTypes.OTHERDATA, + + migrate: function SS_migrate(aCallback) { + this._mainPreferencesPropertyList.read( + MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { + if (!aDict) { + throw new Error("Could not get preferences dictionary"); + } + + if (aDict.has("RecentSearchStrings")) { + let recentSearchStrings = aDict.get("RecentSearchStrings"); + if (recentSearchStrings && recentSearchStrings.length) { + let changes = recentSearchStrings.map(searchString => ({ + op: "add", + fieldname: "searchbar-history", + value: searchString, + })); + FormHistory.update(changes); + } + } + }, aCallback) + ); + }, +}; + +function SafariProfileMigrator() {} + +SafariProfileMigrator.prototype = Object.create(MigratorPrototype); + +SafariProfileMigrator.prototype.getResources = function SM_getResources() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + if (!profileDir.exists()) { + return null; + } + + let resources = []; + let pushProfileFileResource = function(aFileName, aConstructor) { + let file = profileDir.clone(); + file.append(aFileName); + if (file.exists()) { + resources.push(new aConstructor(file)); + } + }; + + pushProfileFileResource("History.plist", History); + pushProfileFileResource("Bookmarks.plist", Bookmarks); + + // The Reading List feature was introduced at the same time in Windows and + // Mac versions of Safari. Not surprisingly, they are stored in the same + // format in both versions. Surpsingly, only on Windows there is a + // separate property list for it. This code is used on mac too, because + // Apple may fix this at some point. + pushProfileFileResource("ReadingList.plist", Bookmarks); + + let prefs = this.mainPreferencesPropertyList; + if (prefs) { + resources.push(new SearchStrings(prefs)); + } + + return resources; +}; + +SafariProfileMigrator.prototype.getLastUsedDate = function SM_getLastUsedDate() { + let profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + let datePromises = ["Bookmarks.plist", "History.plist"].map(file => { + let path = OS.Path.join(profileDir.path, file); + return OS.File.stat(path) + .catch(() => null) + .then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); +}; + +SafariProfileMigrator.prototype.hasPermissions = async function SM_hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access: + let target = FileUtils.getDir( + "ULibDir", + ["Safari", "Bookmarks.plist"], + false + ); + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(target.path, { maxBytes: 1 }); + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } +}; + +SafariProfileMigrator.prototype.getPermissions = async function SM_getPermissions( + win +) { + // Keep prompting the user until they pick a file that grants us access, + // or they cancel out of the file open panel. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + // The title (second arg) is not displayed on macOS, so leave it blank. + fp.init(win, "", Ci.nsIFilePicker.modeOpen); + // This is a little weird. You'd expect that it matters which file + // the user picks, but it doesn't really, as long as it's in this + // directory. Anyway, let's not confuse the user: the sensible idea + // here is to ask for permissions for Bookmarks.plist, and we'll + // silently accept whatever input as long as we can then read the plist. + fp.appendFilter("plist", "*.plist"); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", ["Safari"], false); + // Now wait for the filepicker to open and close. If the user picks + // any file in this directory, macOS will grant us read access, so + // we don't need to check or do anything else with the file returned + // by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + } +}; + +Object.defineProperty( + SafariProfileMigrator.prototype, + "mainPreferencesPropertyList", + { + get: function get_mainPreferencesPropertyList() { + if (this._mainPreferencesPropertyList === undefined) { + let file = FileUtils.getDir("UsrPrfs", [], false); + if (file.exists()) { + file.append("com.apple.Safari.plist"); + if (file.exists()) { + this._mainPreferencesPropertyList = new MainPreferencesPropertyList( + file + ); + return this._mainPreferencesPropertyList; + } + } + this._mainPreferencesPropertyList = null; + return this._mainPreferencesPropertyList; + } + return this._mainPreferencesPropertyList; + }, + } +); + +SafariProfileMigrator.prototype.classDescription = "Safari Profile Migrator"; +SafariProfileMigrator.prototype.contractID = + "@mozilla.org/profile/migrator;1?app=browser&type=safari"; +SafariProfileMigrator.prototype.classID = Components.ID( + "{4b609ecf-60b2-4655-9df4-dc149e474da1}" +); + +var EXPORTED_SYMBOLS = ["SafariProfileMigrator"]; diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf new file mode 100644 index 0000000000..aef59b486c --- /dev/null +++ b/browser/components/migration/components.conf @@ -0,0 +1,114 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XP_WIN = buildconfig.substs['OS_ARCH'] == 'WINNT' +XP_MACOSX = buildconfig.substs['MOZ_WIDGET_TOOLKIT'] == 'cocoa' + +Classes = [ + { + 'cid': '{6F8BB968-C14F-4D6F-9733-6C6737B35DCE}', + 'contract_ids': ['@mozilla.org/toolkit/profile-migrator;1'], + 'jsm': 'resource:///modules/ProfileMigrator.jsm', + 'constructor': 'ProfileMigrator', + }, + { + 'cid': '{4cec1de4-1671-4fc3-a53e-6c539dc77a26}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromeProfileMigrator', + }, + { + 'cid': '{8cece922-9720-42de-b7db-7cef88cb07ca}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromiumProfileMigrator', + }, + { + 'cid': '{91185366-ba97-4438-acba-48deaca63386}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=firefox'], + 'jsm': 'resource:///modules/FirefoxProfileMigrator.jsm', + 'constructor': 'FirefoxProfileMigrator', + }, +] + +if not XP_MACOSX: + Classes += [ + { + 'cid': '{47f75963-840b-4950-a1f0-d9c1864f8b8e}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome-beta'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromeBetaMigrator', + }, + ] + +if XP_WIN or XP_MACOSX: + Classes += [ + { + 'cid': '{4bf85aa5-4e21-46ca-825f-f9c51a5e8c76}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=canary'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'CanaryProfileMigrator', + }, + { + 'cid': '{3c7f6b7c-baa9-4338-acfa-04bf79f1dcf1}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromiumEdgeMigrator', + }, + { + 'cid': '{0fc3d48a-c1c3-4871-b58f-a8b47d1555fb}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chromium-edge-beta'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromiumEdgeBetaMigrator', + }, + ] +else: + Classes += [ + { + 'cid': '{7370a02a-4886-42c3-a4ec-d48c726ec30a}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=chrome-dev'], + 'jsm': 'resource:///modules/ChromeProfileMigrator.jsm', + 'constructor': 'ChromeDevMigrator', + }, + ] + +if XP_WIN: + Classes += [ + { + 'cid': '{3d2532e3-4932-4774-b7ba-968f5899d3a4}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=ie'], + 'jsm': 'resource:///modules/IEProfileMigrator.jsm', + 'constructor': 'IEProfileMigrator', + }, + { + 'cid': '{62e8834b-2d17-49f5-96ff-56344903a2ae}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=edge'], + 'jsm': 'resource:///modules/EdgeProfileMigrator.jsm', + 'constructor': 'EdgeProfileMigrator', + }, + { + 'cid': '{d0037b95-296a-4a4e-94b2-c3d075d20ab1}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=360se'], + 'jsm': 'resource:///modules/360seProfileMigrator.jsm', + 'constructor': 'Qihoo360seProfileMigrator', + }, + ] + +if XP_MACOSX: + Classes += [ + { + 'cid': '{4b609ecf-60b2-4655-9df4-dc149e474da1}', + 'contract_ids': ['@mozilla.org/profile/migrator;1?app=browser&type=safari'], + 'jsm': 'resource:///modules/SafariProfileMigrator.jsm', + 'constructor': 'SafariProfileMigrator', + }, + { + 'cid': '{647bf80c-cd35-4ce6-b904-fd586b97ae48}', + 'contract_ids': ['@mozilla.org/profile/migrator/keychainmigrationutils;1'], + 'type': 'nsKeychainMigrationUtils', + 'headers': ['nsKeychainMigrationUtils.h'], + }, + ] diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 0000000000..e4c02d23d3 --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +--> +<!DOCTYPE html [ + <!ENTITY % htmlDTD PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "DTD/xhtml1-strict.dtd"> + %htmlDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <meta http-equiv="Content-Security-Policy" content="default-src chrome: resource:; img-src chrome: resource: data:; object-src 'none'" /> + <title data-l10n-id="welcome-back-tab-title"></title> + <link rel="stylesheet" href="chrome://global/skin/in-content/info-pages.css" type="text/css" media="all"/> + <link rel="stylesheet" href="chrome://browser/skin/aboutWelcomeBack.css" type="text/css" media="all"/> + <link rel="icon" type="image/png" href="chrome://browser/skin/info.svg"/> + <link rel="localization" href="browser/aboutSessionRestore.ftl"/> + <link rel="localization" href="branding/brand.ftl"/> + <script src="chrome://browser/content/aboutSessionRestore.js"/> + </head> + + <body> + + <div class="container"> + + <div class="title"> + <h1 class="title-text" data-l10n-id="welcome-back-page-title"></h1> + </div> + + <div class="description"> + + <p data-l10n-id="welcome-back-page-info"></p> + <!-- Note a href in the anchor below is added by JS --> + <p data-l10n-id="welcome-back-page-info-link"><a id="linkMoreTroubleshooting" target="_blank" data-l10n-name="link-more"></a></p> + + <div> + <div class="radioRestoreContainer"> + <input class="radioRestoreButton" id="radioRestoreAll" type="radio" + name="restore" checked="checked"/> + <label class="radioRestoreLabel" for="radioRestoreAll" data-l10n-id="welcome-back-restore-all-label"></label> + </div> + + <div class="radioRestoreContainer"> + <input class="radioRestoreButton" id="radioRestoreChoose" type="radio" + name="restore"/> + <label class="radioRestoreLabel" for="radioRestoreChoose" data-l10n-id="welcome-back-restore-some-label"></label> + </div> + </div> + </div> + + <div class="tree-container"> + <xul:tree id="tabList" flex="1" seltype="single" hidecolumnpicker="true"> + <xul:treecols> + <xul:treecol cycler="true" id="restore" type="checkbox" data-l10n-id="restore-page-restore-header"/> + <xul:splitter class="tree-splitter"/> + <xul:treecol primary="true" id="title" data-l10n-id="restore-page-list-header" flex="1"/> + </xul:treecols> + <xul:treechildren flex="1"/> + </xul:tree> + </div> + + <div class="button-container"> + <xul:button class="primary" + id="errorTryAgain" + data-l10n-id="welcome-back-restore-button"/> + </div> + + <input type="text" id="sessionData" hidden="true"/> + + </div> + </body> +</html> diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js new file mode 100644 index 0000000000..e9cc1df645 --- /dev/null +++ b/browser/components/migration/content/migration.js @@ -0,0 +1,658 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const kIMig = Ci.nsIBrowserProfileMigrator; +const kIPStartup = Ci.nsIProfileStartup; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { MigrationUtils } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); + +/** + * Map from data types that match Ci.nsIBrowserProfileMigrator's types to + * prefixes for strings used to label these data types in the migration + * dialog. We use these strings with -checkbox and -label suffixes for the + * checkboxes on the "importItems" page, and for the labels on the "migrating" + * and "done" pages, respectively. + */ +const kDataToStringMap = new Map([ + ["cookies", "browser-data-cookies"], + ["history", "browser-data-history"], + ["formdata", "browser-data-formdata"], + ["passwords", "browser-data-passwords"], + ["bookmarks", "browser-data-bookmarks"], + ["otherdata", "browser-data-otherdata"], + ["session", "browser-data-session"], +]); + +var MigrationWizard = { + /* exported MigrationWizard */ + _source: "", // Source Profile Migrator ContractID suffix + _itemsFlags: kIMig.ALL, // Selected Import Data Sources (16-bit bitfield) + _selectedProfile: null, // Selected Profile name to import from + _wiz: null, + _migrator: null, + _autoMigrate: null, + _receivedPermissions: new Set(), + + init() { + let os = Services.obs; + os.addObserver(this, "Migration:Started"); + os.addObserver(this, "Migration:ItemBeforeMigrate"); + os.addObserver(this, "Migration:ItemAfterMigrate"); + os.addObserver(this, "Migration:ItemError"); + os.addObserver(this, "Migration:Ended"); + + this._wiz = document.querySelector("wizard"); + + let args = window.arguments; + let entryPointId = args[0] || MigrationUtils.MIGRATION_ENTRYPOINT_UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT") + .add(entryPointId); + this.isInitialMigration = + entryPointId == MigrationUtils.MIGRATION_ENTRYPOINT_FIRSTRUN; + + { + // Record that the uninstaller requested a profile refresh + let env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + if (env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) { + env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", ""); + Services.telemetry.scalarSet( + "migration.uninstaller_profile_refresh", + true + ); + } + } + + if (args.length == 2) { + this._source = args[1]; + } else if (args.length > 2) { + this._source = args[1]; + this._migrator = args[2] instanceof kIMig ? args[2] : null; + this._autoMigrate = args[3].QueryInterface(kIPStartup); + this._skipImportSourcePage = args[4]; + if (this._migrator && args[5]) { + let sourceProfiles = this.spinResolve( + this._migrator.getSourceProfiles() + ); + this._selectedProfile = sourceProfiles.find( + profile => profile.id == args[5] + ); + } + + if (this._autoMigrate) { + // Show the "nothing" option in the automigrate case to provide an + // easily identifiable way to avoid migration and create a new profile. + document.getElementById("nothing").hidden = false; + } + } + this._setSourceForDataLocalization(); + + document.addEventListener("wizardcancel", function() { + MigrationWizard.onWizardCancel(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageshow", function() { + MigrationWizard.onSelectProfilePageShow(); + }); + document + .getElementById("importItems") + .addEventListener("pageshow", function() { + MigrationWizard.onImportItemsPageShow(); + }); + document + .getElementById("migrating") + .addEventListener("pageshow", function() { + MigrationWizard.onMigratingPageShow(); + }); + document.getElementById("done").addEventListener("pageshow", function() { + MigrationWizard.onDonePageShow(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pagerewound", function() { + MigrationWizard.onSelectProfilePageRewound(); + }); + document + .getElementById("importItems") + .addEventListener("pagerewound", function() { + MigrationWizard.onImportItemsPageRewound(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageadvanced", function() { + MigrationWizard.onSelectProfilePageAdvanced(); + }); + document + .getElementById("importItems") + .addEventListener("pageadvanced", function() { + MigrationWizard.onImportItemsPageAdvanced(); + }); + document + .getElementById("importPermissions") + .addEventListener("pageadvanced", function(e) { + MigrationWizard.onImportPermissionsPageAdvanced(e); + }); + document + .getElementById("importSource") + .addEventListener("pageadvanced", function(e) { + MigrationWizard.onImportSourcePageAdvanced(e); + }); + + this.onImportSourcePageShow(); + }, + + uninit() { + var os = Services.obs; + os.removeObserver(this, "Migration:Started"); + os.removeObserver(this, "Migration:ItemBeforeMigrate"); + os.removeObserver(this, "Migration:ItemAfterMigrate"); + os.removeObserver(this, "Migration:ItemError"); + os.removeObserver(this, "Migration:Ended"); + MigrationUtils.finishMigration(); + }, + + spinResolve(promise) { + let canAdvance = this._wiz.canAdvance; + let canRewind = this._wiz.canRewind; + this._wiz.canAdvance = false; + this._wiz.canRewind = false; + let result = MigrationUtils.spinResolve(promise); + this._wiz.canAdvance = canAdvance; + this._wiz.canRewind = canRewind; + return result; + }, + + _setSourceForDataLocalization() { + this._sourceForDataLocalization = this._source; + // Ensure consistency for various channels, brandings and versions of + // Chromium and MS Edge. + if (this._sourceForDataLocalization) { + this._sourceForDataLocalization = this._sourceForDataLocalization + .replace(/^(chromium-edge-beta|chromium-edge)$/, "edge") + .replace(/^(canary|chromium|chrome-beta|chrome-dev)$/, "chrome"); + } + }, + + onWizardCancel() { + MigrationUtils.forceExitSpinResolve(); + return true; + }, + + // 1 - Import Source + onImportSourcePageShow() { + // Show warning message to close the selected browser when needed + let toggleCloseBrowserWarning = () => { + let visibility = "hidden"; + if (group.selectedItem.id != "nothing") { + let migrator = this.spinResolve( + MigrationUtils.getMigrator(group.selectedItem.id) + ); + visibility = migrator.sourceLocked ? "visible" : "hidden"; + } + document.getElementById( + "closeSourceBrowser" + ).style.visibility = visibility; + }; + this._wiz.canRewind = false; + + var selectedMigrator = null; + this._availableMigrators = []; + + // Figure out what source apps are are available to import from: + var group = document.getElementById("importSourceGroup"); + for (var i = 0; i < group.childNodes.length; ++i) { + var migratorKey = group.childNodes[i].id; + if (migratorKey != "nothing") { + var migrator = this.spinResolve( + MigrationUtils.getMigrator(migratorKey) + ); + if (migrator) { + // Save this as the first selectable item, if we don't already have + // one, or if it is the migrator that was passed to us. + if (!selectedMigrator || this._source == migratorKey) { + selectedMigrator = group.childNodes[i]; + } + this._availableMigrators.push([migratorKey, migrator]); + } else { + // Hide this option + group.childNodes[i].hidden = true; + } + } + } + if (this.isInitialMigration) { + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_BROWSER_COUNT") + .add(this._availableMigrators.length); + let defaultBrowser = MigrationUtils.getMigratorKeyForDefaultBrowser(); + // This will record 0 for unknown default browser IDs. + defaultBrowser = MigrationUtils.getSourceIdForTelemetry(defaultBrowser); + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER") + .add(defaultBrowser); + } + + group.addEventListener("command", toggleCloseBrowserWarning); + + if (selectedMigrator) { + group.selectedItem = selectedMigrator; + toggleCloseBrowserWarning(); + } else { + // We didn't find a migrator, notify the user + document.getElementById("noSources").hidden = false; + + this._wiz.canAdvance = false; + + document.getElementById("importAll").hidden = true; + } + + // Advance to the next page if the caller told us to. + if (this._migrator && this._skipImportSourcePage) { + this._wiz.advance(); + this._wiz.canRewind = false; + } + }, + + onImportSourcePageAdvanced(event) { + var newSource = document.getElementById("importSourceGroup").selectedItem + .id; + + if (newSource == "nothing") { + // Need to do telemetry here because we're closing the dialog before we get to + // do actual migration. For actual migration, this doesn't happen until after + // migration takes place. + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry("nothing")); + this._wiz.cancel(); + event.preventDefault(); + } + + if (!this._migrator || newSource != this._source) { + // Create the migrator for the selected source. + this._migrator = this.spinResolve(MigrationUtils.getMigrator(newSource)); + + this._itemsFlags = kIMig.ALL; + this._selectedProfile = null; + } + this._source = newSource; + this._setSourceForDataLocalization(); + + // check for more than one source profile + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + if (this._skipImportSourcePage) { + this._updateNextPageForPermissions(); + } else if (sourceProfiles && sourceProfiles.length > 1) { + this._wiz.currentPage.next = "selectProfile"; + } else { + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } else { + this._wiz.currentPage.next = "importItems"; + } + + if (sourceProfiles && sourceProfiles.length == 1) { + this._selectedProfile = sourceProfiles[0]; + } else { + this._selectedProfile = null; + } + } + }, + + // 2 - [Profile Selection] + onSelectProfilePageShow() { + // Disabling this for now, since we ask about import sources in automigration + // too and don't want to disable the back button + // if (this._autoMigrate) + // document.documentElement.getButton("back").disabled = true; + + var profiles = document.getElementById("profiles"); + while (profiles.hasChildNodes()) { + profiles.firstChild.remove(); + } + + // Note that this block is still reached even if the user chose 'From File' + // and we canceled the dialog. When that happens, _migrator will be null. + if (this._migrator) { + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + + for (let profile of sourceProfiles) { + var item = document.createXULElement("radio"); + item.id = profile.id; + item.setAttribute("label", profile.name); + profiles.appendChild(item); + } + } + + profiles.selectedItem = this._selectedProfile + ? document.getElementById(this._selectedProfile.id) + : profiles.firstChild; + }, + + onSelectProfilePageRewound() { + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + }, + + onSelectProfilePageAdvanced() { + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + + // If we're automigrating or just doing bookmarks don't show the item selection page + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } + }, + + // 3 - ImportItems + onImportItemsPageShow() { + var dataSources = document.getElementById("dataSources"); + while (dataSources.hasChildNodes()) { + dataSources.firstChild.remove(); + } + + var items = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = Ci.nsIBrowserProfileMigrator[itemType.toUpperCase()]; + if (items & itemValue) { + let checkbox = document.createXULElement("checkbox"); + checkbox.id = itemValue; + document.l10n.setAttributes( + checkbox, + kDataToStringMap.get(itemType) + "-checkbox", + { browser: this._sourceForDataLocalization } + ); + dataSources.appendChild(checkbox); + if (!this._itemsFlags || this._itemsFlags & itemValue) { + checkbox.checked = true; + } + } + } + }, + + onImportItemsPageRewound() { + this._wiz.canAdvance = true; + this.onImportItemsPageAdvanced(); + }, + + onImportItemsPageAdvanced() { + var dataSources = document.getElementById("dataSources"); + this._itemsFlags = 0; + for (var i = 0; i < dataSources.childNodes.length; ++i) { + var checkbox = dataSources.childNodes[i]; + if (checkbox.localName == "checkbox" && checkbox.checked) { + this._itemsFlags |= parseInt(checkbox.id); + } + } + + this._updateNextPageForPermissions(); + }, + + onImportItemCommand() { + var items = document.getElementById("dataSources"); + var checkboxes = items.getElementsByTagName("checkbox"); + + var oneChecked = false; + for (var i = 0; i < checkboxes.length; ++i) { + if (checkboxes[i].checked) { + oneChecked = true; + break; + } + } + + this._wiz.canAdvance = oneChecked; + + this._updateNextPageForPermissions(); + }, + + _updateNextPageForPermissions() { + // We would like to just go straight to work: + this._wiz.currentPage.next = "migrating"; + // If we already have permissions, this is easy: + if (this._receivedPermissions.has(this._source)) { + return; + } + + // Otherwise, if we're on mojave or later and importing from + // Safari, prompt for the bookmarks file. + // We may add other browser/OS combos here in future. + if ( + this._source == "safari" && + AppConstants.isPlatformAndVersionAtLeast("macosx", "18") && + this._itemsFlags & MigrationUtils.resourceTypes.BOOKMARKS + ) { + let migrator = this._migrator.wrappedJSObject; + let havePermissions = this.spinResolve(migrator.hasPermissions()); + + if (!havePermissions) { + this._wiz.currentPage.next = "importPermissions"; + } + } + }, + + // 3b: permissions. This gets invoked when the user clicks "Next" + async onImportPermissionsPageAdvanced(event) { + // We're done if we have permission: + if (this._receivedPermissions.has(this._source)) { + return; + } + // The wizard helper is sync, and we need to check some stuff, so just stop + // advancing for now and prompt the user, then advance the wizard if everything + // worked. + event.preventDefault(); + + let migrator = this._migrator.wrappedJSObject; + await migrator.getPermissions(window); + if (await migrator.hasPermissions()) { + this._receivedPermissions.add(this._source); + // Re-enter (we'll then allow the advancement through the early return above) + this._wiz.advance(); + } + // if we didn't have permissions after the `getPermissions` call, the user + // cancelled the dialog. Just no-op out now; the user can re-try by clicking + // the 'Continue' button again, or go back and pick a different browser. + }, + + // 4 - Migrating + onMigratingPageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._wiz.canAdvance = false; + + // When automigrating, show all of the data that can be received from this source. + if (this._autoMigrate) { + this._itemsFlags = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + } + + this._listItems("migratingItems"); + setTimeout(() => this.onMigratingMigrate(), 0); + }, + + async onMigratingMigrate() { + await this._migrator.migrate( + this._itemsFlags, + this._autoMigrate, + this._selectedProfile + ); + + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry(this._source)); + if (!this._autoMigrate) { + let hist = Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE"); + let exp = 0; + let items = this._itemsFlags; + while (items) { + if (items & 1) { + hist.add(this._source, exp); + } + items = items >> 1; + exp++; + } + } + }, + + _listItems(aID) { + var items = document.getElementById(aID); + while (items.hasChildNodes()) { + items.firstChild.remove(); + } + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = Ci.nsIBrowserProfileMigrator[itemType.toUpperCase()]; + if (this._itemsFlags & itemValue) { + var label = document.createXULElement("label"); + label.id = itemValue + "_migrated"; + try { + document.l10n.setAttributes( + label, + kDataToStringMap.get(itemType) + "-label", + { browser: this._sourceForDataLocalization } + ); + items.appendChild(label); + } catch (e) { + // if the block above throws, we've enumerated all the import data types we + // currently support and are now just wasting time, break. + break; + } + } + } + }, + + observe(aSubject, aTopic, aData) { + var label; + switch (aTopic) { + case "Migration:Started": + break; + case "Migration:ItemBeforeMigrate": + label = document.getElementById(aData + "_migrated"); + if (label) { + label.setAttribute("style", "font-weight: bold"); + } + break; + case "Migration:ItemAfterMigrate": + label = document.getElementById(aData + "_migrated"); + if (label) { + label.removeAttribute("style"); + } + break; + case "Migration:Ended": + if (this.isInitialMigration) { + // Ensure errors in reporting data recency do not affect the rest of the migration. + try { + this.reportDataRecencyTelemetry(); + } catch (ex) { + Cu.reportError(ex); + } + } + if (this._autoMigrate) { + // We're done now. + this._wiz.canAdvance = true; + this._wiz.advance(); + + setTimeout(close, 5000); + } else { + this._wiz.canAdvance = true; + var nextButton = this._wiz.getButton("next"); + nextButton.click(); + } + break; + case "Migration:ItemError": + let type = "undefined"; + let numericType = parseInt(aData); + switch (numericType) { + case Ci.nsIBrowserProfileMigrator.COOKIES: + type = "cookies"; + break; + case Ci.nsIBrowserProfileMigrator.HISTORY: + type = "history"; + break; + case Ci.nsIBrowserProfileMigrator.FORMDATA: + type = "form data"; + break; + case Ci.nsIBrowserProfileMigrator.PASSWORDS: + type = "passwords"; + break; + case Ci.nsIBrowserProfileMigrator.BOOKMARKS: + type = "bookmarks"; + break; + case Ci.nsIBrowserProfileMigrator.OTHERDATA: + type = "misc. data"; + break; + } + Services.console.logStringMessage( + "some " + type + " did not successfully migrate." + ); + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(this._source, Math.log2(numericType)); + break; + } + }, + + onDonePageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._listItems("doneItems"); + }, + + reportDataRecencyTelemetry() { + let histogram = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_DATA_RECENCY" + ); + let lastUsedPromises = []; + for (let [key, migrator] of this._availableMigrators) { + // No block-scoped let in for...of loop conditions, so get the source: + let localKey = key; + lastUsedPromises.push( + migrator.getLastUsedDate().then(date => { + const ONE_YEAR = 24 * 365; + let diffInHours = Math.round((Date.now() - date) / (60 * 60 * 1000)); + if (diffInHours > ONE_YEAR) { + diffInHours = ONE_YEAR; + } + histogram.add(localKey, diffInHours); + return [localKey, diffInHours]; + }) + ); + } + Promise.all(lastUsedPromises).then(migratorUsedTimeDiff => { + // Sort low to high. + migratorUsedTimeDiff.sort( + ([keyA, diffA], [keyB, diffB]) => diffA - diffB + ); /* eslint no-unused-vars: off */ + let usedMostRecentBrowser = + migratorUsedTimeDiff.length && + this._source == migratorUsedTimeDiff[0][0]; + let usedRecentBrowser = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_USED_RECENT_BROWSER" + ); + usedRecentBrowser.add(this._source, usedMostRecentBrowser); + }); + }, +}; diff --git a/browser/components/migration/content/migration.xhtml b/browser/components/migration/content/migration.xhtml new file mode 100644 index 0000000000..ff2b269c5e --- /dev/null +++ b/browser/components/migration/content/migration.xhtml @@ -0,0 +1,104 @@ +<?xml version="1.0"?> +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<window id="migrationWizard" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + data-l10n-id="migration-wizard" + windowtype="Browser:MigrationWizard" + onload="MigrationWizard.init()" + onunload="MigrationWizard.uninit()" + style="width: 40em;" + buttons="accept,cancel"> +<linkset> + <html:link rel="localization" href="branding/brand.ftl"/> + <html:link rel="localization" href="toolkit/global/wizard.ftl"/> + <html:link rel="localization" href="browser/migration.ftl"/> +</linkset> + +<script src="chrome://global/content/customElements.js"/> +<script src="chrome://browser/content/migration/migration.js"/> + +<wizard data-branded="true"> + <wizardpage id="importSource" pageid="importSource" next="selectProfile" + data-header-label-id="import-source-page-title"> + <description id="importAll" control="importSourceGroup" data-l10n-id="import-from"></description> + <description id="importBookmarks" control="importSourceGroup" data-l10n-id="import-from-bookmarks" hidden="true" ></description> + + <radiogroup id="importSourceGroup" align="start"> +# NB: if you add items to this list, please also assign them a unique migrator ID in MigrationUtils.jsm + <radio id="firefox" data-l10n-id="import-from-firefox"/> +#ifdef XP_WIN + <radio id="chromium-edge" data-l10n-id="import-from-edge"/> + <radio id="edge" data-l10n-id="import-from-edge-legacy" /> + <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/> + <radio id="ie" data-l10n-id="import-from-ie"/> + <radio id="chrome" data-l10n-id="import-from-chrome"/> + <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/> + <radio id="chromium" data-l10n-id="import-from-chromium"/> + <radio id="canary" data-l10n-id="import-from-canary" /> + <radio id="360se" data-l10n-id="import-from-360se"/> +#elifdef XP_MACOSX + <radio id="safari" data-l10n-id="import-from-safari"/> + <radio id="chrome" data-l10n-id="import-from-chrome"/> + <radio id="chromium-edge" data-l10n-id="import-from-edge"/> + <radio id="chromium-edge-beta" data-l10n-id="import-from-edge-beta"/> + <radio id="chromium" data-l10n-id="import-from-chromium"/> + <radio id="canary" data-l10n-id="import-from-canary"/> +#elifdef XP_UNIX + <radio id="chrome" data-l10n-id="import-from-chrome"/> + <radio id="chrome-beta" data-l10n-id="import-from-chrome-beta"/> + <radio id="chrome-dev" data-l10n-id="import-from-chrome-dev"/> + <radio id="chromium" data-l10n-id="import-from-chromium"/> +#endif + <radio id="nothing" data-l10n-id="import-from-nothing" hidden="true"/> + </radiogroup> + <label id="noSources" hidden="true" data-l10n-id="no-migration-sources"></label> + <spacer flex="1"/> + <description class="header" id="closeSourceBrowser" data-l10n-id="import-close-source-browser" style="visibility:hidden"></description> + </wizardpage> + + <wizardpage id="selectProfile" pageid="selectProfile" + data-header-label-id="import-select-profile-page-title" + next="importItems"> + <description control="profiles" data-l10n-id="import-select-profile-description"></description> + + <radiogroup id="profiles" align="start"/> + </wizardpage> + + <wizardpage id="importItems" pageid="importItems" + data-header-label-id="import-items-page-title" + next="migrating" + oncommand="MigrationWizard.onImportItemCommand();"> + <description control="dataSources" data-l10n-id="import-items-description"></description> + + <vbox id="dataSources" style="overflow: auto; appearance: auto; -moz-default-appearance: listbox" align="start" flex="1" role="group"/> + </wizardpage> + + <wizardpage id="importPermissions" pageid="importPermissions" + data-header-label-id="import-permissions-page-title" + next="migrating"> + <description data-l10n-id="import-permissions-description"></description> + </wizardpage> + + <wizardpage id="migrating" pageid="migrating" + data-header-label-id="import-migrating-page-title" + next="done"> + <description control="migratingItems" data-l10n-id="import-migrating-description"></description> + + <vbox id="migratingItems" style="overflow: auto;" align="start" role="group"/> + </wizardpage> + + <wizardpage id="done" pageid="done" + data-header-label-id="import-done-page-title"> + <description control="doneItems" data-l10n-id="import-done-description"></description> + + <vbox id="doneItems" style="overflow: auto;" align="start" role="group"/> + </wizardpage> + +</wizard> +</window> diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn new file mode 100644 index 0000000000..9689f5e3b1 --- /dev/null +++ b/browser/components/migration/jar.mn @@ -0,0 +1,8 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: +* content/browser/migration/migration.xhtml (content/migration.xhtml) + content/browser/migration/migration.js (content/migration.js) + content/browser/aboutWelcomeBack.xhtml (content/aboutWelcomeBack.xhtml) diff --git a/browser/components/migration/moz.build b/browser/components/migration/moz.build new file mode 100644 index 0000000000..778c7f938b --- /dev/null +++ b/browser/components/migration/moz.build @@ -0,0 +1,67 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XPCSHELL_TESTS_MANIFESTS += ["tests/unit/xpcshell.ini"] + +MARIONETTE_UNIT_MANIFESTS += ["tests/marionette/manifest.ini"] + +JAR_MANIFESTS += ["jar.mn"] + +XPIDL_SOURCES += [ + "nsIBrowserProfileMigrator.idl", +] + +XPIDL_MODULE = "migration" + +EXTRA_JS_MODULES += [ + "ChromeMigrationUtils.jsm", + "ChromeProfileMigrator.jsm", + "FirefoxProfileMigrator.jsm", + "MigrationUtils.jsm", + "ProfileMigrator.jsm", +] + +if CONFIG["OS_ARCH"] == "WINNT": + if CONFIG["ENABLE_TESTS"]: + DIRS += [ + "tests/unit/insertIEHistory", + ] + SOURCES += [ + "nsIEHistoryEnumerator.cpp", + ] + EXTRA_JS_MODULES += [ + "360seProfileMigrator.jsm", + "ChromeWindowsLoginCrypto.jsm", + "EdgeProfileMigrator.jsm", + "ESEDBReader.jsm", + "IEProfileMigrator.jsm", + "MSMigrationUtils.jsm", + ] + +if CONFIG["MOZ_WIDGET_TOOLKIT"] == "cocoa": + EXPORTS += [ + "nsKeychainMigrationUtils.h", + ] + EXTRA_JS_MODULES += [ + "ChromeMacOSLoginCrypto.jsm", + "SafariProfileMigrator.jsm", + ] + SOURCES += [ + "nsKeychainMigrationUtils.mm", + ] + XPIDL_SOURCES += [ + "nsIKeychainMigrationUtils.idl", + ] + + +XPCOM_MANIFESTS += [ + "components.conf", +] + +FINAL_LIBRARY = "browsercomps" + +with Files("**"): + BUG_COMPONENT = ("Firefox", "Migration") diff --git a/browser/components/migration/nsIBrowserProfileMigrator.idl b/browser/components/migration/nsIBrowserProfileMigrator.idl new file mode 100644 index 0000000000..630d3c2b9e --- /dev/null +++ b/browser/components/migration/nsIBrowserProfileMigrator.idl @@ -0,0 +1,73 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +interface nsIArray; +interface nsIProfileStartup; + +[scriptable, uuid(22b56ffc-3149-43c5-b5a9-b3a6b678de93)] +interface nsIBrowserProfileMigrator : nsISupports +{ + /** + * profile items to migrate. use with migrate(). + */ + const unsigned short ALL = 0x0000; + /* 0x01 used to be used for settings, but was removed. */ + const unsigned short COOKIES = 0x0002; + const unsigned short HISTORY = 0x0004; + const unsigned short FORMDATA = 0x0008; + const unsigned short PASSWORDS = 0x0010; + const unsigned short BOOKMARKS = 0x0020; + const unsigned short OTHERDATA = 0x0040; + const unsigned short SESSION = 0x0080; + + /** + * Copy user profile information to the current active profile. + * @param aItems list of data items to migrate. see above for values. + * @param aStartup helper interface which is non-null if called during startup. + * @param aProfile profile to migrate from, if there is more than one. + */ + void migrate(in unsigned short aItems, in nsIProfileStartup aStartup, in jsval aProfile); + + /** + * A bit field containing profile items that this migrator + * offers for import. + * @param aProfile the profile that we are looking for available data + * to import + * @return Promise containing a bit field containing profile items (see above) + * @note a return value of 0 represents no items rather than ALL. + */ + jsval getMigrateData(in jsval aProfile); + + /** + * Get the last time data from this browser was modified + * @return a promise that resolves to a JS Date object + */ + jsval getLastUsedDate(); + + /** + * Get whether or not there is any data that can be imported from this + * browser (i.e. whether or not it is installed, and there exists + * a user profile) + * @return a promise that resolves with a boolean. + */ + jsval isSourceAvailable(); + + + /** + * An enumeration of available profiles. If the import source does + * not support profiles, this attribute is null. + * @return a promise that resolves with an array of profiles or null. + */ + jsval getSourceProfiles(); + + + /** + * Whether the source browser data is locked/in-use meaning migration likely + * won't succeed and the user should be warned. + */ + readonly attribute boolean sourceLocked; +}; diff --git a/browser/components/migration/nsIEHistoryEnumerator.cpp b/browser/components/migration/nsIEHistoryEnumerator.cpp new file mode 100644 index 0000000000..497b92bab7 --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.cpp @@ -0,0 +1,116 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsIEHistoryEnumerator.h" + +#include <urlhist.h> +#include <shlguid.h> + +#include "nsArrayEnumerator.h" +#include "nsComponentManagerUtils.h" +#include "nsCOMArray.h" +#include "nsIURI.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsWindowsMigrationUtils.h" +#include "prtime.h" + +//////////////////////////////////////////////////////////////////////////////// +//// nsIEHistoryEnumerator + +nsIEHistoryEnumerator::nsIEHistoryEnumerator() { ::CoInitialize(nullptr); } + +nsIEHistoryEnumerator::~nsIEHistoryEnumerator() { ::CoUninitialize(); } + +void nsIEHistoryEnumerator::EnsureInitialized() { + if (mURLEnumerator) return; + + HRESULT hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg2, getter_AddRefs(mIEHistory)); + if (FAILED(hr)) return; + + hr = mIEHistory->EnumUrls(getter_AddRefs(mURLEnumerator)); + if (FAILED(hr)) return; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::HasMoreElements(bool* _retval) { + *_retval = false; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + STATURL statURL; + ULONG fetched; + + // First argument is not implemented, so doesn't matter what we pass. + HRESULT hr = mURLEnumerator->Next(1, &statURL, &fetched); + if (FAILED(hr) || fetched != 1UL) { + // Reached the last entry. + return NS_OK; + } + + nsCOMPtr<nsIURI> uri; + if (statURL.pwcsUrl) { + nsDependentString url(statURL.pwcsUrl); + nsresult rv = NS_NewURI(getter_AddRefs(uri), url); + ::CoTaskMemFree(statURL.pwcsUrl); + if (NS_FAILED(rv)) { + // Got a corrupt or invalid URI, continue to the next entry. + return HasMoreElements(_retval); + } + } + + nsDependentString title(statURL.pwcsTitle ? statURL.pwcsTitle : L""); + + bool lastVisitTimeIsValid; + PRTime lastVisited = WinMigrationFileTimeToPRTime(&(statURL.ftLastVisited), + &lastVisitTimeIsValid); + + mCachedNextEntry = do_CreateInstance("@mozilla.org/hash-property-bag;1"); + MOZ_ASSERT(mCachedNextEntry, "Should have instanced a new property bag"); + if (mCachedNextEntry) { + mCachedNextEntry->SetPropertyAsInterface(u"uri"_ns, uri); + mCachedNextEntry->SetPropertyAsAString(u"title"_ns, title); + if (lastVisitTimeIsValid) { + mCachedNextEntry->SetPropertyAsInt64(u"time"_ns, lastVisited); + } + + *_retval = true; + } + + if (statURL.pwcsTitle) ::CoTaskMemFree(statURL.pwcsTitle); + + return NS_OK; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::GetNext(nsISupports** _retval) { + *_retval = nullptr; + + EnsureInitialized(); + MOZ_ASSERT(mURLEnumerator, + "Should have instanced an IE History URLEnumerator"); + if (!mURLEnumerator) return NS_OK; + + if (!mCachedNextEntry) { + bool hasMore = false; + nsresult rv = this->HasMoreElements(&hasMore); + if (NS_FAILED(rv)) { + return rv; + } + if (!hasMore) { + return NS_ERROR_FAILURE; + } + } + + NS_ADDREF(*_retval = mCachedNextEntry); + // Release the cached entry, so it can't be returned twice. + mCachedNextEntry = nullptr; + + return NS_OK; +} diff --git a/browser/components/migration/nsIEHistoryEnumerator.h b/browser/components/migration/nsIEHistoryEnumerator.h new file mode 100644 index 0000000000..cd0c202bfc --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.h @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef iehistoryenumerator___h___ +#define iehistoryenumerator___h___ + +#include <urlhist.h> + +#include "mozilla/Attributes.h" +#include "nsCOMPtr.h" +#include "nsIWritablePropertyBag2.h" +#include "nsSimpleEnumerator.h" + +class nsIEHistoryEnumerator final : public nsSimpleEnumerator { + public: + NS_DECL_NSISIMPLEENUMERATOR + + nsIEHistoryEnumerator(); + + const nsID& DefaultInterface() override { + return NS_GET_IID(nsIWritablePropertyBag2); + } + + private: + ~nsIEHistoryEnumerator() override; + + /** + * Initializes the history reader, if needed. + */ + void EnsureInitialized(); + + RefPtr<IUrlHistoryStg2> mIEHistory; + RefPtr<IEnumSTATURL> mURLEnumerator; + + nsCOMPtr<nsIWritablePropertyBag2> mCachedNextEntry; +}; + +#endif diff --git a/browser/components/migration/nsIKeychainMigrationUtils.idl b/browser/components/migration/nsIKeychainMigrationUtils.idl new file mode 100644 index 0000000000..e0a9db4ddf --- /dev/null +++ b/browser/components/migration/nsIKeychainMigrationUtils.idl @@ -0,0 +1,12 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(647bf80c-cd35-4ce6-b904-fd586b97ae48)] +interface nsIKeychainMigrationUtils : nsISupports +{ + ACString getGenericPassword(in ACString aServiceName, in ACString aAccountName); +}; diff --git a/browser/components/migration/nsKeychainMigrationUtils.h b/browser/components/migration/nsKeychainMigrationUtils.h new file mode 100644 index 0000000000..343c24086e --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.h @@ -0,0 +1,23 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef nsKeychainMigrationUtils_h__ +#define nsKeychainMigrationUtils_h__ + +#include <CoreFoundation/CoreFoundation.h> + +#include "nsIKeychainMigrationUtils.h" + +class nsKeychainMigrationUtils : public nsIKeychainMigrationUtils { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIKEYCHAINMIGRATIONUTILS + + nsKeychainMigrationUtils(){}; + + protected: + virtual ~nsKeychainMigrationUtils(){}; +}; + +#endif diff --git a/browser/components/migration/nsKeychainMigrationUtils.mm b/browser/components/migration/nsKeychainMigrationUtils.mm new file mode 100644 index 0000000000..3b0f662914 --- /dev/null +++ b/browser/components/migration/nsKeychainMigrationUtils.mm @@ -0,0 +1,62 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsKeychainMigrationUtils.h" + +#include <Security/Security.h> + +#include "mozilla/Logging.h" + +#include "nsCocoaUtils.h" +#include "nsString.h" + +using namespace mozilla; + +LazyLogModule gKeychainUtilsLog("keychainmigrationutils"); + +NS_IMPL_ISUPPORTS(nsKeychainMigrationUtils, nsIKeychainMigrationUtils) + +NS_IMETHODIMP +nsKeychainMigrationUtils::GetGenericPassword(const nsACString& aServiceName, + const nsACString& aAccountName, nsACString& aKey) { + // To retrieve a secret, we create a CFDictionary of the form: + // { class: generic password, + // service: the given service name + // account: the given account name, + // match limit: match one, + // return attributes: true, + // return data: true } + // This searches for and returns the attributes and data for the secret + // matching the given service and account names. We then extract the data + // (i.e. the secret) and return it. + NSDictionary* searchDictionary = @{ + (__bridge NSString*)kSecClass : (__bridge NSString*)kSecClassGenericPassword, + (__bridge NSString*)kSecAttrService : nsCocoaUtils::ToNSString(aServiceName), + (__bridge NSString*)kSecAttrAccount : nsCocoaUtils::ToNSString(aAccountName), + (__bridge NSString*)kSecMatchLimit : (__bridge NSString*)kSecMatchLimitOne, + (__bridge NSString*)kSecReturnAttributes : @YES, + (__bridge NSString*)kSecReturnData : @YES + }; + + CFTypeRef item; + // https://developer.apple.com/documentation/security/1398306-secitemcopymatching + OSStatus rv = SecItemCopyMatching((__bridge CFDictionaryRef)searchDictionary, &item); + if (rv != errSecSuccess) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("SecItemCopyMatching failed: %d", rv)); + return NS_ERROR_FAILURE; + } + NSDictionary* resultDict = [(__bridge NSDictionary*)item autorelease]; + NSData* secret = [resultDict objectForKey:(__bridge NSString*)kSecValueData]; + if (!secret) { + MOZ_LOG(gKeychainUtilsLog, LogLevel::Debug, ("objectForKey failed")); + return NS_ERROR_FAILURE; + } + if ([secret length] != 0) { + // We assume that the data is UTF-8 encoded since that seems to be common and + // Keychain Access shows it with that encoding. + aKey.Assign(reinterpret_cast<const char*>([secret bytes]), [secret length]); + } + + return NS_OK; +} diff --git a/browser/components/migration/nsWindowsMigrationUtils.h b/browser/components/migration/nsWindowsMigrationUtils.h new file mode 100644 index 0000000000..4541759485 --- /dev/null +++ b/browser/components/migration/nsWindowsMigrationUtils.h @@ -0,0 +1,33 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef windowsmigrationutils__h__ +#define windowsmigrationutils__h__ + +#include "prtime.h" + +static PRTime WinMigrationFileTimeToPRTime(FILETIME* filetime, bool* isValid) { + SYSTEMTIME st; + *isValid = ::FileTimeToSystemTime(filetime, &st); + if (!*isValid) { + return 0; + } + PRExplodedTime prt; + prt.tm_year = st.wYear; + // SYSTEMTIME's day-of-month parameter is 1-based, + // PRExplodedTime's is 0-based. + prt.tm_month = st.wMonth - 1; + prt.tm_mday = st.wDay; + prt.tm_hour = st.wHour; + prt.tm_min = st.wMinute; + prt.tm_sec = st.wSecond; + prt.tm_usec = st.wMilliseconds * 1000; + prt.tm_wday = 0; + prt.tm_yday = 0; + prt.tm_params.tp_gmt_offset = 0; + prt.tm_params.tp_dst_offset = 0; + return PR_ImplodeTime(&prt); +} + +#endif diff --git a/browser/components/migration/tests/marionette/manifest.ini b/browser/components/migration/tests/marionette/manifest.ini new file mode 100644 index 0000000000..afba7ead73 --- /dev/null +++ b/browser/components/migration/tests/marionette/manifest.ini @@ -0,0 +1,4 @@ +[DEFAULT] +run-if = buildapp == 'browser' + +[test_refresh_firefox.py] diff --git a/browser/components/migration/tests/marionette/test_refresh_firefox.py b/browser/components/migration/tests/marionette/test_refresh_firefox.py new file mode 100644 index 0000000000..4de3de5ab7 --- /dev/null +++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py @@ -0,0 +1,698 @@ +from __future__ import absolute_import, print_function +import os +import time + +from marionette_harness import MarionetteTestCase +from marionette_driver.errors import NoAlertPresentException + + +# Holds info about things we need to cleanup after the tests are done. +class PendingCleanup: + desktop_backup_path = None + reset_profile_path = None + reset_profile_local_path = None + + def __init__(self, profile_name_to_remove): + self.profile_name_to_remove = profile_name_to_remove + + +class TestFirefoxRefresh(MarionetteTestCase): + _sandbox = "firefox-refresh" + + _username = "marionette-test-login" + _password = "marionette-test-password" + _bookmarkURL = "about:mozilla" + _bookmarkText = "Some bookmark from Marionette" + + _cookieHost = "firefox-refresh.marionette-test.mozilla.org" + _cookiePath = "some/cookie/path" + _cookieName = "somecookie" + _cookieValue = "some cookie value" + + _historyURL = "http://firefox-refresh.marionette-test.mozilla.org/" + _historyTitle = "Test visit for Firefox Reset" + + _formHistoryFieldName = "some-very-unique-marionette-only-firefox-reset-field" + _formHistoryValue = "special-pumpkin-value" + + _formAutofillAvailable = False + _formAutofillAddressGuid = None + + _expectedURLs = ["about:robots", "about:mozilla"] + + def savePassword(self): + self.runCode( + """ + let myLogin = new global.LoginInfo( + "test.marionette.mozilla.com", + "http://test.marionette.mozilla.com/some/form/", + null, + arguments[0], + arguments[1], + "username", + "password" + ); + Services.logins.addLogin(myLogin) + """, + script_args=(self._username, self._password), + ) + + def createBookmarkInMenu(self): + error = self.runAsyncCode( + """ + // let url = arguments[0]; + // let title = arguments[1]; + // let resolve = arguments[arguments.length - 1]; + let [url, title, resolve] = arguments; + PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.menuGuid, url, title + }).then(() => resolve(false), resolve); + """, + script_args=(self._bookmarkURL, self._bookmarkText), + ) + if error: + print(error) + + def createBookmarksOnToolbar(self): + error = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + let children = []; + for (let i = 1; i <= 5; i++) { + children.push({url: `about:rights?p=${i}`, title: `Bookmark ${i}`}); + } + PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children + }).then(() => resolve(false), resolve); + """ + ) + if error: + print(error) + + def createHistory(self): + error = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + PlacesUtils.history.insert({ + url: arguments[0], + title: arguments[1], + visits: [{ + date: new Date(Date.now() - 5000), + referrer: "about:mozilla" + }] + }).then(() => resolve(false), + ex => resolve("Unexpected error in adding visit: " + ex)); + """, + script_args=(self._historyURL, self._historyTitle), + ) + if error: + print(error) + + def createFormHistory(self): + error = self.runAsyncCode( + """ + let updateDefinition = { + op: "add", + fieldname: arguments[0], + value: arguments[1], + firstUsed: (Date.now() - 5000) * 1000, + }; + let finished = false; + let resolve = arguments[arguments.length - 1]; + global.FormHistory.update(updateDefinition, { + handleError(error) { + finished = true; + resolve(error); + }, + handleCompletion() { + if (!finished) { + resolve(false); + } + } + }); + """, + script_args=(self._formHistoryFieldName, self._formHistoryValue), + ) + if error: + print(error) + + def createFormAutofill(self): + if not self._formAutofillAvailable: + return + self._formAutofillAddressGuid = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + const TEST_ADDRESS_1 = { + "given-name": "John", + "additional-name": "R.", + "family-name": "Smith", + organization: "World Wide Web Consortium", + "street-address": "32 Vassar Street\\\nMIT Room 32-G524", + "address-level2": "Cambridge", + "address-level1": "MA", + "postal-code": "02139", + country: "US", + tel: "+15195555555", + email: "user@example.com", + }; + return global.formAutofillStorage.initialize().then(() => { + return global.formAutofillStorage.addresses.add(TEST_ADDRESS_1); + }).then(resolve); + """ + ) + + def createCookie(self): + self.runCode( + """ + // Expire in 15 minutes: + let expireTime = Math.floor(Date.now() / 1000) + 15 * 60; + Services.cookies.add(arguments[0], arguments[1], arguments[2], arguments[3], + true, false, false, expireTime, {}, + Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_UNSET); + """, + script_args=( + self._cookieHost, + self._cookiePath, + self._cookieName, + self._cookieValue, + ), + ) + + def createSession(self): + self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + const COMPLETE_STATE = Ci.nsIWebProgressListener.STATE_STOP + + Ci.nsIWebProgressListener.STATE_IS_NETWORK; + let {TabStateFlusher} = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {}); + let expectedURLs = Array.from(arguments[0]) + gBrowser.addTabsProgressListener({ + onStateChange(browser, webprogress, request, flags, status) { + try { + request && request.QueryInterface(Ci.nsIChannel); + } catch (ex) {} + let uriLoaded = request.originalURI && request.originalURI.spec; + if ((flags & COMPLETE_STATE == COMPLETE_STATE) && uriLoaded && + expectedURLs.includes(uriLoaded)) { + TabStateFlusher.flush(browser).then(function() { + expectedURLs.splice(expectedURLs.indexOf(uriLoaded), 1); + if (!expectedURLs.length) { + gBrowser.removeTabsProgressListener(this); + resolve(); + } + }); + } + } + }); + let expectedTabs = new Set(); + for (let url of expectedURLs) { + expectedTabs.add(gBrowser.addTab(url, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + })); + } + // Close any other tabs that might be open: + let allTabs = Array.from(gBrowser.tabs); + for (let tab of allTabs) { + if (!expectedTabs.has(tab)) { + gBrowser.removeTab(tab); + } + } + """, # NOQA: E501 + script_args=(self._expectedURLs,), + ) + + def createFxa(self): + # This script will write an entry to the login manager and create + # a signedInUser.json in the profile dir. + self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + Cu.import("resource://gre/modules/FxAccountsStorage.jsm"); + let storage = new FxAccountsStorageManager(); + let data = {email: "test@test.com", uid: "uid", keyFetchToken: "top-secret"}; + storage.initialize(data); + storage.finalize().then(resolve); + """ + ) + + def createSync(self): + # This script will write the canonical preference which indicates a user + # is signed into sync. + self.marionette.execute_script( + """ + Services.prefs.setStringPref("services.sync.username", "test@test.com"); + """ + ) + + def checkPassword(self): + loginInfo = self.marionette.execute_script( + """ + let ary = Services.logins.findLogins( + "test.marionette.mozilla.com", + "http://test.marionette.mozilla.com/some/form/", + null, {}); + return ary.length ? ary : {username: "null", password: "null"}; + """ + ) + self.assertEqual(len(loginInfo), 1) + self.assertEqual(loginInfo[0]["username"], self._username) + self.assertEqual(loginInfo[0]["password"], self._password) + + loginCount = self.marionette.execute_script( + """ + return Services.logins.getAllLogins().length; + """ + ) + # Note that we expect 2 logins - one from us, one from sync. + self.assertEqual(loginCount, 2, "No other logins are present") + + def checkBookmarkInMenu(self): + titleInBookmarks = self.runAsyncCode( + """ + let [url, resolve] = arguments; + PlacesUtils.bookmarks.fetch({url}).then( + bookmark => resolve(bookmark ? bookmark.title : ""), + ex => resolve(ex) + ); + """, + script_args=(self._bookmarkURL,), + ) + self.assertEqual(titleInBookmarks, self._bookmarkText) + + def checkBookmarkToolbarVisibility(self): + toolbarVisible = self.marionette.execute_script( + """ + const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL; + return Services.xulStore.getValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed"); + """ + ) + if toolbarVisible == "": + toolbarVisible = "false" + self.assertEqual(toolbarVisible, "false") + + def checkHistory(self): + historyResult = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + PlacesUtils.history.fetch(arguments[0]).then(pageInfo => { + if (!pageInfo) { + resolve("No visits found"); + } else { + resolve(pageInfo); + } + }).catch(e => { + resolve("Unexpected error in fetching page: " + e); + }); + """, + script_args=(self._historyURL,), + ) + if type(historyResult) == str: + self.fail(historyResult) + return + + self.assertEqual(historyResult["title"], self._historyTitle) + + def checkFormHistory(self): + formFieldResults = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + let results = []; + global.FormHistory.search(["value"], {fieldname: arguments[0]}, { + handleError(error) { + results = error; + }, + handleResult(result) { + results.push(result); + }, + handleCompletion() { + resolve(results); + }, + }); + """, + script_args=(self._formHistoryFieldName,), + ) + if type(formFieldResults) == str: + self.fail(formFieldResults) + return + + formFieldResultCount = len(formFieldResults) + self.assertEqual( + formFieldResultCount, + 1, + "Should have exactly 1 entry for this field, got %d" % formFieldResultCount, + ) + if formFieldResultCount == 1: + self.assertEqual(formFieldResults[0]["value"], self._formHistoryValue) + + formHistoryCount = self.runAsyncCode( + """ + let [resolve] = arguments; + let count; + let callbacks = { + handleResult: rv => count = rv, + handleCompletion() { + resolve(count); + }, + }; + global.FormHistory.count({}, callbacks); + """ + ) + self.assertEqual( + formHistoryCount, 1, "There should be only 1 entry in the form history" + ) + + def checkFormAutofill(self): + if not self._formAutofillAvailable: + return + + formAutofillResults = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1]; + return global.formAutofillStorage.initialize().then(() => { + return global.formAutofillStorage.addresses.getAll() + }).then(resolve); + """, + ) + if type(formAutofillResults) == str: + self.fail(formAutofillResults) + return + + formAutofillAddressCount = len(formAutofillResults) + self.assertEqual( + formAutofillAddressCount, + 1, + "Should have exactly 1 saved address, got %d" % formAutofillAddressCount, + ) + if formAutofillAddressCount == 1: + self.assertEqual( + formAutofillResults[0]["guid"], self._formAutofillAddressGuid + ) + + def checkCookie(self): + cookieInfo = self.runCode( + """ + try { + let cookies = Services.cookies.getCookiesFromHost(arguments[0], {}); + let cookie = null; + for (let hostCookie of cookies) { + // getCookiesFromHost returns any cookie from the BASE host. + if (hostCookie.rawHost != arguments[0]) + continue; + if (cookie != null) { + return "more than 1 cookie! That shouldn't happen!"; + } + cookie = hostCookie; + } + return {path: cookie.path, name: cookie.name, value: cookie.value}; + } catch (ex) { + return "got exception trying to fetch cookie: " + ex; + } + """, + script_args=(self._cookieHost,), + ) + if not isinstance(cookieInfo, dict): + self.fail(cookieInfo) + return + self.assertEqual(cookieInfo["path"], self._cookiePath) + self.assertEqual(cookieInfo["value"], self._cookieValue) + self.assertEqual(cookieInfo["name"], self._cookieName) + + def checkSession(self): + tabURIs = self.runCode( + """ + return [... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec) + """ + ) + self.assertSequenceEqual(tabURIs, ["about:welcomeback"]) + + # Dismiss modal dialog if any. This is mainly to dismiss the check for + # default browser dialog if it shows up. + try: + alert = self.marionette.switch_to_alert() + alert.dismiss() + except NoAlertPresentException: + pass + + tabURIs = self.runAsyncCode( + """ + let resolve = arguments[arguments.length - 1] + let mm = gBrowser.selectedBrowser.messageManager; + + let {TabStateFlusher} = Cu.import("resource:///modules/sessionstore/TabStateFlusher.jsm", {}); + window.addEventListener("SSWindowStateReady", function testSSPostReset() { + window.removeEventListener("SSWindowStateReady", testSSPostReset, false); + Promise.all(gBrowser.browsers.map(b => TabStateFlusher.flush(b))).then(function() { + resolve([... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec)); + }); + }, false); + + let fs = function() { + if (content.document.readyState === "complete") { + content.document.getElementById("errorTryAgain").click(); + } else { + content.window.addEventListener("load", function(event) { + content.document.getElementById("errorTryAgain").click(); + }, { once: true }); + } + }; + + mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true); + """ # NOQA: E501 + ) + self.assertSequenceEqual(tabURIs, self._expectedURLs) + + def checkFxA(self): + result = self.runAsyncCode( + """ + Cu.import("resource://gre/modules/FxAccountsStorage.jsm"); + let resolve = arguments[arguments.length - 1]; + let storage = new FxAccountsStorageManager(); + let result = {}; + storage.initialize(); + storage.getAccountData().then(data => { + result.accountData = data; + return storage.finalize(); + }).then(() => { + resolve(result); + }).catch(err => { + resolve(err.toString()); + }); + """ + ) + if type(result) != dict: + self.fail(result) + return + self.assertEqual(result["accountData"]["email"], "test@test.com") + self.assertEqual(result["accountData"]["uid"], "uid") + self.assertEqual(result["accountData"]["keyFetchToken"], "top-secret") + + def checkSync(self, expect_sync_user): + pref_value = self.marionette.execute_script( + """ + return Services.prefs.getStringPref("services.sync.username", null); + """ + ) + expected_value = "test@test.com" if expect_sync_user else None + self.assertEqual(pref_value, expected_value) + + def checkProfile(self, has_migrated=False, expect_sync_user=True): + self.checkPassword() + self.checkBookmarkInMenu() + self.checkHistory() + self.checkFormHistory() + self.checkFormAutofill() + self.checkCookie() + self.checkFxA() + self.checkSync(expect_sync_user) + if has_migrated: + self.checkBookmarkToolbarVisibility() + self.checkSession() + + def createProfileData(self): + self.savePassword() + self.createBookmarkInMenu() + self.createBookmarksOnToolbar() + self.createHistory() + self.createFormHistory() + self.createFormAutofill() + self.createCookie() + self.createSession() + self.createFxa() + self.createSync() + + def setUpScriptData(self): + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + self.runCode( + """ + window.global = {}; + global.LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", "nsILoginInfo", "init"); + global.profSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(Ci.nsIToolkitProfileService); + global.Preferences = Cu.import("resource://gre/modules/Preferences.jsm", {}).Preferences; + global.FormHistory = Cu.import("resource://gre/modules/FormHistory.jsm", {}).FormHistory; + """ # NOQA: E501 + ) + self._formAutofillAvailable = self.runCode( + """ + try { + global.formAutofillStorage = Cu.import("resource://formautofill/FormAutofillStorage.jsm", {}).formAutofillStorage; + } catch(e) { + return false; + } + return true; + """ # NOQA: E501 + ) + + def runCode(self, script, *args, **kwargs): + return self.marionette.execute_script( + script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs + ) + + def runAsyncCode(self, script, *args, **kwargs): + return self.marionette.execute_async_script( + script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs + ) + + def setUp(self): + MarionetteTestCase.setUp(self) + self.setUpScriptData() + + self.cleanups = [] + + def tearDown(self): + # Force yet another restart with a clean profile to disconnect from the + # profile and environment changes we've made, to leave a more or less + # blank slate for the next person. + self.marionette.restart(clean=True, in_app=False) + self.setUpScriptData() + + # Super + MarionetteTestCase.tearDown(self) + + # A helper to deal with removing a load of files + import mozfile + + for cleanup in self.cleanups: + if cleanup.desktop_backup_path: + mozfile.remove(cleanup.desktop_backup_path) + + if cleanup.reset_profile_path: + # Remove ourselves from profiles.ini + self.runCode( + """ + let name = arguments[0]; + let profile = global.profSvc.getProfileByName(name); + profile.remove(false) + global.profSvc.flush(); + """, + script_args=(cleanup.profile_name_to_remove,), + ) + # Remove the local profile dir if it's not the same as the profile dir: + different_path = ( + cleanup.reset_profile_local_path != cleanup.reset_profile_path + ) + if cleanup.reset_profile_local_path and different_path: + mozfile.remove(cleanup.reset_profile_local_path) + + # And delete all the files. + mozfile.remove(cleanup.reset_profile_path) + + def doReset(self): + profileName = "marionette-test-profile-" + str(int(time.time() * 1000)) + cleanup = PendingCleanup(profileName) + self.runCode( + """ + // Ensure the current (temporary) profile is in profiles.ini: + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileName = arguments[1]; + let myProfile = global.profSvc.createProfile(profD, profileName); + global.profSvc.flush() + + // Now add the reset parameters: + let env = Cc["@mozilla.org/process/environment;1"].getService(Ci.nsIEnvironment); + let prefsToKeep = Array.from(Services.prefs.getChildList("marionette.")); + prefsToKeep.push("datareporting.policy.dataSubmissionPolicyBypassNotification"); + let prefObj = {}; + for (let pref of prefsToKeep) { + prefObj[pref] = global.Preferences.get(pref); + } + env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj)); + env.set("MOZ_RESET_PROFILE_RESTART", "1"); + env.set("XRE_PROFILE_PATH", arguments[0]); + """, + script_args=( + self.marionette.instance.profile.profile, + profileName, + ), + ) + + profileLeafName = os.path.basename( + os.path.normpath(self.marionette.instance.profile.profile) + ) + + # Now restart the browser to get it reset: + self.marionette.restart(clean=False, in_app=True) + self.setUpScriptData() + + # Determine the new profile path (we'll need to remove it when we're done) + [cleanup.reset_profile_path, cleanup.reset_profile_local_path] = self.runCode( + """ + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let localD = Services.dirsvc.get("ProfLD", Ci.nsIFile); + return [profD.path, localD.path]; + """ + ) + + # Determine the backup path + cleanup.desktop_backup_path = self.runCode( + """ + let container; + try { + container = Services.dirsvc.get("Desk", Ci.nsIFile); + } catch (ex) { + container = Services.dirsvc.get("Home", Ci.nsIFile); + } + let bundle = Services.strings.createBundle("chrome://mozapps/locale/profile/profileSelection.properties"); + let dirName = bundle.formatStringFromName("resetBackupDirectory", [Services.appinfo.name]); + container.append(dirName); + container.append(arguments[0]); + return container.path; + """, # NOQA: E501 + script_args=(profileLeafName,), + ) + + self.assertTrue( + os.path.isdir(cleanup.reset_profile_path), + "Reset profile path should be present", + ) + self.assertTrue( + os.path.isdir(cleanup.desktop_backup_path), + "Backup profile path should be present", + ) + self.assertIn(cleanup.profile_name_to_remove, cleanup.reset_profile_path) + return cleanup + + def testResetEverything(self): + self.createProfileData() + + self.checkProfile(expect_sync_user=True) + + this_cleanup = self.doReset() + self.cleanups.append(this_cleanup) + + # Now check that we're doing OK... + self.checkProfile(has_migrated=True, expect_sync_user=True) + + def testFxANoSync(self): + # This test doesn't need to repeat all the non-sync tests... + # Setup FxA but *not* sync + self.createFxa() + + self.checkFxA() + self.checkSync(False) + + this_cleanup = self.doReset() + self.cleanups.append(this_cleanup) + + self.checkFxA() + self.checkSync(False) diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data Binary files differnew file mode 100644 index 0000000000..7e6e843a03 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data 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 Binary files differnew file mode 100644 index 0000000000..fd135624c4 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db new file mode 100644 index 0000000000..a632fdcbad --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/0f3ab103a522f4463ecacc36d34eb996/360sefav.db @@ -0,0 +1 @@ +Placeholder file to satisfy the resource existence check, not a real SQLite db.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db Binary files differnew file mode 100644 index 0000000000..1835c33583 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/default/360sefav.db diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini new file mode 100644 index 0000000000..47f6f024e1 --- /dev/null +++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/apps/data/users/login.ini @@ -0,0 +1,17 @@ +# This file contains Chinese characters encoded in GBK
+[NowLogin]
+NickName=ıǻ
+email=test@firefox.com.cn
+UserMd5=0f3ab103a522f4463ecacc36d34eb996
+IsLogined=1
+# Will be excluded from sourceProfiles due to missing files
+[20070606]
+NickName=ı
+email=test@mozillaonline.com
+UserMd5=46a579b8b64358fd45616247df4ea604
+# Will be excluded from sourceProfiles as duplication of NowLogin
+[20110303]
+NickName=ıǻ
+email=test@firefox.com.cn
+UserMd5=0f3ab103a522f4463ecacc36d34eb996
+# There's also a default profile (not included here) for anonymous users
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies Binary files differnew file mode 100644 index 0000000000..83d855cb33 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies 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 Binary files differnew file mode 100644 index 0000000000..7fb19903b0 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster 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 Binary files differnew file mode 100644 index 0000000000..19b8542b98 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data 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 Binary files differnew file mode 100644 index 0000000000..8046d5e9c9 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist 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 Binary files differnew file mode 100644 index 0000000000..b2d425eb4a --- /dev/null +++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State new file mode 100644 index 0000000000..bb03d6b9a1 --- /dev/null +++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State @@ -0,0 +1,22 @@ +{ + "profile" : { + "info_cache" : { + "Default" : { + "active_time" : 1430950755.65137, + "is_using_default_name" : true, + "is_ephemeral" : false, + "is_omitted_from_profile_list" : false, + "user_name" : "", + "background_apps" : false, + "is_using_default_avatar" : true, + "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0", + "name" : "Person With No Data" + } + }, + "profiles_created" : 1, + "last_used" : "Default", + "last_active_profiles" : [ + "Default" + ] + } +} diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js new file mode 100644 index 0000000000..ef2a142c34 --- /dev/null +++ b/browser/components/migration/tests/unit/head_migration.js @@ -0,0 +1,109 @@ +"use strict"; + +var { MigrationUtils, MigratorPrototype } = ChromeUtils.import( + "resource:///modules/MigrationUtils.jsm" +); +var { LoginHelper } = ChromeUtils.import( + "resource://gre/modules/LoginHelper.jsm" +); +var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +var { PlacesUtils } = ChromeUtils.import( + "resource://gre/modules/PlacesUtils.jsm" +); +var { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); +var { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); +var { PlacesTestUtils } = ChromeUtils.import( + "resource://testing-common/PlacesTestUtils.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); + +ChromeUtils.defineModuleGetter( + this, + "FileUtils", + "resource://gre/modules/FileUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Sqlite", + "resource://gre/modules/Sqlite.jsm" +); + +// Initialize profile. +var gProfD = do_get_profile(); + +var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.import( + "resource://testing-common/AppInfo.jsm" +); +updateAppInfo(); + +/** + * Migrates the requested resource and waits for the migration to be complete. + */ +async function promiseMigration( + migrator, + resourceType, + aProfile = null, + succeeds = null +) { + // Ensure resource migration is available. + let availableSources = await migrator.getMigrateData(aProfile); + Assert.ok( + (availableSources & resourceType) > 0, + "Resource supported by migrator" + ); + let promises = [TestUtils.topicObserved("Migration:Ended")]; + + if (succeeds !== null) { + // Check that the specific resource type succeeded + promises.push( + TestUtils.topicObserved( + succeeds ? "Migration:ItemAfterMigrate" : "Migration:ItemError", + (_, data) => data == resourceType + ) + ); + } + + // Start the migration. + migrator.migrate(resourceType, null, aProfile); + + return Promise.all(promises); +} + +/** + * Replaces a directory service entry with a given nsIFile. + */ +function registerFakePath(key, file) { + let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties); + let originalFile; + try { + // If a file is already provided save it and undefine, otherwise set will + // throw for persistent entries (ones that are cached). + originalFile = dirsvc.get(key, Ci.nsIFile); + dirsvc.undefine(key); + } catch (e) { + // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine + // will throw if it's not a persistent entry, in either case we don't want + // to set the original file in cleanup. + originalFile = undefined; + } + + dirsvc.set(key, file); + registerCleanupFunction(() => { + dirsvc.undefine(key); + if (originalFile) { + dirsvc.set(key, originalFile); + } + }); +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp new file mode 100644 index 0000000000..cdc1faff7d --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Insert URLs into Internet Explorer (IE) history so we can test importing + * them. + * + * See API docs at + * https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms774949(v%3dvs.85) + */ + +#include <urlhist.h> // IUrlHistoryStg +#include <shlguid.h> // SID_SUrlHistory + +int main(int argc, char** argv) { + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) { + CoUninitialize(); + return -1; + } + IUrlHistoryStg* ieHist; + + hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg, reinterpret_cast<void**>(&ieHist)); + if (FAILED(hr)) return -2; + + hr = ieHist->AddUrl(L"http://www.mozilla.org/1", L"Mozilla HTTP Test", 0); + if (FAILED(hr)) return -3; + + hr = ieHist->AddUrl(L"https://www.mozilla.org/2", L"Mozilla HTTPS Test 🦊", + 0); + if (FAILED(hr)) return -4; + + CoUninitialize(); + + return 0; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/moz.build b/browser/components/migration/tests/unit/insertIEHistory/moz.build new file mode 100644 index 0000000000..33c261c746 --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +FINAL_TARGET = "_tests/xpcshell/browser/components/migration/tests/unit" + +Program("InsertIEHistory") +OS_LIBS += [ + "ole32", +] +SOURCES += [ + "InsertIEHistory.cpp", +] + +NO_PGO = True +DisableStlWrapping() diff --git a/browser/components/migration/tests/unit/test_360se_bookmarks.js b/browser/components/migration/tests/unit/test_360se_bookmarks.js new file mode 100644 index 0000000000..e08959f50a --- /dev/null +++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js @@ -0,0 +1,75 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.import( + "resource:///modules/CustomizableUI.jsm" +); + +add_task(async function() { + registerFakePath("AppData", do_get_file("AppData/Roaming/")); + + let migrator = await MigrationUtils.getMigrator("360se"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let profiles = await migrator.getSourceProfiles(); + Assert.equal(profiles.length, 2, "Should present two profiles"); + Assert.equal( + profiles[0].name, + "test@firefox.com.cn", + "Current logged in user should be the first" + ); + Assert.equal( + profiles[profiles.length - 1].name, + "Default", + "Default user should be the last" + ); + + let importedToBookmarksToolbar = false; + let itemsSeen = { bookmarks: 0, folders: 0 }; + + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + if (event.parentId == PlacesUtils.toolbarFolderId) { + importedToBookmarksToolbar = true; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS, { + id: "default", + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(importedToBookmarksToolbar, "Bookmarks imported in the toolbar"); + Assert.equal(itemsSeen.bookmarks, 8, "Should import all bookmarks."); + Assert.equal(itemsSeen.folders, 2, "Should import all folders."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js new file mode 100644 index 0000000000..937332628c --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js @@ -0,0 +1,84 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.import( + "resource:///modules/ChromeMigrationUtils.jsm" +); + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return do_get_file("Library/Application Support/Google/Chrome/").path; +}; + +add_task(async function test_getExtensionList_function() { + let extensionList = await ChromeMigrationUtils.getExtensionList("Default"); + Assert.equal( + extensionList.length, + 2, + "There should be 2 extensions installed." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-1"), + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "First extension should match expectations." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-2"), + { + id: "fake-extension-2", + name: "Fake Extension 2", + description: "It is the description of fake extension 2.", + }, + "Second extension should match expectations." + ); +}); + +add_task(async function test_getExtensionInformation_function() { + let extension = await ChromeMigrationUtils.getExtensionInformation( + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + extension, + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "Should get the extension information correctly." + ); +}); + +add_task(async function test_getLocaleString_function() { + let name = await ChromeMigrationUtils._getLocaleString( + "__MSG_name__", + "en_US", + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + name, + "Fake Extension 1", + "The value of __MSG_name__ locale key is Fake Extension 1." + ); +}); + +add_task(async function test_isExtensionInstalled_function() { + let isInstalled = await ChromeMigrationUtils.isExtensionInstalled( + "fake-extension-1", + "Default" + ); + Assert.ok(isInstalled, "The fake-extension-1 extension should be installed."); +}); + +add_task(async function test_getLastUsedProfileId_function() { + let profileId = await ChromeMigrationUtils.getLastUsedProfileId(); + Assert.equal( + profileId, + "Default", + "The last used profile ID should be Default." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js new file mode 100644 index 0000000000..ef34b7ce1c --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js @@ -0,0 +1,114 @@ +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { ChromeMigrationUtils } = ChromeUtils.import( + "resource:///modules/ChromeMigrationUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +function getRootPath() { + let dirKey; + if (AppConstants.platform == "win") { + dirKey = "winLocalAppDataDir"; + } else if (AppConstants.platform == "macosx") { + dirKey = "macUserLibDir"; + } else { + dirKey = "homeDir"; + } + return OS.Constants.Path[dirKey]; +} + +add_task(async function test_getDataPath_function() { + let chromeUserDataPath = ChromeMigrationUtils.getDataPath("Chrome"); + let chromiumUserDataPath = ChromeMigrationUtils.getDataPath("Chromium"); + let canaryUserDataPath = ChromeMigrationUtils.getDataPath("Canary"); + if (AppConstants.platform == "win") { + Assert.equal( + chromeUserDataPath, + OS.Path.join(getRootPath(), "Google", "Chrome", "User Data"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + OS.Path.join(getRootPath(), "Chromium", "User Data"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + OS.Path.join(getRootPath(), "Google", "Chrome SxS", "User Data"), + "Should get the path of Canary data directory." + ); + } else if (AppConstants.platform == "macosx") { + Assert.equal( + chromeUserDataPath, + OS.Path.join(getRootPath(), "Application Support", "Google", "Chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + OS.Path.join(getRootPath(), "Application Support", "Chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + OS.Path.join( + getRootPath(), + "Application Support", + "Google", + "Chrome Canary" + ), + "Should get the path of Canary data directory." + ); + } else { + Assert.equal( + chromeUserDataPath, + OS.Path.join(getRootPath(), ".config", "google-chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + OS.Path.join(getRootPath(), ".config", "chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal(canaryUserDataPath, null, "Should get null for Canary."); + } +}); + +add_task(async function test_getExtensionPath_function() { + let extensionPath = ChromeMigrationUtils.getExtensionPath("Default"); + let expectedPath; + if (AppConstants.platform == "win") { + expectedPath = OS.Path.join( + getRootPath(), + "Google", + "Chrome", + "User Data", + "Default", + "Extensions" + ); + } else if (AppConstants.platform == "macosx") { + expectedPath = OS.Path.join( + getRootPath(), + "Application Support", + "Google", + "Chrome", + "Default", + "Extensions" + ); + } else { + expectedPath = OS.Path.join( + getRootPath(), + ".config", + "google-chrome", + "Default", + "Extensions" + ); + } + Assert.equal( + extensionPath, + expectedPath, + "Should get the path of extensions directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js new file mode 100644 index 0000000000..6198eb7265 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js @@ -0,0 +1,198 @@ +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { CustomizableUI } = ChromeUtils.import( + "resource:///modules/CustomizableUI.jsm" +); + +const { PlacesUIUtils } = ChromeUtils.import( + "resource:///modules/PlacesUIUtils.jsm" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function setup_initialBookmarks() { + let bookmarks = []; + for (let i = 0; i < PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + 1; i++) { + bookmarks.push({ url: "https://example.com/" + i, title: "" + i }); + } + + // Ensure we have enough items in both the menu and toolbar to trip creating a "from" folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarks, + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarks, + }); +}); + +async function testBookmarks(migratorKey, subDirs, folderName) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + // We don't import osfile.jsm until after registering the fake path, because + // importing osfile will sometimes greedily fetch certain path identifiers + // from the dir service, which means they get cached, which means we can't + // register a fake path for them anymore. + const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + await OS.File.makeDir(target.path, { + from: rootDir.parent.path, + ignoreExisting: true, + }); + + target.append("Bookmarks"); + await OS.File.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = { + roots: { bookmark_bar: { children: [] }, other: { children: [] } }, + }; + const MAX_BMS = 100; + let barKids = bookmarksData.roots.bookmark_bar.children; + let menuKids = bookmarksData.roots.other.children; + let currentMenuKids = menuKids; + let currentBarKids = barKids; + for (let i = 0; i < MAX_BMS; i++) { + currentBarKids.push({ + url: "https://www.chrome-bookmark-bar-bookmark" + i + ".com", + name: "bookmark " + i, + type: "url", + }); + currentMenuKids.push({ + url: "https://www.chrome-menu-bookmark" + i + ".com", + name: "bookmark for menu " + i, + type: "url", + }); + if (i % 20 == 19) { + let nextFolder = { + name: "toolbar folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentBarKids.push(nextFolder); + currentBarKids = nextFolder.children; + + nextFolder = { + name: "menu folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentMenuKids.push(nextFolder); + currentMenuKids = nextFolder.children; + } + } + + await OS.File.writeAtomic(target.path, JSON.stringify(bookmarksData), { + encoding: "utf-8", + }); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let itemsSeen = { bookmarks: 0, folders: 0 }; + let gotImportedFolderWrapper = false; + let listener = events => { + for (let event of events) { + // "From " comes from the string `importedBookmarksFolder` + if ( + event.title.startsWith("From ") && + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ) { + Assert.equal(event.title, folderName, "Bookmark folder name"); + gotImportedFolderWrapper = true; + } else { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + } + } + }; + + PlacesUtils.observers.addListener(["bookmark-added"], listener); + const PROFILE = { + id: "Default", + name: "Default", + }; + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.BOOKMARKS, + PROFILE + ); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + Assert.equal(itemsSeen.bookmarks, 200, "Should have seen 200 bookmarks."); + Assert.equal(itemsSeen.folders, 10, "Should have seen 10 folders."); + Assert.equal( + gotImportedFolderWrapper, + !Services.prefs.getBoolPref("browser.toolbars.bookmarks.2h2020"), + "Should only get a 'From BrowserX' folder when the 2h2020 pref is disabled" + ); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +} + +add_task(async function test_Chrome() { + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testBookmarks("chrome", subDirs, "From Google Chrome"); +}); + +add_task(async function test_ChromiumEdge() { + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testBookmarks("chromium-edge", subDirs, "From Microsoft Edge"); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_cookies.js b/browser/components/migration/tests/unit/test_Chrome_cookies.js new file mode 100644 index 0000000000..b738c1d1bc --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_cookies.js @@ -0,0 +1,72 @@ +"use strict"; + +const { ForgetAboutSite } = ChromeUtils.import( + "resource://gre/modules/ForgetAboutSite.jsm" +); + +add_task(async function() { + registerFakePath("ULibDir", do_get_file("Library/")); + let migrator = await MigrationUtils.getMigrator("chrome"); + + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + const COOKIE = { + expiry: 2145934800, + host: "unencryptedcookie.invalid", + isHttpOnly: false, + isSession: false, + name: "testcookie", + path: "/", + value: "testvalue", + }; + + // Sanity check. + Assert.equal( + Services.cookies.countCookiesFromHost(COOKIE.host), + 0, + "There are no cookies initially" + ); + + const PROFILE = { + id: "Default", + name: "Person 1", + }; + + // Migrate unencrypted cookies. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.COOKIES, + PROFILE + ); + + Assert.equal( + Services.cookies.countCookiesFromHost(COOKIE.host), + 1, + "Migrated the expected number of unencrypted cookies" + ); + Assert.equal( + Services.cookies.countCookiesFromHost("encryptedcookie.invalid"), + 0, + "Migrated the expected number of encrypted cookies" + ); + + // Now check the cookie details. + let cookies = Services.cookies.getCookiesFromHost(COOKIE.host, {}); + Assert.ok(cookies.length, "Cookies available"); + let foundCookie = cookies[0]; + + for (let prop of Object.keys(COOKIE)) { + Assert.equal(foundCookie[prop], COOKIE[prop], "Check cookie " + prop); + } + + // Cleanup. + await ForgetAboutSite.removeDataFromDomain(COOKIE.host); + Assert.equal( + Services.cookies.countCookiesFromHost(COOKIE.host), + 0, + "There are no cookies after cleanup" + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_history.js b/browser/components/migration/tests/unit/test_Chrome_history.js new file mode 100644 index 0000000000..de0c7d9751 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_history.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.import( + "resource:///modules/ChromeMigrationUtils.jsm" +); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +/** + * TEST_URLS reflects the data stored in '${SOURCE_PROFILE_DIR}HistoryMaster'. + * The main object reflects the data in the 'urls' table. The visits property + * reflects the associated data in the 'visits' table. + */ +const TEST_URLS = [ + { + id: 1, + url: "http://example.com/", + title: "test", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193151310368000, + hidden: 0, + visits: [ + { + id: 1, + url: 1, + visit_time: 13193151310368000, + from_visit: 0, + transition: 805306370, + segment_id: 0, + visit_duration: 10745006, + incremented_omnibox_typed_score: 0, + }, + ], + }, + { + id: 2, + url: "http://invalid.com/", + title: "test2", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193154948901000, + hidden: 0, + visits: [ + { + id: 2, + url: 2, + visit_time: 13193154948901000, + from_visit: 0, + transition: 805306376, + segment_id: 0, + visit_duration: 6568270, + incremented_omnibox_typed_score: 0, + }, + ], + }, +]; + +async function setVisitTimes(time) { + let loginDataFile = do_get_file(`${SOURCE_PROFILE_DIR}History`); + let dbConn = await Sqlite.openConnection({ path: loginDataFile.path }); + + await dbConn.execute(`UPDATE urls SET last_visit_time = :last_visit_time`, { + last_visit_time: time, + }); + await dbConn.execute(`UPDATE visits SET visit_time = :visit_time`, { + visit_time: time, + }); + + await dbConn.close(); +} + +function setExpectedVisitTimes(time) { + for (let urlInfo of TEST_URLS) { + urlInfo.last_visit_time = time; + urlInfo.visits[0].visit_time = time; + } +} + +function assertEntryMatches(entry, urlInfo, dateWasInFuture = false) { + info(`Checking url: ${urlInfo.url}`); + Assert.ok(entry, `Should have stored an entry`); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits.length, + "Should have the correct number of visits" + ); + + for (let index in urlInfo.visits) { + Assert.equal( + entry.visits[index].transition, + PlacesUtils.history.TRANSITIONS.LINK, + "Should have Link type transition" + ); + + if (dateWasInFuture) { + Assert.lessOrEqual( + entry.visits[index].date.getTime(), + new Date().getTime(), + "Should have moved the date to no later than the current date." + ); + } else { + Assert.equal( + entry.visits[index].date.getTime(), + ChromeMigrationUtils.chromeTimeToDate( + urlInfo.visits[index].visit_time, + new Date() + ).getTime(), + "Should have the correct date" + ); + } + } +} + +function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryMaster`); + file.copyTo(file.parent, "History"); +} + +function removeHistoryFile() { + let file = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST) { + throw ex; + } + } +} + +add_task(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function test_import() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + // Update to ~10 days ago since the date can't be too old or Places may expire it. + const pastDate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 10); + const pastChromeTime = ChromeMigrationUtils.dateToChromeTime(pastDate); + await setVisitTimes(pastChromeTime); + setExpectedVisitTimes(pastChromeTime); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo); + } +}); + +add_task(async function test_import_future_date() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + const futureDate = new Date().getTime() + 6000 * 60 * 24; + await setVisitTimes(ChromeMigrationUtils.dateToChromeTime(futureDate)); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo, true); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js new file mode 100644 index 0000000000..48d1934140 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js @@ -0,0 +1,379 @@ +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const TEST_LOGINS = [ + { + id: 1, // id of the row in the chrome login db + username: "username", + password: "password", + origin: "https://c9.io", + formActionOrigin: "https://c9.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, + { + id: 2, + username: "username@gmail.com", + password: "password2", + origin: "https://accounts.google.com", + formActionOrigin: "https://accounts.google.com", + httpRealm: null, + usernameField: "Email", + passwordField: "Passwd", + timeCreated: 1437418446598, + timePasswordChanged: 1437418446598, + timesUsed: 6, + }, + { + id: 3, + username: "username", + password: "password3", + origin: "https://www.facebook.com", + formActionOrigin: "https://www.facebook.com", + httpRealm: null, + usernameField: "email", + passwordField: "pass", + timeCreated: 1437418478851, + timePasswordChanged: 1437418478851, + timesUsed: 1, + }, + { + id: 4, + username: "user", + password: "اقرأPÀßwörd", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "me@kennethreitz.com", // Digest auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787462368, + timePasswordChanged: 1437787462368, + timesUsed: 1, + }, + { + id: 5, + username: "buser", + password: "bpassword", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "Fake Realm", // Basic auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787539233, + timePasswordChanged: 1437787539233, + timesUsed: 1, + }, + { + id: 6, + username: "username", + password: "password6", + origin: "https://www.example.com", + formActionOrigin: "", // NULL `action_url` + httpRealm: null, + usernameField: "", + passwordField: "pass", + timeCreated: 1557291348878, + timePasswordChanged: 1557291348878, + timesUsed: 1, + }, + { + id: 7, + version: "v10", + username: "username", + password: "password", + origin: "https://v10.io", + formActionOrigin: "https://v10.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, +]; + +var loginCrypto; +var dbConn; + +async function promiseSetPassword(login) { + let encryptedString = await loginCrypto.encryptData( + login.password, + login.version + ); + info(`promiseSetPassword: ${encryptedString}`); + let passwordValue = new Uint8Array( + loginCrypto.stringToArray(encryptedString) + ); + return dbConn.execute( + `UPDATE logins + SET password_value = :password_value + WHERE rowid = :rowid + `, + { password_value: passwordValue, rowid: login.id } + ); +} + +function checkLoginsAreEqual(passwordManagerLogin, chromeLogin, id) { + passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo); + + Assert.equal( + passwordManagerLogin.username, + chromeLogin.username, + "The two logins ID " + id + " have the same username" + ); + Assert.equal( + passwordManagerLogin.password, + chromeLogin.password, + "The two logins ID " + id + " have the same password" + ); + Assert.equal( + passwordManagerLogin.origin, + chromeLogin.origin, + "The two logins ID " + id + " have the same origin" + ); + Assert.equal( + passwordManagerLogin.formActionOrigin, + chromeLogin.formActionOrigin, + "The two logins ID " + id + " have the same formActionOrigin" + ); + Assert.equal( + passwordManagerLogin.httpRealm, + chromeLogin.httpRealm, + "The two logins ID " + id + " have the same httpRealm" + ); + Assert.equal( + passwordManagerLogin.usernameField, + chromeLogin.usernameField, + "The two logins ID " + id + " have the same usernameElement" + ); + Assert.equal( + passwordManagerLogin.passwordField, + chromeLogin.passwordField, + "The two logins ID " + id + " have the same passwordElement" + ); + Assert.equal( + passwordManagerLogin.timeCreated, + chromeLogin.timeCreated, + "The two logins ID " + id + " have the same timeCreated" + ); + Assert.equal( + passwordManagerLogin.timePasswordChanged, + chromeLogin.timePasswordChanged, + "The two logins ID " + id + " have the same timePasswordChanged" + ); + Assert.equal( + passwordManagerLogin.timesUsed, + chromeLogin.timesUsed, + "The two logins ID " + id + " have the same timesUsed" + ); +} + +function generateDifferentLogin(login) { + let newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + + newLogin.init( + login.origin, + login.formActionOrigin, + null, + login.username, + login.password + 1, + login.usernameField + 1, + login.passwordField + 1 + ); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + newLogin.timeCreated = login.timeCreated + 1; + newLogin.timePasswordChanged = login.timePasswordChanged + 1; + newLogin.timesUsed = login.timesUsed + 1; + return newLogin; +} + +add_task(async function setup() { + let dirSvcPath; + let pathId; + let profilePathSegments; + + // Use a mock service and account name to avoid a Keychain auth. prompt that + // would block the test from finishing if Chrome has already created a matching + // Keychain entry. This allows us to still exercise the keychain lookup code. + // The mock encryption passphrase is used when the Keychain item isn't found. + let mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.import( + "resource:///modules/ChromeMacOSLoginCrypto.jsm" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + dirSvcPath = "Library/"; + pathId = "ULibDir"; + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + "Login Data", + ]; + } else if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.import( + "resource:///modules/ChromeWindowsLoginCrypto.jsm" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + dirSvcPath = "AppData/Local/"; + pathId = "LocalAppData"; + profilePathSegments = [ + "Google", + "Chrome", + "User Data", + "Default", + "Login Data", + ]; + } else { + throw new Error("Not implemented"); + } + let dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + // We don't import osfile.jsm until after registering the fake path, because + // importing osfile will sometimes greedily fetch certain path identifiers + // from the dir service, which means they get cached, which means we can't + // register a fake path for them anymore. + const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + info(OS.Path.join(dirSvcFile.path, ...profilePathSegments)); + let loginDataFilePath = OS.Path.join(dirSvcFile.path, ...profilePathSegments); + dbConn = await Sqlite.openConnection({ path: loginDataFilePath }); + + if (AppConstants.platform == "macosx") { + let migrator = await MigrationUtils.getMigrator("chrome"); + Object.assign(migrator.wrappedJSObject, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + if (loginCrypto.finalize) { + loginCrypto.finalize(); + } + return dbConn.close(); + }); +}); + +add_task(async function test_importIntoEmptyDB() { + for (let login of TEST_LOGINS) { + await promiseSetPassword(login); + } + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check login count after importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < TEST_LOGINS.length; i++) { + checkLoginsAreEqual(logins[i], TEST_LOGINS[i], i + 1); + } +}); + +// Test that existing logins for the same primary key don't get overwritten +add_task(async function test_importExistingLogins() { + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.logins.removeAllUserFacingLogins(); + let logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins after removing all of them" + ); + + let newLogins = []; + + // Create 3 new logins that are different but where the key properties are still the same. + for (let i = 0; i < 3; i++) { + newLogins.push(generateDifferentLogin(TEST_LOGINS[i])); + Services.logins.addLogin(newLogins[i]); + } + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + newLogins.length, + "Check login count after the insertion" + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check there are still the same number of logins after re-importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js new file mode 100644 index 0000000000..505e8219fc --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js @@ -0,0 +1,83 @@ +"use strict"; + +/** + * Ensure that there is no dialog for OS crypto that blocks a migration when + * importing from an empty Login Data DB. + */ + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +const PROFILE = { + id: "Default", + name: "Person With No Data", +}; + +add_task(async function setup() { + let dirSvcPath; + let pathId; + + // Use a mock service and account name to avoid a Keychain auth. prompt that + // would block the test from finishing if Chrome has already created a matching + // Keychain entry. This allows us to still exercise the keychain lookup code. + // The mock encryption passphrase is used when the Keychain item isn't found. + let mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + dirSvcPath = "LibraryWithNoData/"; + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + dirSvcPath = "AppData/LocalWithNoData/"; + pathId = "LocalAppData"; + } else { + throw new Error("Not implemented"); + } + let dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + if (AppConstants.platform == "macosx") { + let migrator = await MigrationUtils.getMigrator("chrome"); + Object.assign(migrator.wrappedJSObject, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + // No `_keychainMockPassphrase` as we don't want to mock the OS dialog as + // it shouldn't appear. + }); + } + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + }); +}); + +add_task(async function test_importEmptyDBWithoutAuthPrompts() { + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. If an OS dialog (e.g. Keychain) blocks this the test + // would timeout. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "Check login count after importing the data"); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js new file mode 100644 index 0000000000..0ebd1b1fff --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js @@ -0,0 +1,791 @@ +"use strict"; + +const { ctypes } = ChromeUtils.import("resource://gre/modules/ctypes.jsm"); +let eseBackStage = ChromeUtils.import( + "resource:///modules/ESEDBReader.jsm", + null +); +let ESE = eseBackStage.ESE; +let KERNEL = eseBackStage.KERNEL; +let gLibs = eseBackStage.gLibs; +let COLUMN_TYPES = eseBackStage.COLUMN_TYPES; +let declareESEFunction = eseBackStage.declareESEFunction; +let loadLibraries = eseBackStage.loadLibraries; + +let gESEInstanceCounter = 1; + +ESE.JET_COLUMNCREATE_W = new ctypes.StructType("JET_COLUMNCREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szColumnName: ESE.JET_PCWSTR }, + { coltyp: ESE.JET_COLTYP }, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { pvDefault: ctypes.voidptr_t }, + { cbDefault: ctypes.unsigned_long }, + { cp: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { err: ESE.JET_ERR }, +]); + +function createColumnCreationWrapper({ name, type, cbMax }) { + // We use a wrapper object because we need to be sure the JS engine won't GC + // data that we're "only" pointing to. + let wrapper = {}; + wrapper.column = new ESE.JET_COLUMNCREATE_W(); + wrapper.column.cbStruct = ESE.JET_COLUMNCREATE_W.size; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(name.length + 1); + wrapper.name.value = String(name); + wrapper.column.szColumnName = wrapper.name; + wrapper.column.coltyp = type; + let fallback = 0; + switch (type) { + case COLUMN_TYPES.JET_coltypText: + fallback = 255; + // Intentional fall-through + case COLUMN_TYPES.JET_coltypLongText: + wrapper.column.cbMax = cbMax || fallback || 64 * 1024; + break; + case COLUMN_TYPES.JET_coltypGUID: + wrapper.column.cbMax = 16; + break; + case COLUMN_TYPES.JET_coltypBit: + wrapper.column.cbMax = 1; + break; + case COLUMN_TYPES.JET_coltypLongLong: + wrapper.column.cbMax = 8; + break; + default: + throw new Error("Unknown column type!"); + } + + wrapper.column.columnid = new ESE.JET_COLUMNID(); + wrapper.column.grbit = 0; + wrapper.column.pvDefault = null; + wrapper.column.cbDefault = 0; + wrapper.column.cp = 0; + + return wrapper; +} + +// "forward declarations" of indexcreate and setinfo structs, which we don't use. +ESE.JET_INDEXCREATE = new ctypes.StructType("JET_INDEXCREATE"); +ESE.JET_SETINFO = new ctypes.StructType("JET_SETINFO"); + +ESE.JET_TABLECREATE_W = new ctypes.StructType("JET_TABLECREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szTableName: ESE.JET_PCWSTR }, + { szTemplateTableName: ESE.JET_PCWSTR }, + { ulPages: ctypes.unsigned_long }, + { ulDensity: ctypes.unsigned_long }, + { rgcolumncreate: ESE.JET_COLUMNCREATE_W.ptr }, + { cColumns: ctypes.unsigned_long }, + { rgindexcreate: ESE.JET_INDEXCREATE.ptr }, + { cIndexes: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { tableid: ESE.JET_TABLEID }, + { cCreated: ctypes.unsigned_long }, +]); + +function createTableCreationWrapper(tableName, columns) { + let wrapper = {}; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(tableName.length + 1); + wrapper.name.value = String(tableName); + wrapper.table = new ESE.JET_TABLECREATE_W(); + wrapper.table.cbStruct = ESE.JET_TABLECREATE_W.size; + wrapper.table.szTableName = wrapper.name; + wrapper.table.szTemplateTableName = null; + wrapper.table.ulPages = 1; + wrapper.table.ulDensity = 0; + let columnArrayType = ESE.JET_COLUMNCREATE_W.array(columns.length); + wrapper.columnAry = new columnArrayType(); + wrapper.table.rgcolumncreate = wrapper.columnAry.addressOfElement(0); + wrapper.table.cColumns = columns.length; + wrapper.columns = []; + for (let i = 0; i < columns.length; i++) { + let column = columns[i]; + let columnWrapper = createColumnCreationWrapper(column); + wrapper.columnAry.addressOfElement(i).contents = columnWrapper.column; + wrapper.columns.push(columnWrapper); + } + wrapper.table.rgindexcreate = null; + wrapper.table.cIndexes = 0; + return wrapper; +} + +function convertValueForWriting(value, valueType) { + let buffer; + let valueOfValueType = ctypes.UInt64.lo(valueType); + switch (valueOfValueType) { + case COLUMN_TYPES.JET_coltypLongLong: + if (value instanceof Date) { + buffer = new KERNEL.FILETIME(); + let sysTime = new KERNEL.SYSTEMTIME(); + sysTime.wYear = value.getUTCFullYear(); + sysTime.wMonth = value.getUTCMonth() + 1; + sysTime.wDay = value.getUTCDate(); + sysTime.wHour = value.getUTCHours(); + sysTime.wMinute = value.getUTCMinutes(); + sysTime.wSecond = value.getUTCSeconds(); + sysTime.wMilliseconds = value.getUTCMilliseconds(); + let rv = KERNEL.SystemTimeToFileTime( + sysTime.address(), + buffer.address() + ); + if (!rv) { + throw new Error("Failed to get FileTime."); + } + return [buffer, KERNEL.FILETIME.size]; + } + throw new Error("Unrecognized value for longlong column"); + case COLUMN_TYPES.JET_coltypLongText: + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + buffer = new wchar_tArray(value.length + 1); + buffer.value = String(value); + return [buffer, buffer.length * 2]; + case COLUMN_TYPES.JET_coltypBit: + buffer = new ctypes.uint8_t(); + // Bizarre boolean values, but whatever: + buffer.value = value ? 255 : 0; + return [buffer, 1]; + case COLUMN_TYPES.JET_coltypGUID: + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(16); + let j = 0; + for (let i = 0; i < value.length; i++) { + if (!/[0-9a-f]/i.test(value[i])) { + continue; + } + let byteAsHex = value.substr(i, 2); + buffer[j++] = parseInt(byteAsHex, 16); + i++; + } + return [buffer, 16]; + } + + throw new Error("Unknown type " + valueType); +} + +let initializedESE = false; + +let eseDBWritingHelpers = { + setupDB(dbFile, tables) { + if (!initializedESE) { + initializedESE = true; + loadLibraries(); + + KERNEL.SystemTimeToFileTime = gLibs.kernel.declare( + "SystemTimeToFileTime", + ctypes.winapi_abi, + ctypes.bool, + KERNEL.SYSTEMTIME.ptr, + KERNEL.FILETIME.ptr + ); + + declareESEFunction( + "CreateDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "CreateTableColumnIndexW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_TABLECREATE_W.ptr + ); + declareESEFunction("BeginTransaction", ESE.JET_SESID); + declareESEFunction("CommitTransaction", ESE.JET_SESID, ESE.JET_GRBIT); + declareESEFunction( + "PrepareUpdate", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.unsigned_long + ); + declareESEFunction( + "Update", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr + ); + declareESEFunction( + "SetColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_SETINFO.ptr + ); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + 8192, + null + ); + } + + let rootPath = dbFile.parent.path + "\\"; + let logPath = rootPath + "LogFiles\\"; + + try { + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbwriter-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + logPath + ); + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + this._dbId = new ESE.JET_DBID(); + this._dbPath = rootPath + "spartan.edb"; + ESE.CreateDatabaseW( + this._sessionId, + this._dbPath, + null, + this._dbId.address(), + 0 + ); + this._opened = this._attached = true; + + for (let [tableName, data] of tables) { + let { rows, columns } = data; + let tableCreationWrapper = createTableCreationWrapper( + tableName, + columns + ); + ESE.CreateTableColumnIndexW( + this._sessionId, + this._dbId, + tableCreationWrapper.table.address() + ); + this._tableId = tableCreationWrapper.table.tableid; + + let columnIdMap = new Map(); + if (rows.length) { + // Iterate over the struct we passed into ESENT because they have the + // created column ids. + let columnCount = ctypes.UInt64.lo( + tableCreationWrapper.table.cColumns + ); + let columnsPassed = tableCreationWrapper.table.rgcolumncreate; + for (let i = 0; i < columnCount; i++) { + let column = columnsPassed.contents; + columnIdMap.set(column.szColumnName.readString(), column); + columnsPassed = columnsPassed.increment(); + } + ESE.ManualMove( + this._sessionId, + this._tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + ESE.BeginTransaction(this._sessionId); + for (let row of rows) { + ESE.PrepareUpdate( + this._sessionId, + this._tableId, + 0 /* JET_prepInsert */ + ); + for (let columnName in row) { + let col = columnIdMap.get(columnName); + let colId = col.columnid; + let [val, valSize] = convertValueForWriting( + row[columnName], + col.coltyp + ); + /* JET_bitSetOverwriteLV */ + ESE.SetColumn( + this._sessionId, + this._tableId, + colId, + val.address(), + valSize, + 4, + null + ); + } + let actualBookmarkSize = new ctypes.unsigned_long(); + ESE.Update( + this._sessionId, + this._tableId, + null, + 0, + actualBookmarkSize.address() + ); + } + ESE.CommitTransaction( + this._sessionId, + 0 /* JET_bitWaitLastLevel0Commit */ + ); + } + } + } finally { + try { + this._close(); + } catch (ex) { + Cu.reportError(ex); + } + } + }, + + _close() { + if (this._tableId) { + ESE.FailSafeCloseTable(this._sessionId, this._tableId); + delete this._tableId; + } + if (this._opened) { + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + this._opened = false; + } + if (this._attached) { + ESE.FailSafeDetachDatabaseW(this._sessionId, this._dbPath); + this._attached = false; + } + if (this._sessionCreated) { + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, +}; + +add_task(async function() { + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("fx-xpcshell-edge-db"); + tempFile.createUnique(tempFile.DIRECTORY_TYPE, 0o600); + + let db = tempFile.clone(); + db.append("spartan.edb"); + + let logs = tempFile.clone(); + logs.append("LogFiles"); + logs.create(tempFile.DIRECTORY_TYPE, 0o600); + + let creationDate = new Date(Date.now() - 5000); + const kEdgeMenuParent = "62d07e2b-5f0d-4e41-8426-5f5ec9717beb"; + let bookmarkReferenceItems = [ + { + URL: "http://www.mozilla.org/", + Title: "Mozilla", + DateUpdated: new Date(creationDate.valueOf() + 100), + ItemId: "1c00c10a-15f6-4618-92dd-22575102a4da", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Folder", + DateUpdated: new Date(creationDate.valueOf() + 200), + ItemId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in folder", + URL: "http://www.iteminfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 300), + ItemId: "c295ddaf-04a1-424a-866c-0ebde011e7c8", + ParentId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Deleted folder", + DateUpdated: new Date(creationDate.valueOf() + 400), + ItemId: "a547573c-4d4d-4406-a736-5b5462d93bca", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: true, + }, + { + Title: "Deleted item", + URL: "http://www.deleteditem.org/", + DateUpdated: new Date(creationDate.valueOf() + 500), + ItemId: "37a574bb-b44b-4bbc-a414-908615536435", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: true, + }, + { + Title: "Item in deleted folder (should be in root)", + URL: "http://www.itemindeletedfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 600), + ItemId: "74dd1cc3-4c5d-471f-bccc-7bc7c72fa621", + ParentId: "a547573c-4d4d-4406-a736-5b5462d93bca", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "_Favorites_Bar_", + DateUpdated: new Date(creationDate.valueOf() + 700), + ItemId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in favorites bar", + URL: "http://www.iteminfavoritesbar.org/", + DateUpdated: new Date(creationDate.valueOf() + 800), + ItemId: "9f2b1ff8-b651-46cf-8f41-16da8bcb6791", + ParentId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + IsFolder: false, + IsDeleted: false, + }, + ]; + + let readingListReferenceItems = [ + { + Title: "Some mozilla page", + URL: "http://www.mozilla.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 900), + ItemId: "c88426fd-52a7-419d-acbc-d2310e8afebe", + IsDeleted: false, + }, + { + Title: "Some other page", + URL: "https://www.example.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 1000), + ItemId: "a35fc843-5d5a-4d1e-9be8-45214be24b5c", + IsDeleted: false, + }, + ]; + eseDBWritingHelpers.setupDB( + db, + new Map([ + [ + "Favorites", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "DateUpdated" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsFolder" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ParentId" }, + ], + rows: bookmarkReferenceItems, + }, + ], + [ + "ReadingList", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "AddedDate" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + ], + rows: readingListReferenceItems, + }, + ], + ]) + ); + + let migrator = Cc[ + "@mozilla.org/profile/migrator;1?app=browser&type=edge" + ].createInstance(Ci.nsIBrowserProfileMigrator); + let bookmarksMigrator = migrator.wrappedJSObject.getBookmarksMigratorForTesting( + db + ); + Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created"); + + let source = await MigrationUtils.getLocalizedString("source-name-edge"); + let sourceLabel = await MigrationUtils.getLocalizedString( + "imported-bookmarks-source", + { source } + ); + + let seenBookmarks = []; + let listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + if (title.startsWith("Deleted")) { + ok(false, "Should not see deleted items being bookmarked!"); + } + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let migrateResult = await new Promise(resolve => + bookmarksMigrator.migrate(resolve) + ).catch(ex => { + Cu.reportError(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 5, + "Should have seen 5 items being bookmarked." + ); + Assert.equal( + seenBookmarks.filter(bm => bm.title != sourceLabel).length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items except for 'From Microsoft Edge' folders" + ); + + let menuParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.menuGuid + ); + Assert.equal( + menuParents.length, + 3, + "Bookmarks are added to the menu without a folder" + ); + let toolbarParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.toolbarGuid + ); + Assert.equal( + toolbarParents.length, + 1, + "Should have a single item added to the toolbar" + ); + let menuParentGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarParentGuid = PlacesUtils.bookmarks.toolbarGuid; + + let expectedTitlesInMenu = bookmarkReferenceItems + .filter(item => item.ParentId == kEdgeMenuParent) + .map(item => item.Title); + // Hacky, but seems like much the simplest way: + expectedTitlesInMenu.push("Item in deleted folder (should be in root)"); + let expectedTitlesInToolbar = bookmarkReferenceItems + .filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf") + .map(item => item.Title); + + let importParentFolderName = sourceLabel; + + for (let bookmark of seenBookmarks) { + let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title); + let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title); + if ( + bookmark.title == "Folder" || + bookmark.title == importParentFolderName + ) { + Assert.equal( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should be a folder" + ); + } else { + Assert.notEqual( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should not be a folder" + ); + } + + if (shouldBeInMenu) { + Assert.equal( + bookmark.parentGuid, + menuParentGuid, + "Item '" + bookmark.title + "' should be in menu" + ); + } else if (shouldBeInToolbar) { + Assert.equal( + bookmark.parentGuid, + toolbarParentGuid, + "Item '" + bookmark.title + "' should be in toolbar" + ); + } else if ( + bookmark.guid == menuParentGuid || + bookmark.guid == toolbarParentGuid + ) { + Assert.ok( + true, + "Expect toolbar and menu folders to not be in menu or toolbar" + ); + } else { + // Bit hacky, but we do need to check this. + Assert.equal( + bookmark.title, + "Item in folder", + "Subfoldered item shouldn't be in menu or toolbar" + ); + let parent = seenBookmarks.find( + maybeParent => maybeParent.guid == bookmark.parentGuid + ); + Assert.equal( + parent && parent.title, + "Folder", + "Subfoldered item should be in subfolder labeled 'Folder'" + ); + } + + let dbItem = bookmarkReferenceItems.find( + someItem => bookmark.title == someItem.Title + ); + if (!dbItem) { + Assert.equal( + bookmark.title, + importParentFolderName, + "Only the extra layer of folders isn't in the input we stuck in the DB." + ); + Assert.ok( + [menuParentGuid, toolbarParentGuid].includes(bookmark.guid), + "This item should be one of the containers" + ); + } else { + Assert.equal(dbItem.URL || "", bookmark.url, "URL is correct"); + Assert.equal( + dbItem.DateUpdated.valueOf(), + new Date(bookmark.dateAdded).valueOf(), + "Date added is correct" + ); + } + } + + MigrationUtils._importQuantities.bookmarks = 0; + seenBookmarks = []; + listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let readingListMigrator = migrator.wrappedJSObject.getReadingListMigratorForTesting( + db + ); + Assert.ok(readingListMigrator.exists, "Should recognize db we just created"); + migrateResult = await new Promise(resolve => + readingListMigrator.migrate(resolve) + ).catch(ex => { + Cu.reportError(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 3, + "Should have seen 3 items being bookmarked (2 items + 1 folder)." + ); + Assert.equal( + seenBookmarks.filter(bm => bm.title != sourceLabel).length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items except for 'From Microsoft Edge' folders" + ); + let readingListContainerLabel = await MigrationUtils.getLocalizedString( + "imported-edge-reading-list" + ); + + for (let bookmark of seenBookmarks) { + if (readingListContainerLabel == bookmark.title) { + continue; + } + let referenceItem = readingListReferenceItems.find( + item => item.Title == bookmark.title + ); + Assert.ok(referenceItem, "Should have imported what we expected"); + Assert.equal(referenceItem.URL, bookmark.url, "Should have the right URL"); + readingListReferenceItems.splice( + readingListReferenceItems.findIndex(item => item.Title == bookmark.title), + 1 + ); + } + Assert.ok( + !readingListReferenceItems.length, + "Should have seen all expected items." + ); +}); diff --git a/browser/components/migration/tests/unit/test_IE7_passwords.js b/browser/components/migration/tests/unit/test_IE7_passwords.js new file mode 100644 index 0000000000..14acf5ad33 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE7_passwords.js @@ -0,0 +1,1369 @@ +"use strict"; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "OSCrypto", + "resource://gre/modules/OSCrypto.jsm" +); + +const IE7_FORM_PASSWORDS_MIGRATOR_NAME = "IE7FormPasswords"; +const LOGINS_KEY = + "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2"; +const EXTENSION = "-backup"; +const TESTED_WEBSITES = { + twitter: { + uri: makeURI("https://twitter.com"), + hash: "A89D42BC6406E27265B1AD0782B6F376375764A301", + data: [ + 12, + 0, + 0, + 0, + 56, + 0, + 0, + 0, + 38, + 0, + 0, + 0, + 87, + 73, + 67, + 75, + 24, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 68, + 36, + 67, + 124, + 118, + 212, + 208, + 1, + 8, + 0, + 0, + 0, + 18, + 0, + 0, + 0, + 68, + 36, + 67, + 124, + 118, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 97, + 0, + 98, + 0, + 99, + 0, + 100, + 0, + 101, + 0, + 102, + 0, + 103, + 0, + 104, + 0, + 0, + 0, + 49, + 0, + 50, + 0, + 51, + 0, + 52, + 0, + 53, + 0, + 54, + 0, + 55, + 0, + 56, + 0, + 57, + 0, + 0, + 0, + ], + logins: [ + { + username: "abcdefgh", + password: "123456789", + origin: "https://twitter.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439325854000, + timeLastUsed: 1439325854000, + timePasswordChanged: 1439325854000, + timesUsed: 1, + }, + ], + }, + facebook: { + uri: makeURI("https://www.facebook.com/"), + hash: "EF44D3E034009CB0FD1B1D81A1FF3F3335213BD796", + data: [ + 12, + 0, + 0, + 0, + 152, + 0, + 0, + 0, + 160, + 0, + 0, + 0, + 87, + 73, + 67, + 75, + 24, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 88, + 182, + 125, + 18, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 20, + 0, + 0, + 0, + 88, + 182, + 125, + 18, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 40, + 0, + 0, + 0, + 134, + 65, + 33, + 37, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 60, + 0, + 0, + 0, + 134, + 65, + 33, + 37, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 80, + 0, + 0, + 0, + 45, + 242, + 246, + 62, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 100, + 0, + 0, + 0, + 45, + 242, + 246, + 62, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 120, + 0, + 0, + 0, + 28, + 10, + 193, + 80, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 140, + 0, + 0, + 0, + 28, + 10, + 193, + 80, + 121, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 117, + 0, + 115, + 0, + 101, + 0, + 114, + 0, + 110, + 0, + 97, + 0, + 109, + 0, + 101, + 0, + 48, + 0, + 0, + 0, + 112, + 0, + 97, + 0, + 115, + 0, + 115, + 0, + 119, + 0, + 111, + 0, + 114, + 0, + 100, + 0, + 48, + 0, + 0, + 0, + 117, + 0, + 115, + 0, + 101, + 0, + 114, + 0, + 110, + 0, + 97, + 0, + 109, + 0, + 101, + 0, + 49, + 0, + 0, + 0, + 112, + 0, + 97, + 0, + 115, + 0, + 115, + 0, + 119, + 0, + 111, + 0, + 114, + 0, + 100, + 0, + 49, + 0, + 0, + 0, + 117, + 0, + 115, + 0, + 101, + 0, + 114, + 0, + 110, + 0, + 97, + 0, + 109, + 0, + 101, + 0, + 50, + 0, + 0, + 0, + 112, + 0, + 97, + 0, + 115, + 0, + 115, + 0, + 119, + 0, + 111, + 0, + 114, + 0, + 100, + 0, + 50, + 0, + 0, + 0, + 117, + 0, + 115, + 0, + 101, + 0, + 114, + 0, + 110, + 0, + 97, + 0, + 109, + 0, + 101, + 0, + 51, + 0, + 0, + 0, + 112, + 0, + 97, + 0, + 115, + 0, + 115, + 0, + 119, + 0, + 111, + 0, + 114, + 0, + 100, + 0, + 51, + 0, + 0, + 0, + ], + logins: [ + { + username: "username0", + password: "password0", + origin: "https://www.facebook.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439326966000, + timeLastUsed: 1439326966000, + timePasswordChanged: 1439326966000, + timesUsed: 1, + }, + { + username: "username1", + password: "password1", + origin: "https://www.facebook.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439326997000, + timeLastUsed: 1439326997000, + timePasswordChanged: 1439326997000, + timesUsed: 1, + }, + { + username: "username2", + password: "password2", + origin: "https://www.facebook.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439327040000, + timeLastUsed: 1439327040000, + timePasswordChanged: 1439327040000, + timesUsed: 1, + }, + { + username: "username3", + password: "password3", + origin: "https://www.facebook.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439327070000, + timeLastUsed: 1439327070000, + timePasswordChanged: 1439327070000, + timesUsed: 1, + }, + ], + }, + live: { + uri: makeURI("https://login.live.com/"), + hash: "7B506F2D6B81D939A8E0456F036EE8970856FF705E", + data: [ + 12, + 0, + 0, + 0, + 56, + 0, + 0, + 0, + 44, + 0, + 0, + 0, + 87, + 73, + 67, + 75, + 24, + 0, + 0, + 0, + 2, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 212, + 17, + 219, + 140, + 148, + 212, + 208, + 1, + 9, + 0, + 0, + 0, + 20, + 0, + 0, + 0, + 212, + 17, + 219, + 140, + 148, + 212, + 208, + 1, + 11, + 0, + 0, + 0, + 114, + 0, + 105, + 0, + 97, + 0, + 100, + 0, + 104, + 0, + 49, + 6, + 74, + 6, + 39, + 6, + 54, + 6, + 0, + 0, + 39, + 6, + 66, + 6, + 49, + 6, + 35, + 6, + 80, + 0, + 192, + 0, + 223, + 0, + 119, + 0, + 246, + 0, + 114, + 0, + 100, + 0, + 0, + 0, + ], + logins: [ + { + username: "riadhرياض", + password: "اقرأPÀßwörd", + origin: "https://login.live.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439338767000, + timeLastUsed: 1439338767000, + timePasswordChanged: 1439338767000, + timesUsed: 1, + }, + ], + }, + reddit: { + uri: makeURI("http://www.reddit.com/"), + hash: "B644028D1C109A91EC2C4B9D1F145E55A1FAE42065", + data: [ + 12, + 0, + 0, + 0, + 152, + 0, + 0, + 0, + 212, + 0, + 0, + 0, + 87, + 73, + 67, + 75, + 24, + 0, + 0, + 0, + 8, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 32, + 8, + 234, + 114, + 153, + 212, + 208, + 1, + 1, + 0, + 0, + 0, + 4, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 6, + 0, + 0, + 0, + 97, + 93, + 131, + 116, + 153, + 212, + 208, + 1, + 3, + 0, + 0, + 0, + 14, + 0, + 0, + 0, + 97, + 93, + 131, + 116, + 153, + 212, + 208, + 1, + 16, + 0, + 0, + 0, + 48, + 0, + 0, + 0, + 88, + 150, + 78, + 174, + 153, + 212, + 208, + 1, + 4, + 0, + 0, + 0, + 58, + 0, + 0, + 0, + 88, + 150, + 78, + 174, + 153, + 212, + 208, + 1, + 29, + 0, + 0, + 0, + 118, + 0, + 0, + 0, + 79, + 102, + 137, + 34, + 154, + 212, + 208, + 1, + 15, + 0, + 0, + 0, + 150, + 0, + 0, + 0, + 79, + 102, + 137, + 34, + 154, + 212, + 208, + 1, + 30, + 0, + 0, + 0, + 97, + 0, + 0, + 0, + 0, + 0, + 252, + 140, + 173, + 138, + 146, + 48, + 0, + 0, + 66, + 0, + 105, + 0, + 116, + 0, + 116, + 0, + 101, + 0, + 32, + 0, + 98, + 0, + 101, + 0, + 115, + 0, + 116, + 0, + 228, + 0, + 116, + 0, + 105, + 0, + 103, + 0, + 101, + 0, + 110, + 0, + 0, + 0, + 205, + 145, + 110, + 127, + 198, + 91, + 1, + 120, + 0, + 0, + 31, + 4, + 48, + 4, + 64, + 4, + 62, + 4, + 59, + 4, + 76, + 4, + 32, + 0, + 67, + 4, + 65, + 4, + 63, + 4, + 53, + 4, + 72, + 4, + 61, + 4, + 62, + 4, + 32, + 0, + 65, + 4, + 49, + 4, + 64, + 4, + 62, + 4, + 72, + 4, + 53, + 4, + 61, + 4, + 46, + 0, + 32, + 0, + 18, + 4, + 62, + 4, + 57, + 4, + 66, + 4, + 56, + 4, + 0, + 0, + 40, + 6, + 51, + 6, + 69, + 6, + 32, + 0, + 39, + 6, + 68, + 6, + 68, + 6, + 71, + 6, + 32, + 0, + 39, + 6, + 68, + 6, + 49, + 6, + 45, + 6, + 69, + 6, + 70, + 6, + 0, + 0, + 118, + 0, + 101, + 0, + 117, + 0, + 105, + 0, + 108, + 0, + 108, + 0, + 101, + 0, + 122, + 0, + 32, + 0, + 108, + 0, + 101, + 0, + 32, + 0, + 118, + 0, + 233, + 0, + 114, + 0, + 105, + 0, + 102, + 0, + 105, + 0, + 101, + 0, + 114, + 0, + 32, + 0, + 224, + 0, + 32, + 0, + 110, + 0, + 111, + 0, + 117, + 0, + 118, + 0, + 101, + 0, + 97, + 0, + 117, + 0, + 0, + 0, + ], + logins: [ + // This login is present in the data, but should be stripped out + // by the validation rules of the importer: + // { + // "username": "a", + // "password": "", + // "origin": "http://www.reddit.com", + // "formActionOrigin": "", + // "httpRealm": null, + // "usernameField": "", + // "passwordField": "" + // }, + { + username: "購読を", + password: "Bitte bestätigen", + origin: "http://www.reddit.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439340874000, + timeLastUsed: 1439340874000, + timePasswordChanged: 1439340874000, + timesUsed: 1, + }, + { + username: "重置密码", + password: "Пароль успешно сброшен. Войти", + origin: "http://www.reddit.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439340971000, + timeLastUsed: 1439340971000, + timePasswordChanged: 1439340971000, + timesUsed: 1, + }, + { + username: "بسم الله الرحمن", + password: "veuillez le vérifier à nouveau", + origin: "http://www.reddit.com", + formActionOrigin: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439341166000, + timeLastUsed: 1439341166000, + timePasswordChanged: 1439341166000, + timesUsed: 1, + }, + ], + }, +}; + +const TESTED_URLS = [ + "http://a.foo.com", + "http://b.foo.com", + "http://c.foo.com", + "http://www.test.net", + "http://www.test.net/home", + "http://www.test.net/index", + "https://a.bar.com", + "https://b.bar.com", + "https://c.bar.com", +]; + +var nsIWindowsRegKey = Ci.nsIWindowsRegKey; +var Storage2Key; + +/* + * If the key value exists, it's going to be backed up and replaced, so the value could be restored. + * Otherwise a new value is going to be created. + */ +function backupAndStore(key, name, value) { + if (key.hasValue(name)) { + // backup the the current value + let type = key.getValueType(name); + // create a new value using use the current value name followed by EXTENSION as its new name + switch (type) { + case nsIWindowsRegKey.TYPE_STRING: + key.writeStringValue(name + EXTENSION, key.readStringValue(name)); + break; + case nsIWindowsRegKey.TYPE_BINARY: + key.writeBinaryValue(name + EXTENSION, key.readBinaryValue(name)); + break; + case nsIWindowsRegKey.TYPE_INT: + key.writeIntValue(name + EXTENSION, key.readIntValue(name)); + break; + case nsIWindowsRegKey.TYPE_INT64: + key.writeInt64Value(name + EXTENSION, key.readInt64Value(name)); + break; + } + } + key.writeBinaryValue(name, value); +} + +// Remove all values where their names are members of the names array from the key of registry +function removeAllValues(key, names) { + for (let name of names) { + key.removeValue(name); + } +} + +// Restore all the backed up values +function restore(key) { + let count = key.valueCount; + let names = []; // the names of the key values + for (let i = 0; i < count; ++i) { + names.push(key.getValueName(i)); + } + + for (let name of names) { + // backed up values have EXTENSION at the end of their names + if (name.lastIndexOf(EXTENSION) == name.length - EXTENSION.length) { + let valueName = name.substr(0, name.length - EXTENSION.length); + let type = key.getValueType(name); + // create a new value using the name before the backup and removed the backed up one + switch (type) { + case nsIWindowsRegKey.TYPE_STRING: + key.writeStringValue(valueName, key.readStringValue(name)); + key.removeValue(name); + break; + case nsIWindowsRegKey.TYPE_BINARY: + key.writeBinaryValue(valueName, key.readBinaryValue(name)); + key.removeValue(name); + break; + case nsIWindowsRegKey.TYPE_INT: + key.writeIntValue(valueName, key.readIntValue(name)); + key.removeValue(name); + break; + case nsIWindowsRegKey.TYPE_INT64: + key.writeInt64Value(valueName, key.readInt64Value(name)); + key.removeValue(name); + break; + } + } + } +} + +function checkLoginsAreEqual(passwordManagerLogin, IELogin, id) { + passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo); + for (let attribute in IELogin) { + Assert.equal( + passwordManagerLogin[attribute], + IELogin[attribute], + "The two logins ID " + id + " have the same " + attribute + ); + } +} + +function createRegistryPath(path) { + let loginPath = path.split("\\"); + let parentKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + let currentPath = []; + for (let currentKey of loginPath) { + parentKey.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + currentPath.join("\\"), + nsIWindowsRegKey.ACCESS_ALL + ); + + if (!parentKey.hasChild(currentKey)) { + parentKey.createChild(currentKey, 0); + } + currentPath.push(currentKey); + parentKey.close(); + } +} + +function getFirstResourceOfType(type) { + let migrator = Cc[ + "@mozilla.org/profile/migrator;1?app=browser&type=ie" + ].createInstance(Ci.nsISupports).wrappedJSObject; + let migrators = migrator.getResources(); + for (let m of migrators) { + if (m.name == IE7_FORM_PASSWORDS_MIGRATOR_NAME && m.type == type) { + return m; + } + } + throw new Error("failed to find the " + type + " migrator"); +} + +function makeURI(aURL) { + return Services.io.newURI(aURL); +} + +add_task(async function setup() { + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + Assert.throws( + () => getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS), + /failed to find/, + "The migrator doesn't exist for win8+" + ); + return; + } + // create the path to Storage2 in the registry if it doest exist. + createRegistryPath(LOGINS_KEY); + Storage2Key = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + Storage2Key.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + LOGINS_KEY, + nsIWindowsRegKey.ACCESS_ALL + ); + + // create a dummy value otherwise the migrator doesn't exist + if (!Storage2Key.hasValue("dummy")) { + Storage2Key.writeBinaryValue("dummy", "dummy"); + } +}); + +add_task(async function test_passwordsNotAvailable() { + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + return; + } + + let migrator = getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS); + Assert.ok(migrator.exists, "The migrator has to exist"); + let logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins at the beginning of the test" + ); + + let uris = []; // the uris of the migrated logins + for (let url of TESTED_URLS) { + uris.push(makeURI(url)); + // in this test, there is no IE login data in the registry, so after the migration, the number + // of logins in the store should be 0 + await migrator._migrateURIs(uris); + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins after doing the migration without adding values to the registry" + ); + } +}); + +add_task(async function test_passwordsAvailable() { + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + return; + } + + let crypto = new OSCrypto(); + let hashes = []; // the hashes of all migrator websites, this is going to be used for the clean up + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins after the cleanup"); + // remove all the values created in this test from the registry + removeAllValues(Storage2Key, hashes); + // restore all backed up values + restore(Storage2Key); + + // clean the dummy value + if (Storage2Key.hasValue("dummy")) { + Storage2Key.removeValue("dummy"); + } + Storage2Key.close(); + crypto.finalize(); + }); + + let migrator = getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS); + Assert.ok(migrator.exists, "The migrator has to exist"); + let logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins at the beginning of the test" + ); + + let uris = []; // the uris of the migrated logins + + let loginCount = 0; + for (let current in TESTED_WEBSITES) { + let website = TESTED_WEBSITES[current]; + // backup the current the registry value if it exists and replace the existing value/create a + // new value with the encrypted data + backupAndStore( + Storage2Key, + website.hash, + crypto.encryptData(crypto.arrayToString(website.data), website.uri.spec) + ); + Assert.ok(migrator.exists, "The migrator has to exist"); + uris.push(website.uri); + hashes.push(website.hash); + + await migrator._migrateURIs(uris); + logins = Services.logins.getAllLogins(); + // check that the number of logins in the password manager has increased as expected which means + // that all the values for the current website were imported + loginCount += website.logins.length; + Assert.equal( + logins.length, + loginCount, + "The number of logins has increased after the migration" + ); + // NB: because telemetry records any login data passed to the login manager, it + // also gets told about logins that are duplicates or invalid (for one reason + // or another) and so its counts might exceed those of the login manager itself. + Assert.greaterOrEqual( + MigrationUtils._importQuantities.logins, + loginCount, + "Telemetry quantities equal or exceed the actual import." + ); + // Reset - this normally happens at the start of a new migration, but we're calling + // the migrator directly so can't rely on that: + MigrationUtils._importQuantities.logins = 0; + + let startIndex = loginCount - website.logins.length; + // compares the imported password manager logins with their expected logins + for (let i = 0; i < website.logins.length; i++) { + checkLoginsAreEqual( + logins[startIndex + i], + website.logins[i], + " " + current + " - " + i + " " + ); + } + } +}); diff --git a/browser/components/migration/tests/unit/test_IE_bookmarks.js b/browser/components/migration/tests/unit/test_IE_bookmarks.js new file mode 100644 index 0000000000..4055797b1c --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js @@ -0,0 +1,70 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.import( + "resource:///modules/CustomizableUI.jsm" +); + +add_task(async function() { + let migrator = await MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable(), "Check migrator source"); + + // Since this test doesn't mock out the favorites, execution is dependent + // on the actual favorites stored on the local machine's IE favorites database. + // As such, we can't assert that bookmarks were migrated to both the bookmarks + // menu and the bookmarks toolbar. + let bookmarkRoots = 0; + let itemCount = 0; + let listener = events => { + for (let event of events) { + if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + if (event.parentGuid == PlacesUtils.bookmarks.toolbarGuid) { + bookmarkRoots |= + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR; + } else if (event.parentGuid == PlacesUtils.bookmarks.menuGuid) { + bookmarkRoots |= MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU; + } + info("bookmark added: " + event.parentGuid); + itemCount++; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Ensure telemetry matches actual number of imported items." + ); + await TestUtils.waitForCondition(() => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false + ).parent.FX_MIGRATION_BOOKMARKS_ROOTS; + if (!snapshot || !snapshot.ie) { + return false; + } + info(`Expected ${bookmarkRoots}, got ${snapshot.ie.sum}`); + return snapshot.ie.sum == bookmarkRoots; + }, "Wait until telemetry is updated"); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_IE_cookies.js b/browser/components/migration/tests/unit/test_IE_cookies.js new file mode 100644 index 0000000000..bb003ad5a5 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_cookies.js @@ -0,0 +1,149 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ctypes", + "resource://gre/modules/ctypes.jsm" +); + +add_task(async function() { + let migrator = await MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + // Windows versions newer than 1709 don't store cookies as files anymore, + // thus our migrators don't import anything and this test is pointless. + // In these versions the CookD folder contains a deprecated.cookie file. + let deprecatedCookie = Services.dirsvc.get("CookD", Ci.nsIFile); + deprecatedCookie.append("deprecated.cookie"); + if (deprecatedCookie.exists()) { + return; + } + + const BOOL = ctypes.bool; + const LPCTSTR = ctypes.char16_t.ptr; + const DWORD = ctypes.uint32_t; + const LPDWORD = DWORD.ptr; + + let wininet = ctypes.open("Wininet"); + + /* + BOOL InternetSetCookieW( + _In_ LPCTSTR lpszUrl, + _In_ LPCTSTR lpszCookieName, + _In_ LPCTSTR lpszCookieData + ); + */ + // NOTE: Even though MSDN documentation does not indicate a calling convention, + // InternetSetCookieW is declared in SDK headers as __stdcall but is exported + // from wininet.dll without name mangling, so it is effectively winapi_abi + let setIECookie = wininet.declare( + "InternetSetCookieW", + ctypes.winapi_abi, + BOOL, + LPCTSTR, + LPCTSTR, + LPCTSTR + ); + + /* + BOOL InternetGetCookieW( + _In_ LPCTSTR lpszUrl, + _In_ LPCTSTR lpszCookieName, + _Out_ LPCTSTR lpszCookieData, + _Inout_ LPDWORD lpdwSize + ); + */ + // NOTE: Even though MSDN documentation does not indicate a calling convention, + // InternetGetCookieW is declared in SDK headers as __stdcall but is exported + // from wininet.dll without name mangling, so it is effectively winapi_abi + let getIECookie = wininet.declare( + "InternetGetCookieW", + ctypes.winapi_abi, + BOOL, + LPCTSTR, + LPCTSTR, + LPCTSTR, + LPDWORD + ); + + // We need to randomize the cookie to avoid clashing with other cookies + // that might have been set by previous tests and not properly cleared. + let date = new Date().getDate(); + const COOKIE = { + get host() { + return new URL(this.href).host; + }, + href: `http://mycookietest.${Math.random()}.com`, + name: "testcookie", + value: "testvalue", + expiry: new Date(new Date().setDate(date + 2)), + }; + let data = ctypes.char16_t.array()(256); + let sizeRef = DWORD(256).address(); + + registerCleanupFunction(() => { + // Remove the cookie. + try { + let expired = new Date(new Date().setDate(date - 2)); + let rv = setIECookie( + COOKIE.href, + COOKIE.name, + `; expires=${expired.toUTCString()}` + ); + Assert.ok(rv, "Expired the IE cookie"); + Assert.ok( + !getIECookie(COOKIE.href, COOKIE.name, data, sizeRef), + "The cookie has been properly removed" + ); + } catch (ex) {} + + // Close the library. + try { + wininet.close(); + } catch (ex) {} + }); + + // Create the persistent cookie in IE. + let value = `${COOKIE.value}; expires=${COOKIE.expiry.toUTCString()}`; + let rv = setIECookie(COOKIE.href, COOKIE.name, value); + Assert.ok(rv, "Added a persistent IE cookie: " + value); + + // Sanity check the cookie has been created. + Assert.ok( + getIECookie(COOKIE.href, COOKIE.name, data, sizeRef), + "Found the added persistent IE cookie" + ); + info("Found cookie: " + data.readString()); + Assert.equal( + data.readString(), + `${COOKIE.name}=${COOKIE.value}`, + "Found the expected cookie" + ); + + // Sanity check that there are no cookies. + Assert.equal( + Services.cookies.countCookiesFromHost(COOKIE.host), + 0, + "There are no cookies initially" + ); + + // Migrate cookies. + await promiseMigration(migrator, MigrationUtils.resourceTypes.COOKIES); + + Assert.equal( + Services.cookies.countCookiesFromHost(COOKIE.host), + 1, + "Migrated the expected number of cookies" + ); + + // Now check the cookie details. + let cookies = Services.cookies.getCookiesFromHost(COOKIE.host, {}); + Assert.ok(cookies.length); + let foundCookie = cookies[0]; + + Assert.equal(foundCookie.name, COOKIE.name); + Assert.equal(foundCookie.value, COOKIE.value); + Assert.equal(foundCookie.host, "." + COOKIE.host); + Assert.equal(foundCookie.expiry, Math.floor(COOKIE.expiry / 1000)); +}); diff --git a/browser/components/migration/tests/unit/test_IE_history.js b/browser/components/migration/tests/unit/test_IE_history.js new file mode 100644 index 0000000000..fd803f4607 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_history.js @@ -0,0 +1,50 @@ +"use strict"; + +// These match what we add to IE via InsertIEHistory.exe. +const TEST_ENTRIES = [ + { + url: "http://www.mozilla.org/1", + title: "Mozilla HTTP Test", + }, + { + url: "https://www.mozilla.org/2", + // Test character encoding with a fox emoji: + title: "Mozilla HTTPS Test 🦊", + }, +]; + +function insertIEHistory() { + let file = do_get_file("InsertIEHistory.exe", false); + let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(file); + + let args = []; + process.run(true, args, args.length); + + Assert.ok(!process.isRunning, "Should be done running"); + Assert.equal(process.exitValue, 0, "Check exit code"); +} + +add_task(async function setup() { + await PlacesUtils.history.clear(); + + insertIEHistory(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_IE_history() { + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let { url, title } of TEST_ENTRIES) { + let entry = await PlacesUtils.history.fetch(url, { includeVisits: true }); + Assert.equal(entry.url, url, "Should have the correct URL"); + Assert.equal(entry.title, title, "Should have the correct title"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js new file mode 100644 index 0000000000..5038f33f2b --- /dev/null +++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js @@ -0,0 +1,27 @@ +"use strict"; + +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +let tmpFile = FileUtils.getDir("TmpD", [], true); +let dbConn; + +add_task(async function setup() { + tmpFile.append("TestDB"); + dbConn = await Sqlite.openConnection({ path: tmpFile.path }); + + registerCleanupFunction(() => { + dbConn.close(); + OS.File.remove(tmpFile.path); + }); +}); + +add_task(async function testgetRowsFromDBWithoutLocksRetries() { + let promise = MigrationUtils.getRowsFromDBWithoutLocks( + tmpFile.path, + "Temp DB", + "SELECT * FROM moz_temp_table" + ); + await new Promise(resolve => do_timeout(50, resolve)); + dbConn.execute("CREATE TABLE moz_temp_table (id INTEGER PRIMARY KEY)"); + await promise; +}); diff --git a/browser/components/migration/tests/unit/test_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js new file mode 100644 index 0000000000..79cbcf9203 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js @@ -0,0 +1,105 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.import( + "resource:///modules/CustomizableUI.jsm" +); + +add_task(async function() { + registerFakePath("ULibDir", do_get_file("Library/")); + + let migrator = await MigrationUtils.getMigrator("safari"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + // Wait for the imported bookmarks. We don't check that "From Safari" + // folders are created on the toolbar since the profile + // we're importing to has less than 3 bookmarks in the destination + // so a "From Safari" folder isn't created. + let expectedParents = [PlacesUtils.toolbarFolderId]; + let bookmarkRoots = 0; + let bookmarkRootMap = { + [PlacesUtils.bookmarks.toolbarGuid]: + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_TOOLBAR, + [PlacesUtils.bookmarks.menuGuid]: + MigrationUtils.SOURCE_BOOKMARK_ROOTS_BOOKMARKS_MENU, + [PlacesUtils.bookmarks.unfiledGuid]: + MigrationUtils.SOURCE_BOOKMARK_ROOTS_UNFILED, + }; + let itemCount = 0; + + let gotFolder = false; + let listener = events => { + for (let event of events) { + itemCount++; + if ( + event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK && + bookmarkRootMap[event.parentGuid] + ) { + bookmarkRoots |= bookmarkRootMap[event.parentGuid]; + } + + if ( + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER && + event.title == "Stuff" + ) { + gotFolder = true; + } + if (expectedParents.length) { + let index = expectedParents.indexOf(event.parentId); + Assert.ok(index != -1, "Found expected parent"); + expectedParents.splice(index, 1); + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(!expectedParents.length, "No more expected parents"); + Assert.ok(gotFolder, "Should have seen the folder get imported"); + Assert.equal(itemCount, 13, "Should import all 13 items."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Telemetry reporting correct." + ); + let telemetryRootsMatchesExpectations = await TestUtils.waitForCondition( + () => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false + ).parent.FX_MIGRATION_BOOKMARKS_ROOTS; + if (!snapshot || !snapshot.safari) { + return false; + } + let sum = arr => Object.values(arr).reduce((a, b) => a + b, 0); + let sumOfValues = sum(snapshot.safari.values); + info(`Expected ${bookmarkRoots}, got ${sumOfValues}`); + return sumOfValues == bookmarkRoots; + }, + "Wait until telemetry is updated" + ); + ok( + telemetryRootsMatchesExpectations, + "The value in the roots histogram should match expectations" + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js new file mode 100644 index 0000000000..0cdb779fbc --- /dev/null +++ b/browser/components/migration/tests/unit/test_fx_telemetry.js @@ -0,0 +1,265 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +function readFile(file) { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF); + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + let contents = sis.read(file.fileSize); + sis.close(); + return contents; +} + +function checkDirectoryContains(dir, files) { + print("checking " + dir.path + " - should contain " + Object.keys(files)); + let seen = new Set(); + for (let file of dir.directoryEntries) { + print("found file: " + file.path); + Assert.ok(file.leafName in files, file.leafName + " exists, but shouldn't"); + + let expectedContents = files[file.leafName]; + if (typeof expectedContents != "string") { + // it's a subdir - recurse! + Assert.ok(file.isDirectory(), "should be a subdir"); + let newDir = dir.clone(); + newDir.append(file.leafName); + checkDirectoryContains(newDir, expectedContents); + } else { + Assert.ok(!file.isDirectory(), "should be a regular file"); + let contents = readFile(file); + Assert.equal(contents, expectedContents); + } + seen.add(file.leafName); + } + let missing = []; + for (let x in files) { + if (!seen.has(x)) { + missing.push(x); + } + } + Assert.deepEqual(missing, [], "no missing files in " + dir.path); +} + +function getTestDirs() { + // we make a directory structure in a temp dir which mirrors what we are + // testing. + let tempDir = do_get_tempdir(); + let srcDir = tempDir.clone(); + srcDir.append("test_source_dir"); + srcDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let targetDir = tempDir.clone(); + targetDir.append("test_target_dir"); + targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + // no need to cleanup these dirs - the xpcshell harness will do it for us. + return [srcDir, targetDir]; +} + +function writeToFile(dir, leafName, contents) { + let file = dir.clone(); + file.append(leafName); + + let outputStream = FileUtils.openFileOutputStream(file); + outputStream.write(contents, contents.length); + outputStream.close(); +} + +function createSubDir(dir, subDirName) { + let subDir = dir.clone(); + subDir.append(subDirName); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return subDir; +} + +function promiseMigrator(name, srcDir, targetDir) { + let migrator = Cc[ + "@mozilla.org/profile/migrator;1?app=browser&type=firefox" + ].createInstance(Ci.nsISupports).wrappedJSObject; + let migrators = migrator._getResourcesInternal(srcDir, targetDir); + for (let m of migrators) { + if (m.name == name) { + return new Promise(resolve => m.migrate(resolve)); + } + } + throw new Error("failed to find the " + name + " migrator"); +} + +function promiseTelemetryMigrator(srcDir, targetDir) { + return promiseMigrator("telemetry", srcDir, targetDir); +} + +add_task(async function test_empty() { + let [srcDir, targetDir] = getTestDirs(); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true with empty directories"); + // check both are empty + checkDirectoryContains(srcDir, {}); + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_migrate_files() { + let [srcDir, targetDir] = getTestDirs(); + + // Set up datareporting files, some to copy, some not. + let stateContent = JSON.stringify({ + clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c", + }); + let sessionStateContent = "foobar 5432"; + let subDir = createSubDir(srcDir, "datareporting"); + writeToFile(subDir, "state.json", stateContent); + writeToFile(subDir, "session-state.json", sessionStateContent); + writeToFile(subDir, "other.file", "do not copy"); + + let archived = createSubDir(subDir, "archived"); + writeToFile(archived, "other.file", "do not copy"); + + // Set up FHR files, they should not be copied. + writeToFile(srcDir, "healthreport.sqlite", "do not copy"); + writeToFile(srcDir, "healthreport.sqlite-wal", "do not copy"); + subDir = createSubDir(srcDir, "healthreport"); + writeToFile(subDir, "state.json", "do not copy"); + writeToFile(subDir, "other.file", "do not copy"); + + // Perform migration. + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true with important telemetry files copied" + ); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_datareporting_not_dir() { + let [srcDir, targetDir] = getTestDirs(); + + writeToFile(srcDir, "datareporting", "I'm a file but should be a directory"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true even though the directory was a file" + ); + + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with an empty 'datareporting' subdir. + createSubDir(srcDir, "datareporting"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, { + datareporting: {}, + }); +}); + +add_task(async function test_healthreport_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with no 'datareporting' and an empty 'healthreport' subdir. + createSubDir(srcDir, "healthreport"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_many() { + let [srcDir, targetDir] = getTestDirs(); + + // Create some datareporting files. + let subDir = createSubDir(srcDir, "datareporting"); + let shouldBeCopied = "should be copied"; + writeToFile(subDir, "state.json", shouldBeCopied); + writeToFile(subDir, "session-state.json", shouldBeCopied); + writeToFile(subDir, "something.else", "should not"); + createSubDir(subDir, "emptyDir"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": shouldBeCopied, + "session-state.json": shouldBeCopied, + }, + }); +}); + +add_task(async function test_no_session_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let stateContent = "abcd984"; + writeToFile(subDir, "state.json", stateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + }, + }); +}); + +add_task(async function test_no_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have session-state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let sessionStateContent = "abcd512"; + writeToFile(subDir, "session-state.json", sessionStateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_times_migration() { + let [srcDir, targetDir] = getTestDirs(); + + // create a times.json in the source directory. + let contents = JSON.stringify({ created: 1234 }); + writeToFile(srcDir, "times.json", contents); + + let earliest = Date.now(); + let ok = await promiseMigrator("times", srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + let latest = Date.now(); + + let timesFile = targetDir.clone(); + timesFile.append("times.json"); + + let raw = readFile(timesFile); + let times = JSON.parse(raw); + Assert.ok(times.reset >= earliest && times.reset <= latest); + // and it should have left the creation time alone. + Assert.equal(times.created, 1234); +}); diff --git a/browser/components/migration/tests/unit/xpcshell.ini b/browser/components/migration/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..0e25e6bc94 --- /dev/null +++ b/browser/components/migration/tests/unit/xpcshell.ini @@ -0,0 +1,40 @@ +[DEFAULT] +head = head_migration.js +firefox-appdir = browser +skip-if = toolkit == 'android' +prefs = + browser.migrate.showBookmarksToolbarAfterMigration=true +support-files = + Library/** + AppData/** + +[test_360se_bookmarks.js] +skip-if = os != "win" +[test_Chrome_bookmarks.js] +[test_Chrome_cookies.js] +skip-if = os != "mac" # Relies on ULibDir +[test_Chrome_history.js] +skip-if = os != "mac" # Relies on ULibDir +[test_Chrome_passwords.js] +skip-if = os != "win" && os != "mac" +[test_Chrome_passwords_emptySource.js] +skip-if = os != "win" && os != "mac" +support-files = + LibraryWithNoData/** +[test_ChromeMigrationUtils.js] +[test_ChromeMigrationUtils_path.js] +[test_Edge_db_migration.js] +skip-if = os != "win" +[test_fx_telemetry.js] +[test_IE_bookmarks.js] +skip-if = !(os == "win" && bits == 64) # bug 1392396 +[test_IE_cookies.js] +skip-if = os != "win" || (os == "win" && bits == 64 && processor == "x86_64") # bug 1522818 +[test_IE_history.js] +skip-if = os != "win" +[test_IE7_passwords.js] +skip-if = os != "win" +[test_MigrationUtils_timedRetry.js] +skip-if = !debug && os == "mac" #Bug 1558330 +[test_Safari_bookmarks.js] +skip-if = os != "mac" |