diff options
Diffstat (limited to 'browser/components/migration')
47 files changed, 10434 insertions, 0 deletions
diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js new file mode 100644 index 000000000..6693f83d0 --- /dev/null +++ b/browser/components/migration/.eslintrc.js @@ -0,0 +1,82 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../.eslintrc.js" + ], + + "globals": { + "Components": true, + "dump": true, + "Iterator": true + }, + + "env": { "browser": true }, + + "rules": { + "block-scoped-var": "error", + // "brace-style": ["warn", "1tbs", {"allowSingleLine": true}], + "comma-dangle": "off", + "comma-spacing": ["warn", {"before": false, "after": true}], + "comma-style": ["warn", "last"], + // "complexity": "warn", + "consistent-return": "error", + //"curly": "error", + "dot-notation": "error", + "eol-last": "error", + "indent": ["warn", 2, {"SwitchCase": 1}], + // "key-spacing": ["warn", {"beforeColon": false, "afterColon": true}], + "keyword-spacing": "warn", + "max-nested-callbacks": ["error", 3], + "new-parens": "error", + "no-array-constructor": "error", + "no-cond-assign": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-eval": "error", + "no-extend-native": "error", + // "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-semi": "warn", + "no-fallthrough": ["error", { "commentPattern": ".*[Ii]ntentional(?:ly)?\\s+fall(?:ing)?[\\s-]*through.*" }], + "no-lonely-if": "error", + "no-mixed-spaces-and-tabs": "error", + "no-multi-spaces": "warn", + "no-multi-str": "warn", + "no-native-reassign": "error", + "no-nested-ternary": "error", + "no-redeclare": "error", + "no-return-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "warn", + "no-shadow-restricted-names": "error", + // "no-spaced-func": "warn", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-vars": ["error", { "varsIgnorePattern": "^C[ciur]$" }], + "no-with": "error", + "padded-blocks": ["warn", "never"], + "quotes": ["error", "double", { "avoidEscape": true, "allowTemplateLiterals": true }], + "semi": ["error", "always", {"omitLastInOneLineBlock": true }], + "semi-spacing": ["warn", {"before": false, "after": true}], + "space-before-blocks": ["warn", "always"], + // "space-before-function-paren": ["warn", "never"], + "space-in-parens": ["warn", "never"], + "space-infix-ops": ["warn", {"int32Hint": true}], + // "space-unary-ops": ["warn", { "words": true, "nonwords": false }], + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "yoda": "error" + } +}; + diff --git a/browser/components/migration/360seProfileMigrator.js b/browser/components/migration/360seProfileMigrator.js new file mode 100644 index 000000000..42347d542 --- /dev/null +++ b/browser/components/migration/360seProfileMigrator.js @@ -0,0 +1,328 @@ +/* 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 { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); /* globals OS */ +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); + +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", 0, 0x0000); + 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 = {}; + let sections = parser.getSections(); + while (sections.hasMore()) { + let section = sections.getNext(); + obj[section] = {}; + + let keys = parser.getKeys(section); + while (keys.hasMore()) { + let key = keys.getNext(); + 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 Task.spawn(function* () { + let idToGuid = new Map(); + let folderGuid = PlacesUtils.bookmarks.toolbarGuid; + if (!MigrationUtils.isStartupMigration) { + folderGuid = + yield MigrationUtils.createImportedBookmarksFolder("360se", folderGuid); + } + idToGuid.set(0, folderGuid); + + let connection = yield Sqlite.openConnection({ + path: this._file.path + }); + + try { + let rows = yield 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 parentGuid = idToGuid.get(parent_id) || idToGuid.get("fallback"); + if (!parentGuid) { + parentGuid = PlacesUtils.bookmarks.unfiledGuid; + if (!MigrationUtils.isStartupMigration) { + parentGuid = + yield MigrationUtils.createImportedBookmarksFolder("360se", parentGuid); + } + idToGuid.set("fallback", parentGuid); + } + + try { + if (is_folder == 1) { + let newFolderGuid = (yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title + })).guid; + + idToGuid.set(id, newFolderGuid); + } else { + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, + url, + title + }); + } + } catch (ex) { + Cu.reportError(ex); + } + } + } finally { + yield connection.close(); + } + }.bind(this)).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); + +Object.defineProperty(Qihoo360seProfileMigrator.prototype, "sourceProfiles", { + get: 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 > 0; + }); + 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 = function() { + let bookmarksPaths = this.sourceProfiles.map(({id}) => { + return OS.Path.join(this._usersDir.path, id, kBookmarksFileName); + }); + if (!bookmarksPaths.length) { + return Promise.resolve(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}"); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([Qihoo360seProfileMigrator]); diff --git a/browser/components/migration/AutoMigrate.jsm b/browser/components/migration/AutoMigrate.jsm new file mode 100644 index 000000000..b38747825 --- /dev/null +++ b/browser/components/migration/AutoMigrate.jsm @@ -0,0 +1,670 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["AutoMigrate"]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +const kAutoMigrateEnabledPref = "browser.migrate.automigrate.enabled"; +const kUndoUIEnabledPref = "browser.migrate.automigrate.ui.enabled"; + +const kAutoMigrateBrowserPref = "browser.migrate.automigrate.browser"; +const kAutoMigrateImportedItemIds = "browser.migrate.automigrate.imported-items"; + +const kAutoMigrateLastUndoPromptDateMsPref = "browser.migrate.automigrate.lastUndoPromptDateMs"; +const kAutoMigrateDaysToOfferUndoPref = "browser.migrate.automigrate.daysToOfferUndo"; + +const kAutoMigrateUndoSurveyPref = "browser.migrate.automigrate.undo-survey"; +const kAutoMigrateUndoSurveyLocalePref = "browser.migrate.automigrate.undo-survey-locales"; + +const kNotificationId = "automigration-undo"; + +Cu.import("resource:///modules/MigrationUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AsyncShutdown", + "resource://gre/modules/AsyncShutdown.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NewTabUtils", + "resource://gre/modules/NewTabUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); + +XPCOMUtils.defineLazyGetter(this, "gBrandBundle", function() { + const kBrandBundle = "chrome://branding/locale/brand.properties"; + return Services.strings.createBundle(kBrandBundle); +}); + +XPCOMUtils.defineLazyGetter(this, "gHardcodedStringBundle", function() { + const kBundleURI = "chrome://browser/content/migration/extra-migration-strings.properties"; + return Services.strings.createBundle(kBundleURI); +}); + +Cu.importGlobalProperties(["URL"]); + +/* globals kUndoStateFullPath */ +XPCOMUtils.defineLazyGetter(this, "kUndoStateFullPath", function() { + return OS.Path.join(OS.Constants.Path.profileDir, "initialMigrationMetadata.jsonlz4"); +}); + +const AutoMigrate = { + get resourceTypesToUse() { + let {BOOKMARKS, HISTORY, PASSWORDS} = Ci.nsIBrowserProfileMigrator; + return BOOKMARKS | HISTORY | PASSWORDS; + }, + + _checkIfEnabled() { + let pref = Preferences.get(kAutoMigrateEnabledPref, false); + // User-set values should take precedence: + if (Services.prefs.prefHasUserValue(kAutoMigrateEnabledPref)) { + return pref; + } + // If we're using the default value, make sure the distribution.ini + // value is taken into account even early on startup. + try { + let distributionFile = Services.dirsvc.get("XREAppDist", Ci.nsIFile); + distributionFile.append("distribution.ini"); + let parser = Cc["@mozilla.org/xpcom/ini-parser-factory;1"]. + getService(Ci.nsIINIParserFactory). + createINIParser(distributionFile); + return JSON.parse(parser.getString("Preferences", kAutoMigrateEnabledPref)); + } catch (ex) { /* ignore exceptions (file doesn't exist, invalid value, etc.) */ } + + return pref; + }, + + init() { + this.enabled = this._checkIfEnabled(); + }, + + /** + * Automatically pick a migrator and resources to migrate, + * then migrate those and start up. + * + * @throws if automatically deciding on migrators/data + * failed for some reason. + */ + migrate(profileStartup, migratorKey, profileToMigrate) { + let histogram = Services.telemetry.getHistogramById( + "FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_PROCESS_SUCCESS"); + histogram.add(0); + let {migrator, pickedKey} = this.pickMigrator(migratorKey); + histogram.add(5); + + profileToMigrate = this.pickProfile(migrator, profileToMigrate); + histogram.add(10); + + let resourceTypes = migrator.getMigrateData(profileToMigrate, profileStartup); + if (!(resourceTypes & this.resourceTypesToUse)) { + throw new Error("No usable resources were found for the selected browser!"); + } + histogram.add(15); + + let sawErrors = false; + let migrationObserver = (subject, topic) => { + if (topic == "Migration:ItemError") { + sawErrors = true; + } else if (topic == "Migration:Ended") { + histogram.add(25); + if (sawErrors) { + histogram.add(26); + } + Services.obs.removeObserver(migrationObserver, "Migration:Ended"); + Services.obs.removeObserver(migrationObserver, "Migration:ItemError"); + Services.prefs.setCharPref(kAutoMigrateBrowserPref, pickedKey); + // Save the undo history and block shutdown on that save completing. + AsyncShutdown.profileBeforeChange.addBlocker( + "AutoMigrate Undo saving", this.saveUndoState(), () => { + return {state: this._saveUndoStateTrackerForShutdown}; + }); + } + }; + + MigrationUtils.initializeUndoData(); + Services.obs.addObserver(migrationObserver, "Migration:Ended", false); + Services.obs.addObserver(migrationObserver, "Migration:ItemError", false); + migrator.migrate(this.resourceTypesToUse, profileStartup, profileToMigrate); + histogram.add(20); + }, + + /** + * Pick and return a migrator to use for automatically migrating. + * + * @param {String} migratorKey optional, a migrator key to prefer/pick. + * @returns {Object} an object with the migrator to use for migrating, as + * well as the key we eventually ended up using to obtain it. + */ + pickMigrator(migratorKey) { + if (!migratorKey) { + let defaultKey = MigrationUtils.getMigratorKeyForDefaultBrowser(); + if (!defaultKey) { + throw new Error("Could not determine default browser key to migrate from"); + } + migratorKey = defaultKey; + } + if (migratorKey == "firefox") { + throw new Error("Can't automatically migrate from Firefox."); + } + + let migrator = MigrationUtils.getMigrator(migratorKey); + if (!migrator) { + throw new Error("Migrator specified or a default was found, but the migrator object is not available (or has no data)."); + } + return {migrator, pickedKey: migratorKey}; + }, + + /** + * Pick a source profile (from the original browser) to use. + * + * @param {Migrator} migrator the migrator object to use + * @param {String} suggestedId the id of the profile to migrate, if pre-specified, or null + * @returns the profile to migrate, or null if migrating + * from the default profile. + */ + pickProfile(migrator, suggestedId) { + let profiles = migrator.sourceProfiles; + if (profiles && !profiles.length) { + throw new Error("No profile data found to migrate."); + } + if (suggestedId) { + if (!profiles) { + throw new Error("Profile specified but only a default profile found."); + } + let suggestedProfile = profiles.find(profile => profile.id == suggestedId); + if (!suggestedProfile) { + throw new Error("Profile specified was not found."); + } + return suggestedProfile; + } + if (profiles && profiles.length > 1) { + throw new Error("Don't know how to pick a profile when more than 1 profile is present."); + } + return profiles ? profiles[0] : null; + }, + + _pendingUndoTasks: false, + canUndo: Task.async(function* () { + if (this._savingPromise) { + yield this._savingPromise; + } + if (this._pendingUndoTasks) { + return false; + } + let fileExists = false; + try { + fileExists = yield OS.File.exists(kUndoStateFullPath); + } catch (ex) { + Cu.reportError(ex); + } + return fileExists; + }), + + undo: Task.async(function* () { + let browserId = Preferences.get(kAutoMigrateBrowserPref, "unknown"); + TelemetryStopwatch.startKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId); + let histogram = Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_AUTOMATED_IMPORT_UNDO"); + histogram.add(0); + if (!(yield this.canUndo())) { + histogram.add(5); + throw new Error("Can't undo!"); + } + + this._pendingUndoTasks = true; + this._removeNotificationBars(); + histogram.add(10); + + let readPromise = OS.File.read(kUndoStateFullPath, { + encoding: "utf-8", + compression: "lz4", + }); + let stateData = this._dejsonifyUndoState(yield readPromise); + histogram.add(12); + + this._errorMap = {bookmarks: 0, visits: 0, logins: 0}; + let reportErrorTelemetry = (type) => { + let histogramId = `FX_STARTUP_MIGRATION_UNDO_${type.toUpperCase()}_ERRORCOUNT`; + Services.telemetry.getKeyedHistogramById(histogramId).add(browserId, this._errorMap[type]); + }; + + let startTelemetryStopwatch = resourceType => { + let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`; + TelemetryStopwatch.startKeyed(histogramId, browserId); + }; + let stopTelemetryStopwatch = resourceType => { + let histogramId = `FX_STARTUP_MIGRATION_UNDO_${resourceType.toUpperCase()}_MS`; + TelemetryStopwatch.finishKeyed(histogramId, browserId); + }; + startTelemetryStopwatch("bookmarks"); + yield this._removeUnchangedBookmarks(stateData.get("bookmarks")).catch(ex => { + Cu.reportError("Uncaught exception when removing unchanged bookmarks!"); + Cu.reportError(ex); + }); + stopTelemetryStopwatch("bookmarks"); + reportErrorTelemetry("bookmarks"); + histogram.add(15); + + startTelemetryStopwatch("visits"); + yield this._removeSomeVisits(stateData.get("visits")).catch(ex => { + Cu.reportError("Uncaught exception when removing history visits!"); + Cu.reportError(ex); + }); + stopTelemetryStopwatch("visits"); + reportErrorTelemetry("visits"); + histogram.add(20); + + startTelemetryStopwatch("logins"); + yield this._removeUnchangedLogins(stateData.get("logins")).catch(ex => { + Cu.reportError("Uncaught exception when removing unchanged logins!"); + Cu.reportError(ex); + }); + stopTelemetryStopwatch("logins"); + reportErrorTelemetry("logins"); + histogram.add(25); + + // This is async, but no need to wait for it. + NewTabUtils.links.populateCache(() => { + NewTabUtils.allPages.update(); + }, true); + + this._purgeUndoState(this.UNDO_REMOVED_REASON_UNDO_USED); + histogram.add(30); + TelemetryStopwatch.finishKeyed("FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", browserId); + }), + + _removeNotificationBars() { + let browserWindows = Services.wm.getEnumerator("navigator:browser"); + while (browserWindows.hasMoreElements()) { + let win = browserWindows.getNext(); + if (!win.closed) { + for (let browser of win.gBrowser.browsers) { + let nb = win.gBrowser.getNotificationBox(browser); + let notification = nb.getNotificationWithValue(kNotificationId); + if (notification) { + nb.removeNotification(notification); + } + } + } + } + }, + + _purgeUndoState(reason) { + // We don't wait for the off-main-thread removal to complete. OS.File will + // ensure it happens before shutdown. + OS.File.remove(kUndoStateFullPath, {ignoreAbsent: true}).then(() => { + this._pendingUndoTasks = false; + }); + + let migrationBrowser = Preferences.get(kAutoMigrateBrowserPref, "unknown"); + Services.prefs.clearUserPref(kAutoMigrateBrowserPref); + + let histogram = + Services.telemetry.getKeyedHistogramById("FX_STARTUP_MIGRATION_UNDO_REASON"); + histogram.add(migrationBrowser, reason); + }, + + getBrowserUsedForMigration() { + let browserId = Services.prefs.getCharPref(kAutoMigrateBrowserPref); + if (browserId) { + return MigrationUtils.getBrowserName(browserId); + } + return null; + }, + + /** + * Show the user a notification bar indicating we automatically imported + * their data and offering them the possibility of removing it. + * @param target (xul:browser) + * The browser in which we should show the notification. + */ + maybeShowUndoNotification: Task.async(function* (target) { + if (!(yield this.canUndo())) { + return; + } + + // The tab might have navigated since we requested the undo state: + let canUndoFromThisPage = ["about:home", "about:newtab"].includes(target.currentURI.spec); + if (!canUndoFromThisPage || + !Preferences.get(kUndoUIEnabledPref, false)) { + return; + } + + let win = target.ownerGlobal; + let notificationBox = win.gBrowser.getNotificationBox(target); + if (!notificationBox || notificationBox.getNotificationWithValue(kNotificationId)) { + return; + } + + // At this stage we're committed to show the prompt - unless we shouldn't, + // in which case we remove the undo prefs (which will cause canUndo() to + // return false from now on.): + if (!this.shouldStillShowUndoPrompt()) { + this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_EXPIRED); + this._removeNotificationBars(); + return; + } + + let browserName = this.getBrowserUsedForMigration(); + if (!browserName) { + browserName = gHardcodedStringBundle.GetStringFromName("automigration.undo.unknownbrowser"); + } + const kMessageId = "automigration.undo.message." + + Preferences.get(kAutoMigrateImportedItemIds, "all"); + const kBrandShortName = gBrandBundle.GetStringFromName("brandShortName"); + let message = gHardcodedStringBundle.formatStringFromName(kMessageId, + [browserName, kBrandShortName], 2); + + let buttons = [ + { + label: gHardcodedStringBundle.GetStringFromName("automigration.undo.keep2.label"), + accessKey: gHardcodedStringBundle.GetStringFromName("automigration.undo.keep2.accesskey"), + callback: () => { + this._purgeUndoState(this.UNDO_REMOVED_REASON_OFFER_REJECTED); + this._removeNotificationBars(); + }, + }, + { + label: gHardcodedStringBundle.GetStringFromName("automigration.undo.dontkeep2.label"), + accessKey: gHardcodedStringBundle.GetStringFromName("automigration.undo.dontkeep2.accesskey"), + callback: () => { + this._maybeOpenUndoSurveyTab(win); + this.undo(); + }, + }, + ]; + notificationBox.appendNotification( + message, kNotificationId, null, notificationBox.PRIORITY_INFO_HIGH, buttons + ); + let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 0); + Services.telemetry.getHistogramById("FX_STARTUP_MIGRATION_UNDO_OFFERED").add(4 - remainingDays); + }), + + shouldStillShowUndoPrompt() { + let today = new Date(); + // Round down to midnight: + today = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + // We store the unix timestamp corresponding to midnight on the last day + // on which we prompted. Fetch that and compare it to today's date. + // (NB: stored as a string because int prefs are too small for unix + // timestamps.) + let previousPromptDateMsStr = Preferences.get(kAutoMigrateLastUndoPromptDateMsPref, "0"); + let previousPromptDate = new Date(parseInt(previousPromptDateMsStr, 10)); + if (previousPromptDate < today) { + let remainingDays = Preferences.get(kAutoMigrateDaysToOfferUndoPref, 4) - 1; + Preferences.set(kAutoMigrateDaysToOfferUndoPref, remainingDays); + Preferences.set(kAutoMigrateLastUndoPromptDateMsPref, today.valueOf().toString()); + if (remainingDays <= 0) { + return false; + } + } + return true; + }, + + UNDO_REMOVED_REASON_UNDO_USED: 0, + UNDO_REMOVED_REASON_SYNC_SIGNIN: 1, + UNDO_REMOVED_REASON_PASSWORD_CHANGE: 2, + UNDO_REMOVED_REASON_BOOKMARK_CHANGE: 3, + UNDO_REMOVED_REASON_OFFER_EXPIRED: 4, + UNDO_REMOVED_REASON_OFFER_REJECTED: 5, + + _jsonifyUndoState(state) { + if (!state) { + return "null"; + } + // Deal with date serialization. + let bookmarks = state.get("bookmarks"); + for (let bm of bookmarks) { + bm.lastModified = bm.lastModified.getTime(); + } + let serializableState = { + bookmarks, + logins: state.get("logins"), + visits: state.get("visits"), + }; + return JSON.stringify(serializableState); + }, + + _dejsonifyUndoState(state) { + state = JSON.parse(state); + if (!state) { + return new Map(); + } + for (let bm of state.bookmarks) { + bm.lastModified = new Date(bm.lastModified); + } + return new Map([ + ["bookmarks", state.bookmarks], + ["logins", state.logins], + ["visits", state.visits], + ]); + }, + + /** + * Store the items we've saved into a pref. We use this to be able to show + * a detailed message to the user indicating what we've imported. + * @param state (Map) + * The 'undo' state for the import, which contains info about + * how many items of each kind we've (tried to) import. + */ + _setImportedItemPrefFromState(state) { + let itemsWithData = []; + if (state) { + for (let itemType of state.keys()) { + if (state.get(itemType).length) { + itemsWithData.push(itemType); + } + } + } + if (itemsWithData.length == 3) { + itemsWithData = "all"; + } else { + itemsWithData = itemsWithData.sort().join("."); + } + if (itemsWithData) { + Preferences.set(kAutoMigrateImportedItemIds, itemsWithData); + } + }, + + /** + * Used for the shutdown blocker's information field. + */ + _saveUndoStateTrackerForShutdown: "not running", + /** + * Store the information required for using 'undo' of the automatic + * migration in the user's profile. + */ + saveUndoState: Task.async(function* () { + let resolveSavingPromise; + this._saveUndoStateTrackerForShutdown = "processing undo history"; + this._savingPromise = new Promise(resolve => { resolveSavingPromise = resolve }); + let state = yield MigrationUtils.stopAndRetrieveUndoData(); + + if (!state || ![...state.values()].some(ary => ary.length > 0)) { + // If we didn't import anything, abort now. + resolveSavingPromise(); + return Promise.resolve(); + } + + this._saveUndoStateTrackerForShutdown = "saving imported item list"; + this._setImportedItemPrefFromState(state); + + this._saveUndoStateTrackerForShutdown = "writing undo history"; + this._undoSavePromise = OS.File.writeAtomic( + kUndoStateFullPath, this._jsonifyUndoState(state), { + encoding: "utf-8", + compression: "lz4", + tmpPath: kUndoStateFullPath + ".tmp", + }); + this._undoSavePromise.then( + rv => { + resolveSavingPromise(rv); + delete this._savingPromise; + }, + e => { + Cu.reportError("Could not write undo state for automatic migration."); + throw e; + }); + return this._undoSavePromise; + }), + + _removeUnchangedBookmarks: Task.async(function* (bookmarks) { + if (!bookmarks.length) { + return; + } + + let guidToLMMap = new Map(bookmarks.map(b => [b.guid, b.lastModified])); + let bookmarksFromDB = []; + let bmPromises = Array.from(guidToLMMap.keys()).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. + try { + return PlacesUtils.bookmarks.fetch(guid).then(bm => bm && bookmarksFromDB.push(bm), () => {}); + } catch (ex) { + // Ignore immediate exceptions, too. + } + return Promise.resolve(); + }); + // We can't use the result of Promise.all because that would include nulls + // for bookmarks that no longer exist (which we're catching above). + yield Promise.all(bmPromises); + let unchangedBookmarks = bookmarksFromDB.filter(bm => { + return bm.lastModified.getTime() == guidToLMMap.get(bm.guid).getTime(); + }); + + // We need to remove items without children first, followed by their + // parents, etc. In order to do this, find out how many ancestors each item + // has that also appear in our list of things to remove, and sort the items + // by those numbers. This ensures that children are always removed before + // their parents. + function determineAncestorCount(bm) { + if (bm._ancestorCount) { + return bm._ancestorCount; + } + let myCount = 0; + let parentBM = unchangedBookmarks.find(item => item.guid == bm.parentGuid); + if (parentBM) { + myCount = determineAncestorCount(parentBM) + 1; + } + bm._ancestorCount = myCount; + return myCount; + } + unchangedBookmarks.forEach(determineAncestorCount); + unchangedBookmarks.sort((a, b) => b._ancestorCount - a._ancestorCount); + for (let {guid} of unchangedBookmarks) { + // Can't just use a .catch() because Bookmarks.remove() can throw (rather + // than returning rejected promises). + try { + yield PlacesUtils.bookmarks.remove(guid, {preventRemovalOfNonEmptyFolders: true}); + } catch (err) { + if (err && err.message != "Cannot remove a non-empty folder.") { + this._errorMap.bookmarks++; + Cu.reportError(err); + } + } + } + }), + + _removeUnchangedLogins: Task.async(function* (logins) { + for (let login of logins) { + let foundLogins = LoginHelper.searchLoginsWithObject({guid: login.guid}); + if (foundLogins.length) { + let foundLogin = foundLogins[0]; + foundLogin.QueryInterface(Ci.nsILoginMetaInfo); + if (foundLogin.timePasswordChanged == login.timePasswordChanged) { + try { + Services.logins.removeLogin(foundLogin); + } catch (ex) { + Cu.reportError("Failed to remove a login for " + foundLogins.hostname); + Cu.reportError(ex); + this._errorMap.logins++; + } + } + } + } + }), + + _removeSomeVisits: Task.async(function* (visits) { + for (let urlVisits of visits) { + let urlObj; + try { + urlObj = new URL(urlVisits.url); + } catch (ex) { + continue; + } + let visitData = { + url: urlObj, + beginDate: PlacesUtils.toDate(urlVisits.first), + endDate: PlacesUtils.toDate(urlVisits.last), + limit: urlVisits.visitCount, + }; + try { + yield PlacesUtils.history.removeVisitsByFilter(visitData); + } catch (ex) { + this._errorMap.visits++; + try { + visitData.url = visitData.url.href; + } catch (ignoredEx) {} + Cu.reportError("Failed to remove a visit: " + JSON.stringify(visitData)); + Cu.reportError(ex); + } + } + }), + + /** + * Maybe open a new tab with a survey. The tab will only be opened if all of + * the following are true: + * - the 'browser.migrate.automigrate.undo-survey' pref is not empty. + * It should contain the URL of the survey to open. + * - the 'browser.migrate.automigrate.undo-survey-locales' pref, a + * comma-separated list of language codes, contains the language code + * that is currently in use for the 'global' chrome pacakge (ie the + * locale in which the user is currently using Firefox). + * The URL will be passed through nsIURLFormatter to allow including + * build ids etc. The special additional formatting variable + * "%IMPORTEDBROWSER" is also replaced with the name of the browser + * from which we imported data. + * + * @param {Window} chromeWindow A reference to the window in which to open a link. + */ + _maybeOpenUndoSurveyTab(chromeWindow) { + let canDoSurveyInLocale = false; + try { + let surveyLocales = Preferences.get(kAutoMigrateUndoSurveyLocalePref, ""); + surveyLocales = surveyLocales.split(",").map(str => str.trim()); + // Strip out any empty elements, so an empty pref doesn't + // lead to a an array with 1 empty string in it. + surveyLocales = new Set(surveyLocales.filter(str => !!str)); + let chromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"] + .getService(Ci.nsIXULChromeRegistry); + canDoSurveyInLocale = + surveyLocales.has(chromeRegistry.getSelectedLocale("global")); + } catch (ex) { + /* ignore exceptions and just don't do the survey. */ + } + + let migrationBrowser = this.getBrowserUsedForMigration(); + let rawURL = Preferences.get(kAutoMigrateUndoSurveyPref, ""); + if (!canDoSurveyInLocale || !migrationBrowser || !rawURL) { + return; + } + + let url = Services.urlFormatter.formatURL(rawURL); + url = url.replace("%IMPORTEDBROWSER%", encodeURIComponent(migrationBrowser)); + chromeWindow.openUILinkIn(url, "tab"); + }, + + QueryInterface: XPCOMUtils.generateQI( + [Ci.nsIObserver, Ci.nsINavBookmarkObserver, Ci.nsISupportsWeakReference] + ), +}; + +AutoMigrate.init(); diff --git a/browser/components/migration/BrowserProfileMigrators.manifest b/browser/components/migration/BrowserProfileMigrators.manifest new file mode 100644 index 000000000..e16fba13a --- /dev/null +++ b/browser/components/migration/BrowserProfileMigrators.manifest @@ -0,0 +1,33 @@ +component {6F8BB968-C14F-4D6F-9733-6C6737B35DCE} ProfileMigrator.js +contract @mozilla.org/toolkit/profile-migrator;1 {6F8BB968-C14F-4D6F-9733-6C6737B35DCE} + +#if defined(XP_WIN) || defined(XP_MACOSX) +component {4bf85aa5-4e21-46ca-825f-f9c51a5e8c76} ChromeProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=canary {4bf85aa5-4e21-46ca-825f-f9c51a5e8c76} +#endif +component {4cec1de4-1671-4fc3-a53e-6c539dc77a26} ChromeProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=chrome {4cec1de4-1671-4fc3-a53e-6c539dc77a26} +component {8cece922-9720-42de-b7db-7cef88cb07ca} ChromeProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=chromium {8cece922-9720-42de-b7db-7cef88cb07ca} + +component {91185366-ba97-4438-acba-48deaca63386} FirefoxProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=firefox {91185366-ba97-4438-acba-48deaca63386} + +#ifdef HAS_IE_MIGRATOR +component {3d2532e3-4932-4774-b7ba-968f5899d3a4} IEProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=ie {3d2532e3-4932-4774-b7ba-968f5899d3a4} +#endif + +#ifdef HAS_EDGE_MIGRATOR +component {62e8834b-2d17-49f5-96ff-56344903a2ae} EdgeProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=edge {62e8834b-2d17-49f5-96ff-56344903a2ae} +#endif + +#ifdef HAS_SAFARI_MIGRATOR +component {4b609ecf-60b2-4655-9df4-dc149e474da1} SafariProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=safari {4b609ecf-60b2-4655-9df4-dc149e474da1} +#endif +#ifdef HAS_360SE_MIGRATOR +component {d0037b95-296a-4a4e-94b2-c3d075d20ab1} 360seProfileMigrator.js +contract @mozilla.org/profile/migrator;1?app=browser&type=360se {d0037b95-296a-4a4e-94b2-c3d075d20ab1} +#endif diff --git a/browser/components/migration/ChromeProfileMigrator.js b/browser/components/migration/ChromeProfileMigrator.js new file mode 100644 index 000000000..ec0c8d444 --- /dev/null +++ b/browser/components/migration/ChromeProfileMigrator.js @@ -0,0 +1,557 @@ +/* -*- 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 { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +const FILE_INPUT_STREAM_CID = "@mozilla.org/network/file-input-stream;1"; + +const S100NS_FROM1601TO1970 = 0x19DB1DED53E8000; +const S100NS_PER_MS = 10; + +const AUTH_TYPE = { + SCHEME_HTML: 0, + SCHEME_BASIC: 1, + SCHEME_DIGEST: 2 +}; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); /* globals OS */ +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OSCrypto", + "resource://gre/modules/OSCrypto.jsm"); +/** + * Get an nsIFile instance representing the expected location of user data + * for this copy of Chrome/Chromium/Canary on different OSes. + * @param subfoldersWin {Array} an array of subfolders to use for Windows + * @param subfoldersOSX {Array} an array of subfolders to use for OS X + * @param subfoldersUnix {Array} an array of subfolders to use for *nix systems + * @returns {nsIFile} the place we expect data to live. Might not actually exist! + */ +function getDataFolder(subfoldersWin, subfoldersOSX, subfoldersUnix) { + let dirServiceID, subfolders; + if (AppConstants.platform == "win") { + dirServiceID = "LocalAppData"; + subfolders = subfoldersWin.concat(["User Data"]); + } else if (AppConstants.platform == "macosx") { + dirServiceID = "ULibDir"; + subfolders = ["Application Support"].concat(subfoldersOSX); + } else { + dirServiceID = "Home"; + subfolders = [".config"].concat(subfoldersUnix); + } + return FileUtils.getDir(dirServiceID, subfolders, false); +} + +/** + * Convert Chrome time format to Date object + * + * @param aTime + * Chrome time + * @return converted Date object + * @note Google Chrome uses FILETIME / 10 as time. + * FILETIME is based on same structure of Windows. + */ +function chromeTimeToDate(aTime) +{ + 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. + */ +function dateToChromeTime(aDate) { + return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS; +} + +/** + * Insert bookmark items into specific folder. + * + * @param parentGuid + * GUID of the folder where items will be inserted + * @param items + * bookmark items to be inserted + * @param errorAccumulator + * function that gets called with any errors thrown so we don't drop them on the floor. + */ +function* insertBookmarkItems(parentGuid, items, errorAccumulator) { + for (let item of items) { + try { + if (item.type == "url") { + if (item.url.trim().startsWith("chrome:")) { + // Skip invalid chrome URIs. Creating an actual URI always reports + // messages to the console, so we avoid doing that. + continue; + } + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, url: item.url, title: item.name + }); + } else if (item.type == "folder") { + let newFolderGuid = (yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, type: PlacesUtils.bookmarks.TYPE_FOLDER, title: item.name + })).guid; + + yield insertBookmarkItems(newFolderGuid, item.children, errorAccumulator); + } + } catch (e) { + Cu.reportError(e); + errorAccumulator(e); + } + } +} + +function ChromeProfileMigrator() { + let chromeUserDataFolder = + getDataFolder(["Google", "Chrome"], ["Google", "Chrome"], ["google-chrome"]); + this._chromeUserDataFolder = chromeUserDataFolder.exists() ? + chromeUserDataFolder : null; +} + +ChromeProfileMigrator.prototype = Object.create(MigratorPrototype); + +ChromeProfileMigrator.prototype.getResources = + function Chrome_getResources(aProfile) { + if (this._chromeUserDataFolder) { + let profileFolder = this._chromeUserDataFolder.clone(); + profileFolder.append(aProfile.id); + if (profileFolder.exists()) { + let possibleResources = [ + GetBookmarksResource(profileFolder), + GetHistoryResource(profileFolder), + GetCookiesResource(profileFolder), + ]; + if (AppConstants.platform == "win") { + possibleResources.push(GetWindowsPasswordsResource(profileFolder)); + } + return possibleResources.filter(r => r != null); + } + } + return []; + }; + +ChromeProfileMigrator.prototype.getLastUsedDate = + function Chrome_getLastUsedDate() { + let datePromises = this.sourceProfiles.map(profile => { + let basePath = OS.Path.join(this._chromeUserDataFolder.path, profile.id); + let fileDatePromises = ["Bookmarks", "History", "Cookies"].map(leafName => { + let path = OS.Path.join(basePath, leafName); + return OS.File.stat(path).catch(() => null).then(info => { + return info ? info.lastModificationDate : 0; + }); + }); + return Promise.all(fileDatePromises).then(dates => { + return Math.max.apply(Math, dates); + }); + }); + return Promise.all(datePromises).then(dates => { + dates.push(0); + return new Date(Math.max.apply(Math, dates)); + }); + }; + +Object.defineProperty(ChromeProfileMigrator.prototype, "sourceProfiles", { + get: function Chrome_sourceProfiles() { + if ("__sourceProfiles" in this) + return this.__sourceProfiles; + + if (!this._chromeUserDataFolder) + return []; + + let profiles = []; + try { + // Local State is a JSON file that contains profile info. + let localState = this._chromeUserDataFolder.clone(); + localState.append("Local State"); + if (!localState.exists()) + throw new Error("Chrome's 'Local State' file does not exist."); + if (!localState.isReadable()) + throw new Error("Chrome's 'Local State' file could not be read."); + + let fstream = Cc[FILE_INPUT_STREAM_CID].createInstance(Ci.nsIFileInputStream); + fstream.init(localState, -1, 0, 0); + let inputStream = NetUtil.readInputStreamToString(fstream, fstream.available(), + { charset: "UTF-8" }); + let info_cache = JSON.parse(inputStream).profile.info_cache; + for (let profileFolderName in info_cache) { + let profileFolder = this._chromeUserDataFolder.clone(); + profileFolder.append(profileFolderName); + profiles.push({ + id: profileFolderName, + name: info_cache[profileFolderName].name || profileFolderName, + }); + } + } catch (e) { + Cu.reportError("Error detecting Chrome profiles: " + e); + // If we weren't able to detect any profiles above, fallback to the Default profile. + let defaultProfileFolder = this._chromeUserDataFolder.clone(); + defaultProfileFolder.append("Default"); + if (defaultProfileFolder.exists()) { + profiles = [{ + id: "Default", + name: "Default", + }]; + } + } + + // Only list profiles from which any data can be imported + this.__sourceProfiles = profiles.filter(function(profile) { + let resources = this.getResources(profile); + return resources && resources.length > 0; + }, this); + return this.__sourceProfiles; + } +}); + +Object.defineProperty(ChromeProfileMigrator.prototype, "sourceHomePageURL", { + get: function Chrome_sourceHomePageURL() { + let prefsFile = this._chromeUserDataFolder.clone(); + prefsFile.append("Preferences"); + if (prefsFile.exists()) { + // XXX reading and parsing JSON is synchronous. + let fstream = Cc[FILE_INPUT_STREAM_CID]. + createInstance(Ci.nsIFileInputStream); + fstream.init(prefsFile, -1, 0, 0); + try { + return JSON.parse( + NetUtil.readInputStreamToString(fstream, fstream.available(), + { charset: "UTF-8" }) + ).homepage; + } + catch (e) { + Cu.reportError("Error parsing Chrome's preferences file: " + e); + } + } + return ""; + } +}); + +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; + }, +}); + +function GetBookmarksResource(aProfileFolder) { + let bookmarksFile = aProfileFolder.clone(); + bookmarksFile.append("Bookmarks"); + if (!bookmarksFile.exists()) + return null; + + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function(aCallback) { + return Task.spawn(function* () { + let gotErrors = false; + let errorGatherer = function() { gotErrors = true }; + let jsonStream = yield new Promise((resolve, reject) => { + let options = { + uri: NetUtil.newURI(bookmarksFile), + loadUsingSystemPrincipal: true + }; + NetUtil.asyncFetch(options, (inputStream, resultCode) => { + if (Components.isSuccessCode(resultCode)) { + resolve(inputStream); + } else { + reject(new Error("Could not read Bookmarks file")); + } + }); + }); + + // Parse Chrome bookmark file that is JSON format + let bookmarkJSON = NetUtil.readInputStreamToString( + jsonStream, jsonStream.available(), { charset : "UTF-8" }); + let roots = JSON.parse(bookmarkJSON).roots; + + // Importing bookmark bar items + if (roots.bookmark_bar.children && + roots.bookmark_bar.children.length > 0) { + // Toolbar + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + if (!MigrationUtils.isStartupMigration) { + parentGuid = + yield MigrationUtils.createImportedBookmarksFolder("Chrome", parentGuid); + } + yield insertBookmarkItems(parentGuid, roots.bookmark_bar.children, errorGatherer); + } + + // Importing bookmark menu items + if (roots.other.children && + roots.other.children.length > 0) { + // Bookmark menu + let parentGuid = PlacesUtils.bookmarks.menuGuid; + if (!MigrationUtils.isStartupMigration) { + parentGuid = + yield MigrationUtils.createImportedBookmarksFolder("Chrome", parentGuid); + } + yield insertBookmarkItems(parentGuid, roots.other.children, errorGatherer); + } + if (gotErrors) { + throw new Error("The migration included errors."); + } + }.bind(this)).then(() => aCallback(true), + () => aCallback(false)); + } + }; +} + +function GetHistoryResource(aProfileFolder) { + let historyFile = aProfileFolder.clone(); + historyFile.append("History"); + if (!historyFile.exists()) + return null; + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + migrate(aCallback) { + Task.spawn(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 = 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 = + yield MigrationUtils.getRowsFromDBWithoutLocks(historyFile.path, "Chrome history", query); + let places = []; + for (let row of rows) { + try { + // if having typed_count, we changes transition type to typed. + let transType = PlacesUtils.history.TRANSITION_LINK; + if (row.getResultByName("typed_count") > 0) + transType = PlacesUtils.history.TRANSITION_TYPED; + + places.push({ + uri: NetUtil.newURI(row.getResultByName("url")), + title: row.getResultByName("title"), + visits: [{ + transitionType: transType, + visitDate: chromeTimeToDate( + row.getResultByName( + "last_visit_time")) * 1000, + }], + }); + } catch (e) { + Cu.reportError(e); + } + } + + if (places.length > 0) { + yield new Promise((resolve, reject) => { + MigrationUtils.insertVisitsWrapper(places, { + _success: false, + handleResult: function() { + // Importing any entry is considered a successful import. + this._success = true; + }, + handleError: function() {}, + handleCompletion: function() { + if (this._success) { + resolve(); + } else { + reject(new Error("Couldn't add visits")); + } + } + }); + }); + } + }).then(() => { aCallback(true) }, + ex => { + Cu.reportError(ex); + aCallback(false); + }); + } + }; +} + +function GetCookiesResource(aProfileFolder) { + let cookiesFile = aProfileFolder.clone(); + cookiesFile.append("Cookies"); + if (!cookiesFile.exists()) + return null; + + return { + type: MigrationUtils.resourceTypes.COOKIES, + + migrate: Task.async(function* (aCallback) { + // We don't support decrypting cookies yet so only import plaintext ones. + let rows = yield MigrationUtils.getRowsFromDBWithoutLocks(cookiesFile.path, "Chrome cookies", + `SELECT host_key, name, value, path, expires_utc, secure, httponly, encrypted_value + FROM cookies + WHERE length(encrypted_value) = 0`).catch(ex => { + Cu.reportError(ex); + aCallback(false); + }); + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + for (let row of rows) { + let host_key = row.getResultByName("host_key"); + if (host_key.match(/^\./)) { + // 1st character of host_key may be ".", so we have to remove it + host_key = host_key.substr(1); + } + + try { + let expiresUtc = + chromeTimeToDate(row.getResultByName("expires_utc")) / 1000; + Services.cookies.add(host_key, + row.getResultByName("path"), + row.getResultByName("name"), + row.getResultByName("value"), + row.getResultByName("secure"), + row.getResultByName("httponly"), + false, + parseInt(expiresUtc), + {}); + } catch (e) { + Cu.reportError(e); + } + } + aCallback(true); + }), + }; +} + +function GetWindowsPasswordsResource(aProfileFolder) { + let loginFile = aProfileFolder.clone(); + loginFile.append("Login Data"); + if (!loginFile.exists()) + return null; + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + + migrate: Task.async(function* (aCallback) { + let rows = yield MigrationUtils.getRowsFromDBWithoutLocks(loginFile.path, "Chrome passwords", + `SELECT origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, scheme, date_created, + times_used FROM logins WHERE blacklisted_by_user = 0`).catch(ex => { + Cu.reportError(ex); + aCallback(false); + }); + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + let crypto = new OSCrypto(); + + 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: crypto. + decryptData(crypto.arrayToString(row.getResultByName("password_value")), + null), + hostname: origin_url.prePath, + formSubmitURL: null, + httpRealm: null, + usernameElement: row.getResultByName("username_element"), + passwordElement: row.getResultByName("password_element"), + timeCreated: chromeTimeToDate(row.getResultByName("date_created") + 0).getTime(), + timesUsed: row.getResultByName("times_used") + 0, + }; + + switch (row.getResultByName("scheme")) { + case AUTH_TYPE.SCHEME_HTML: + let action_url = NetUtil.newURI(row.getResultByName("action_url")); + if (!kValidSchemes.has(action_url.scheme)) { + continue; // This continues the outer for loop. + } + loginInfo.formSubmitURL = action_url.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.hostname.length + 1); + break; + default: + throw new Error("Login data scheme type not supported: " + + row.getResultByName("scheme")); + } + MigrationUtils.insertLoginWrapper(loginInfo); + } catch (e) { + Cu.reportError(e); + } + } + 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() { + let chromiumUserDataFolder = getDataFolder(["Chromium"], ["Chromium"], ["chromium"]); + this._chromeUserDataFolder = chromiumUserDataFolder.exists() ? chromiumUserDataFolder : null; +} + +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 componentsArray = [ChromeProfileMigrator, ChromiumProfileMigrator]; + +/** + * Chrome Canary + * Not available on Linux + **/ +function CanaryProfileMigrator() { + let chromeUserDataFolder = getDataFolder(["Google", "Chrome SxS"], ["Google", "Chrome Canary"]); + this._chromeUserDataFolder = chromeUserDataFolder.exists() ? chromeUserDataFolder : null; +} +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") { + componentsArray.push(CanaryProfileMigrator); +} + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory(componentsArray); diff --git a/browser/components/migration/ESEDBReader.jsm b/browser/components/migration/ESEDBReader.jsm new file mode 100644 index 000000000..0768c65aa --- /dev/null +++ b/browser/components/migration/ESEDBReader.jsm @@ -0,0 +1,590 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["ESEDBReader"]; /* exported ESEDBReader */ + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +XPCOMUtils.defineLazyGetter(this, "log", () => { + let ConsoleAPI = Cu.import("resource://gre/modules/Console.jsm", {}).ConsoleAPI; + let consoleOptions = { + maxLogLevelPref: "browser.esedbreader.loglevel", + prefix: "ESEDBReader", + }; + return new ConsoleAPI(consoleOptions); +}); + +// We have a globally unique identifier for ESE instances. A new one +// is used for each different database opened. +let gESEInstanceCounter = 0; + +// We limit the length of strings that we read from databases. +const MAX_STR_LENGTH = 64 * 1024; + +// Kernel-related types: +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 -1207 /* JET_errDatabaseLocked */: + case -1302 /* JET_errTableLocked */: + return "The database or table is locked, error code: " + errorCode; + 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", false); + gLibs.ese = ctypes.open("esent.dll"); + gLibs.kernel = ctypes.open("kernel32.dll"); + KERNEL.FileTimeToSystemTime = gLibs.kernel.declare("FileTimeToSystemTime", + ctypes.default_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: function*(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 + "\\"); + }, + + closeDB(db) { + db.decrementReferenceCounter(); + }, + + COLUMN_TYPES, +}; + diff --git a/browser/components/migration/EdgeProfileMigrator.js b/browser/components/migration/EdgeProfileMigrator.js new file mode 100644 index 000000000..afdcc2773 --- /dev/null +++ b/browser/components/migration/EdgeProfileMigrator.js @@ -0,0 +1,450 @@ +/* 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 { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); /* globals OS */ +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ +Cu.import("resource:///modules/MSMigrationUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ESEDBReader", + "resource:///modules/ESEDBReader.jsm"); + +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()) { + 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 {function} filterFn a function that is called for each row. + * Only rows for which it returns a truthy + * value are included in the result. + * @param {nsIFile} dbFile the database file to use. Defaults to + * the main Edge database. + * @returns {Array} An array of row objects. + */ +function readTableFromEdgeDB(tableName, columns, filterFn, dbFile = gEdgeDatabase) { + 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(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: function(aCallback) { + let typedURLs = this._typedURLs; + let places = []; + for (let [urlString, time] of typedURLs) { + let uri; + try { + uri = Services.io.newURI(urlString, null, null); + if (["http", "https", "ftp"].indexOf(uri.scheme) == -1) { + continue; + } + } catch (ex) { + Cu.reportError(ex); + continue; + } + + // Note that the time will be in microseconds (PRTime), + // and Date.now() returns milliseconds. Places expects PRTime, + // so we multiply the Date.now return value to make up the difference. + let visitDate = time || (Date.now() * 1000); + places.push({ + uri, + visits: [{ transitionType: Ci.nsINavHistoryService.TRANSITION_TYPED, + visitDate}] + }); + } + + if (places.length == 0) { + aCallback(typedURLs.size == 0); + return; + } + + MigrationUtils.insertVisitsWrapper(places, { + _success: false, + handleResult: function() { + // Importing any entry is considered a successful import. + this._success = true; + }, + handleError: function() {}, + handleCompletion: function() { + aCallback(this._success); + } + }); + }, +}; + +function EdgeReadingListMigrator() { +} + +EdgeReadingListMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return !!gEdgeDatabase; + }, + + migrate(callback) { + this._migrateReadingList(PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + Cu.reportError(ex); + callback(false); + } + ); + }, + + _migrateReadingList: Task.async(function*(parentGuid) { + 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, filterFn); + if (!readingListItems.length) { + return; + } + + let destFolderGuid = yield this._ensureReadingListFolder(parentGuid); + let exceptionThrown; + for (let item of readingListItems) { + let dateAdded = item.AddedDate || new Date(); + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid: destFolderGuid, url: item.URL, title: item.Title, dateAdded + }).catch(ex => { + if (!exceptionThrown) { + exceptionThrown = ex; + } + Cu.reportError(ex); + }); + } + if (exceptionThrown) { + throw exceptionThrown; + } + }), + + _ensureReadingListFolder: Task.async(function*(parentGuid) { + if (!this.__readingListFolderGuid) { + let folderTitle = MigrationUtils.getLocalizedString("importedEdgeReadingList"); + let folderSpec = {type: PlacesUtils.bookmarks.TYPE_FOLDER, parentGuid, title: folderTitle}; + this.__readingListFolderGuid = (yield 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(PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + Cu.reportError(ex); + callback(false); + } + ); + }, + + _migrateBookmarks: Task.async(function*(rootGuid) { + let {bookmarks, folderMap} = this._fetchBookmarksFromDB(); + if (!bookmarks.length) { + return; + } + yield this._importBookmarks(bookmarks, folderMap, rootGuid); + }), + + _importBookmarks: Task.async(function*(bookmarks, folderMap, rootGuid) { + if (!MigrationUtils.isStartupMigration) { + rootGuid = + yield MigrationUtils.createImportedBookmarksFolder("Edge", rootGuid); + } + + let exceptionThrown; + for (let bookmark of bookmarks) { + // If this is a folder, we might have created it already to put other bookmarks in. + if (bookmark.IsFolder && bookmark._guid) { + continue; + } + + // If this is a folder, just create folders up to and including that folder. + // Otherwise, create folders until we have a parent for this bookmark. + // This avoids duplicating logic for the bookmarks bar. + let folderId = bookmark.IsFolder ? bookmark.ItemId : bookmark.ParentId; + let parentGuid = yield this._getGuidForFolder(folderId, folderMap, rootGuid).catch(ex => { + if (!exceptionThrown) { + exceptionThrown = ex; + } + Cu.reportError(ex); + }); + + // If this was a folder, we're done with this item + if (bookmark.IsFolder) { + continue; + } + + if (!parentGuid) { + // If we couldn't sort out a parent, fall back to importing on the root: + parentGuid = rootGuid; + } + let placesInfo = { + parentGuid, + url: bookmark.URL, + dateAdded: bookmark.DateUpdated || new Date(), + title: bookmark.Title, + }; + + yield MigrationUtils.insertBookmarkWrapper(placesInfo).catch(ex => { + if (!exceptionThrown) { + exceptionThrown = ex; + } + Cu.reportError(ex); + }); + } + + if (exceptionThrown) { + throw exceptionThrown; + } + }), + + _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, filterFn, this.db); + return {bookmarks, folderMap}; + }, + + _getGuidForFolder: Task.async(function*(folderId, folderMap, rootGuid) { + // If the folderId is not known as a folder in the folder map, we assume + // we just need the root + if (!folderMap.has(folderId)) { + return rootGuid; + } + let folder = folderMap.get(folderId); + // If the folder already has a places guid, just return that. + if (folder._guid) { + return folder._guid; + } + + // Hacks! The bookmarks bar is special: + if (folder.Title == "_Favorites_Bar_") { + let toolbarGuid = PlacesUtils.bookmarks.toolbarGuid; + if (!MigrationUtils.isStartupMigration) { + toolbarGuid = + yield MigrationUtils.createImportedBookmarksFolder("Edge", toolbarGuid); + } + folder._guid = toolbarGuid; + return folder._guid; + } + // Otherwise, get the right parent guid recursively: + let parentGuid = yield this._getGuidForFolder(folder.ParentId, folderMap, rootGuid); + let folderInfo = { + title: folder.Title, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded: folder.DateUpdated || new Date(), + parentGuid, + }; + // and add ourselves as a kid, and return the guid we got. + let parentBM = yield MigrationUtils.insertBookmarkWrapper(folderInfo); + folder._guid = parentBM.guid; + return folder._guid; + }), +}; + +function EdgeProfileMigrator() { + this.wrappedJSObject = this; +} + +EdgeProfileMigrator.prototype = Object.create(MigratorPrototype); + +EdgeProfileMigrator.prototype.getESEMigratorForTesting = function(dbOverride) { + return new EdgeBookmarksMigrator(dbOverride); +}; + +EdgeProfileMigrator.prototype.getResources = function() { + let resources = [ + new EdgeBookmarksMigrator(), + MSMigrationUtils.getCookiesMigrator(MSMigrationUtils.MIGRATION_TYPE_EDGE), + new EdgeTypedURLMigrator(), + new EdgeReadingListMigrator(), + ]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); +}; + +EdgeProfileMigrator.prototype.getLastUsedDate = 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: + if (this.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()]; + resolve(Math.max.apply(Math, times)); + })); + 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.__defineGetter__("sourceProfiles", 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}"); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([EdgeProfileMigrator]); diff --git a/browser/components/migration/FirefoxProfileMigrator.js b/browser/components/migration/FirefoxProfileMigrator.js new file mode 100644 index 000000000..60ffcf627 --- /dev/null +++ b/browser/components/migration/FirefoxProfileMigrator.js @@ -0,0 +1,255 @@ +/* -*- 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"; + +/* + * 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 { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ +Cu.import("resource://gre/modules/Services.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesBackups", + "resource://gre/modules/PlacesBackups.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "SessionMigration", + "resource:///modules/sessionstore/SessionMigration.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OS", + "resource://gre/modules/osfile.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ProfileAge", + "resource://gre/modules/ProfileAge.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "AppConstants", + "resource://gre/modules/AppConstants.jsm"); + + +function FirefoxProfileMigrator() { + this.wrappedJSObject = this; // for testing... +} + +FirefoxProfileMigrator.prototype = Object.create(MigratorPrototype); + +FirefoxProfileMigrator.prototype._getAllProfiles = function() { + let allProfiles = new Map(); + let profiles = + Components.classes["@mozilla.org/toolkit/profile-service;1"] + .getService(Components.interfaces.nsIToolkitProfileService) + .profiles; + while (profiles.hasMoreElements()) { + let profile = profiles.getNext().QueryInterface(Ci.nsIToolkitProfile); + 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()); +} + +Object.defineProperty(FirefoxProfileMigrator.prototype, "sourceProfiles", { + get: 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) : + Components.classes["@mozilla.org/toolkit/profile-service;1"] + .getService(Components.interfaces.nsIToolkitProfileService) + .selectedProfile.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 = function(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: function(aCallback) { + for (let file of files) { + file.copyTo(currentProfileDir, ""); + } + aCallback(true); + } + }; + }.bind(this); + + let types = MigrationUtils.resourceTypes; + let places = getFileResource(types.HISTORY, ["places.sqlite", "places.sqlite-wal"]); + let cookies = getFileResource(types.COOKIES, ["cookies.sqlite", "cookies.sqlite-wal"]); + let passwords = getFileResource(types.PASSWORDS, + ["signons.sqlite", "logins.json", "key3.db", + "signedInUser.json"]); + let formData = getFileResource(types.FORMDATA, ["formhistory.sqlite"]); + let bookmarksBackups = getFileResource(types.OTHERDATA, + [PlacesBackups.profileRelativeFolderPath]); + let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]); + + let sessionCheckpoints = this._getFileObject(sourceProfileDir, "sessionCheckpoints.json"); + let sessionFile = this._getFileObject(sourceProfileDir, "sessionstore.js"); + let session; + if (sessionFile) { + session = { + type: types.SESSION, + migrate: function(aCallback) { + sessionCheckpoints.copyTo(currentProfileDir, "sessionCheckpoints.json"); + let newSessionFile = currentProfileDir.clone(); + newSessionFile.append("sessionstore.js"); + 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); + // It's too early in startup for the pref 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); + aCallback(true); + }, function() { + aCallback(false); + }); + } + }; + } + + // 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 timesAccessor = new ProfileAge(currentProfileDir.path); + timesAccessor.recordProfileReset().then( + () => aCallback(true), + () => aCallback(false) + ); + } + }; + 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 haveStateFile = false; + 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.getNext().QueryInterface(Ci.nsIFile); + if (file.isDirectory() || toCopy.indexOf(file.leafName) == -1) { + continue; + } + + if (file.leafName == "state.json") { + haveStateFile = true; + } + file.copyTo(dest, ""); + } + } + + if (!haveStateFile) { + // Fall back to migrating the state file that contains the client id from healthreport/. + // We first moved the client id management from the FHR implementation to the datareporting + // service. + // Consequently, we try to migrate an existing FHR state file here as a fallback. + let healthReportDir = this._getFileObject(sourceProfileDir, "healthreport"); + if (healthReportDir && healthReportDir.isDirectory()) { + let stateFile = this._getFileObject(healthReportDir, "state.json"); + if (stateFile) { + let dest = createSubDir("healthreport"); + stateFile.copyTo(dest, ""); + } + } + } + + aCallback(true); + } + }; + + return [places, cookies, passwords, formData, dictionary, bookmarksBackups, + session, times, telemetry].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}"); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([FirefoxProfileMigrator]); diff --git a/browser/components/migration/IEProfileMigrator.js b/browser/components/migration/IEProfileMigrator.js new file mode 100644 index 000000000..ac055686c --- /dev/null +++ b/browser/components/migration/IEProfileMigrator.js @@ -0,0 +1,542 @@ +/* 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 Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; + +const kLoginsKey = "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2"; +const kMainKey = "Software\\Microsoft\\Internet Explorer\\Main"; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); /* globals OS */ +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ +Cu.import("resource:///modules/MSMigrationUtils.jsm"); + + +XPCOMUtils.defineLazyModuleGetter(this, "ctypes", + "resource://gre/modules/ctypes.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "OSCrypto", + "resource://gre/modules/OSCrypto.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); + +Cu.importGlobalProperties(["URL"]); + +// Resources + +function History() { +} + +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get exists() { + return true; + }, + + migrate: function H_migrate(aCallback) { + let places = []; + let typedURLs = MSMigrationUtils.getTypedURLs("Software\\Microsoft\\Internet Explorer"); + let historyEnumerator = Cc["@mozilla.org/profile/migrator/iehistoryenumerator;1"]. + createInstance(Ci.nsISimpleEnumerator); + while (historyEnumerator.hasMoreElements()) { + let entry = historyEnumerator.getNext().QueryInterface(Ci.nsIPropertyBag2); + 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 don't properly map handling for + // all of them we just avoid importing them. + if (["http", "https", "ftp", "file"].indexOf(uri.scheme) == -1) { + continue; + } + + let title = entry.get("title"); + // Embed visits have no title and don't need to be imported. + if (title.length == 0) { + continue; + } + + // The typed urls are already fixed-up, so we can use them for comparison. + let transitionType = typedURLs.has(uri.spec) ? + Ci.nsINavHistoryService.TRANSITION_TYPED : + Ci.nsINavHistoryService.TRANSITION_LINK; + // use the current date if we have no visits for this entry. + // Note that the entry will have a time in microseconds (PRTime), + // and Date.now() returns milliseconds. Places expects PRTime, + // so we multiply the Date.now return value to make up the difference. + let lastVisitTime = entry.get("time") || (Date.now() * 1000); + + places.push( + { uri: uri, + title: title, + visits: [{ transitionType: transitionType, + visitDate: lastVisitTime }] + } + ); + } + + // Check whether there is any history to import. + if (places.length == 0) { + aCallback(true); + return; + } + + MigrationUtils.insertVisitsWrapper(places, { + _success: false, + handleResult: function() { + // Importing any entry is considered a successful import. + this._success = true; + }, + handleError: function() {}, + handleCompletion: function() { + aCallback(this._success); + } + }); + } +}; + +// 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; + } + }, + + migrate(aCallback) { + let historyEnumerator = Cc["@mozilla.org/profile/migrator/iehistoryenumerator;1"]. + createInstance(Ci.nsISimpleEnumerator); + let uris = []; // the uris of the websites that are going to be migrated + while (historyEnumerator.hasMoreElements()) { + let entry = historyEnumerator.getNext().QueryInterface(Ci.nsIPropertyBag2); + 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"].indexOf(uri.scheme) == -1) { + continue; + } + + uris.push(uri); + } + 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. + */ + _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. + */ + + 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, true); + } 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++; + } + this._addLogins(ieLogins); + } catch (e) { + Cu.reportError("Error while importing logins for " + uri.spec + ": " + e); + } + } + // 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, + + /** + * Add the logins to the password manager. + * @param {Object[]} logins - array of the login details. + */ + _addLogins(ieLogins) { + for (let ieLogin of ieLogins) { + try { + // create a new login + let login = { + username: ieLogin.username, + password: ieLogin.password, + hostname: ieLogin.url, + timeCreated: ieLogin.creation, + }; + MigrationUtils.insertLoginWrapper(login); + } catch (e) { + Cu.reportError(e); + } + } + }, + + /** + * 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: creation, + url: 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 Settings() { +} + +Settings.prototype = { + type: MigrationUtils.resourceTypes.SETTINGS, + + get exists() { + return true; + }, + + migrate: function S_migrate(aCallback) { + // Converts from yes/no to a boolean. + let yesNoToBoolean = v => v == "yes"; + + // Converts source format like "en-us,ar-kw;q=0.7,ar-om;q=0.3" into + // destination format like "en-us, ar-kw, ar-om". + // Final string is sorted by quality (q=) param. + function parseAcceptLanguageList(v) { + return v.match(/([a-z]{1,8}(-[a-z]{1,8})?)\s*(;\s*q\s*=\s*(1|0\.[0-9]+))?/gi) + .sort(function(a, b) { + let qA = parseFloat(a.split(";q=")[1]) || 1.0; + let qB = parseFloat(b.split(";q=")[1]) || 1.0; + return qB - qA; + }) + .map(a => a.split(";")[0]); + } + + // For reference on some of the available IE Registry settings: + // * http://msdn.microsoft.com/en-us/library/cc980058%28v=prot.13%29.aspx + // * http://msdn.microsoft.com/en-us/library/cc980059%28v=prot.13%29.aspx + + // Note that only settings exposed in our UI should be migrated. + + this._set("Software\\Microsoft\\Internet Explorer\\International", + "AcceptLanguage", + "intl.accept_languages", + parseAcceptLanguageList); + // TODO (bug 745853): For now, only x-western font is translated. + this._set("Software\\Microsoft\\Internet Explorer\\International\\Scripts\\3", + "IEFixedFontName", + "font.name.monospace.x-western"); + this._set(kMainKey, + "Use FormSuggest", + "browser.formfill.enable", + yesNoToBoolean); + this._set(kMainKey, + "FormSuggest Passwords", + "signon.rememberSignons", + yesNoToBoolean); + this._set(kMainKey, + "Anchor Underline", + "browser.underline_anchors", + yesNoToBoolean); + this._set(kMainKey, + "Display Inline Images", + "permissions.default.image", + v => yesNoToBoolean(v) ? 1 : 2); + this._set(kMainKey, + "Move System Caret", + "accessibility.browsewithcaret", + yesNoToBoolean); + this._set("Software\\Microsoft\\Internet Explorer\\Settings", + "Always Use My Colors", + "browser.display.document_color_use", + v => (!v ? 0 : 2)); + this._set("Software\\Microsoft\\Internet Explorer\\Settings", + "Always Use My Font Face", + "browser.display.use_document_fonts", + v => !v); + this._set(kMainKey, + "SmoothScroll", + "general.smoothScroll", + Boolean); + this._set("Software\\Microsoft\\Internet Explorer\\TabbedBrowsing\\", + "WarnOnClose", + "browser.tabs.warnOnClose", + Boolean); + this._set("Software\\Microsoft\\Internet Explorer\\TabbedBrowsing\\", + "OpenInForeground", + "browser.tabs.loadInBackground", + v => !v); + + aCallback(true); + }, + + /** + * Reads a setting from the Registry and stores the converted result into + * the appropriate Firefox preference. + * + * @param aPath + * Registry path under HKCU. + * @param aKey + * Name of the key. + * @param aPref + * Firefox preference. + * @param [optional] aTransformFn + * Conversion function from the Registry format to the pref format. + */ + _set: function S__set(aPath, aKey, aPref, aTransformFn) { + let value = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + aPath, aKey); + // Don't import settings that have never been flipped. + if (value === undefined) + return; + + if (aTransformFn) + value = aTransformFn(value); + + switch (typeof value) { + case "string": + Services.prefs.setCharPref(aPref, value); + break; + case "number": + Services.prefs.setIntPref(aPref, value); + break; + case "boolean": + Services.prefs.setBoolPref(aPref, value); + break; + default: + throw new Error("Unexpected value type: " + (typeof value)); + } + } +}; + +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(), + new Settings(), + ]; + // 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()]; + resolve(Math.max.apply(Math, dates)); + })); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); +}; + +Object.defineProperty(IEProfileMigrator.prototype, "sourceHomePageURL", { + get: function IE_get_sourceHomePageURL() { + let defaultStartPage = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + kMainKey, "Default_Page_URL"); + let startPage = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kMainKey, "Start Page"); + // If the user didn't customize the Start Page, he is still on the default + // page, that may be considered the equivalent of our about:home. There's + // no reason to retain it, since it is heavily targeted to IE. + let homepage = startPage != defaultStartPage ? startPage : ""; + + // IE7+ supports secondary home pages located in a REG_MULTI_SZ key. These + // are in addition to the Start Page, and no empty entries are possible, + // thus a Start Page is always defined if any of these exists, though it + // may be the default one. + let secondaryPages = WindowsRegistry.readRegKey(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kMainKey, "Secondary Start Pages"); + if (secondaryPages) { + if (homepage) + secondaryPages.unshift(homepage); + homepage = secondaryPages.join("|"); + } + + return homepage; + } +}); + +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}"); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([IEProfileMigrator]); diff --git a/browser/components/migration/MSMigrationUtils.jsm b/browser/components/migration/MSMigrationUtils.jsm new file mode 100644 index 000000000..1e0250b06 --- /dev/null +++ b/browser/components/migration/MSMigrationUtils.jsm @@ -0,0 +1,889 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["MSMigrationUtils"]; + +const { classes: Cc, interfaces: Ci, utils: Cu, results: Cr } = Components; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); + +Cu.importGlobalProperties(["FileReader"]); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); +XPCOMUtils.defineLazyModuleGetter(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]; + +Cu.importGlobalProperties(["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.default_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.getNext(); + subDir.QueryInterface(Ci.nsIFile); + 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; + }, + + migrate: function B_migrate(aCallback) { + return Task.spawn(function* () { + // Import to the bookmarks menu. + let folderGuid = PlacesUtils.bookmarks.menuGuid; + if (!MigrationUtils.isStartupMigration) { + folderGuid = + yield MigrationUtils.createImportedBookmarksFolder(this.importedAppLabel, folderGuid); + } + yield this._migrateFolder(this._favoritesFolder, folderGuid); + }.bind(this)).then(() => aCallback(true), + e => { Cu.reportError(e); aCallback(false) }); + }, + + _migrateFolder: Task.async(function* (aSourceFolder, aDestFolderGuid) { + // 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 succeeded = true; + while (entries.hasMoreElements()) { + let entry = entries.getNext().QueryInterface(Ci.nsIFile); + 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 folderGuid; + if (entry.leafName == this._toolbarFolderName && + entry.parent.equals(this._favoritesFolder)) { + // Import to the bookmarks toolbar. + folderGuid = PlacesUtils.bookmarks.toolbarGuid; + if (!MigrationUtils.isStartupMigration) { + folderGuid = + yield MigrationUtils.createImportedBookmarksFolder(this.importedAppLabel, folderGuid); + } + } + else { + // Import to a new folder. + folderGuid = (yield MigrationUtils.insertBookmarkWrapper({ + type: PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid: aDestFolderGuid, + title: entry.leafName + })).guid; + } + + if (entry.isReadable()) { + // Recursively import the folder. + yield this._migrateFolder(entry, folderGuid); + } + } + 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); + let title = matches[1]; + + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid: aDestFolderGuid, url: uri, title + }); + } + } + } catch (ex) { + Components.utils.reportError("Unable to import " + this.importedAppLabel + " favorite (" + entry.leafName + "): " + ex); + succeeded = false; + } + } + if (!succeeded) { + throw new Error("Failed to import all bookmarks correctly."); + } + }), + +}; + +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()) { + // Check if UAC is enabled. + if (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.getNext().QueryInterface(Ci.nsIFile); + // Skip eventual bogus entries. + if (!entry.isFile() || !/\.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) { + let fileReader = new FileReader(); + let onLoadEnd = () => { + fileReader.removeEventListener("loadend", onLoadEnd, false); + + 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) { + Components.utils.reportError("Unable to migrate cookie: " + ex); + success = false; + } finally { + aCallback(success); + } + }; + fileReader.addEventListener("loadend", onLoadEnd, false); + fileReader.readAsText(File.createFromNsIFile(aFile)); + }, + + /** + * 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 > 0) { + 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 == 0) + 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 > 0) { + // Fist delete any possible extant matching host cookie. + Services.cookies.remove(host, name, path, false, {}); + // 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, + {}); + } + } +}; + +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 { + typedURLKey.open(Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLs", + Ci.nsIWindowsRegKey.ACCESS_READ); + 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); + let timeTyped = 0; + 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: + timeTyped = cTypes.fileTimeToSecondsSinceEpoch(hi, lo); + // Callers expect PRTime, which is microseconds since epoch: + timeTyped *= 1000 * 1000; + } catch (ex) { + // Ignore conversion exceptions. Callers will have to deal + // with the fallback value (0). + } + } + } + typedURLs.set(url, timeTyped); + } + } 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. + */ + 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); + } + 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, null, null); + } catch (ex) { /* leave realURL as null */ } + if (!realURL || ["http", "https", "ftp"].indexOf(realURL.scheme) == -1) { + // 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 + let login = { + username, password, + hostname: realURL.prePath, + timeCreated: creation, + }; + MigrationUtils.insertLoginWrapper(login); + + // 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(); + } + } + } 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: 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 000000000..104efe005 --- /dev/null +++ b/browser/components/migration/MigrationUtils.jsm @@ -0,0 +1,1117 @@ +/* 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"; + +this.EXPORTED_SYMBOLS = ["MigrationUtils", "MigratorPrototype"]; + +const { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; +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"; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +Cu.importGlobalProperties(["URL"]); + +XPCOMUtils.defineLazyModuleGetter(this, "AutoMigrate", + "resource:///modules/AutoMigrate.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "BookmarkHTMLUtils", + "resource://gre/modules/BookmarkHTMLUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "LoginHelper", + "resource://gre/modules/LoginHelper.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PromiseUtils", + "resource://gre/modules/PromiseUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ResponsivenessMonitor", + "resource://gre/modules/ResponsivenessMonitor.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Sqlite", + "resource://gre/modules/Sqlite.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "TelemetryStopwatch", + "resource://gre/modules/TelemetryStopwatch.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); + +var gMigrators = null; +var gProfileStartup = null; +var gMigrationBundle = null; +var gPreviousDefaultBrowserKey = ""; + +let gKeepUndoData = false; +let gUndoData = null; + +XPCOMUtils.defineLazyGetter(this, "gAvailableMigratorKeys", function() { + if (AppConstants.platform == "win") { + return [ + "firefox", "edge", "ie", "chrome", "chromium", "360se", + "canary" + ]; + } + if (AppConstants.platform == "macosx") { + return ["firefox", "safari", "chrome", "chromium", "canary"]; + } + if (AppConstants.XP_UNIX) { + return ["firefox", "chrome", "chromium"]; + } + return []; +}); + +function getMigrationBundle() { + if (!gMigrationBundle) { + gMigrationBundle = Services.strings.createBundle( + "chrome://browser/locale/migration/migration.properties"); + } + return gMigrationBundle; +} + +/** + * 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. If the migrator supports reading the home page of the source browser, + * override |sourceHomePageURL| getter. + * 7. For startup-only migrators, override |startupOnlyMigrator|. + */ +this.MigratorPrototype = { + QueryInterface: XPCOMUtils.generateQI([Ci.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. + */ + get sourceProfiles() { + 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 AND ONLY IF your migrator supports importing the homepage. + * @see nsIBrowserProfileMigrator + */ + get sourceHomePageURL() { + return ""; + }, + + /** + * 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: function MP_getMigrateData(aProfile) { + let resources = this._getMaybeCachedResources(aProfile); + if (!resources) { + return []; + } + 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: function MP_migrate(aItems, aStartup, aProfile) { + let resources = this._getMaybeCachedResources(aProfile); + if (resources.length == 0) + 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.mainThread.dispatch(resolve, Ci.nsIThread.DISPATCH_NORMAL); + }); + }; + + 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 = Task.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); + } + + // Certain resources must be ran sequentially or they could fail, + // for example bookmarks and history (See bug 1272652). + if (migrationType == MigrationUtils.resourceTypes.BOOKMARKS || + migrationType == MigrationUtils.resourceTypes.HISTORY) { + yield completeDeferred.promise; + } + + yield unblockMainThread(); + } + } + }); + + if (MigrationUtils.isStartupMigration && !this.startupOnlyMigrator) { + 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. + Task.spawn(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. + yield BookmarkHTMLUtils.importFromURL( + "chrome://browser/locale/bookmarks.html", true).catch(r => r); + + // 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, false); + }); + browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, ""); + yield placesInitedPromise; + doMigrate(); + }); + return; + } + doMigrate(); + }, + + /** + * DO NOT OVERRIDE - After deCOMing migration, this code + * won't be part of the migrator itself. + * + * @see nsIBrowserProfileMigrator + */ + get sourceExists() { + 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 = this.sourceProfiles; + if (!profiles) { + let resources = this._getMaybeCachedResources(""); + if (resources && resources.length > 0) + exists = true; + } + else { + exists = profiles.length > 0; + } + } + catch (ex) { + Cu.reportError(ex); + } + return exists; + }, + + /** * PRIVATE STUFF - DO NOT OVERRIDE ***/ + _getMaybeCachedResources: 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] = this.getResources(aProfile); + return this._resourcesByProfile[profileKey]; + } +}; + +this.MigrationUtils = Object.freeze({ + resourceTypes: { + SETTINGS: Ci.nsIBrowserProfileMigrator.SETTINGS, + 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 a string from the migration bundle. Shorthand for + * nsIStringBundle.GetStringFromName, if aReplacements isn't passed, or for + * nsIStringBundle.formatStringFromName if it is. + * + * This method also takes care of "bumped" keys (See bug 737381 comment 8 for + * details). + * + * @param aKey + * The key of the string to retrieve. + * @param aReplacements + * [optioanl] Array of replacements to run on the retrieved string. + * @return the retrieved string. + * + * @see nsIStringBundle + */ + getLocalizedString: function MU_getLocalizedString(aKey, aReplacements) { + aKey = aKey.replace(/_(canary|chromium)$/, "_chrome"); + + const OVERRIDES = { + "4_firefox": "4_firefox_history_and_bookmarks", + "64_firefox": "64_firefox_other" + }; + aKey = OVERRIDES[aKey] || aKey; + + if (aReplacements === undefined) + return getMigrationBundle().GetStringFromName(aKey); + return getMigrationBundle().formatStringFromName( + aKey, aReplacements, aReplacements.length); + }, + + _getLocalePropertyForBrowser(browserId) { + switch (browserId) { + case "edge": + return "sourceNameEdge"; + case "ie": + return "sourceNameIE"; + case "safari": + return "sourceNameSafari"; + case "canary": + return "sourceNameCanary"; + case "chrome": + return "sourceNameChrome"; + case "chromium": + return "sourceNameChromium"; + case "firefox": + return "sourceNameFirefox"; + case "360se": + return "sourceName360se"; + } + return null; + }, + + getBrowserName(browserId) { + let prop = this._getLocalePropertyForBrowser(browserId); + if (prop) { + return this.getLocalizedString(prop); + } + 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. + */ + createImportedBookmarksFolder: Task.async(function* (sourceNameStr, parentGuid) { + let source = this.getLocalizedString("sourceName" + sourceNameStr); + let title = this.getLocalizedString("importedBookmarksFolder", [source]); + return (yield 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 Task.spawn(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 = yield Sqlite.openConnection(dbOptions); + didOpen = true; + rows = yield db.execute(selectQuery); + } catch (ex) { + if (!exceptionSeen) { + Cu.reportError(ex); + } + exceptionSeen = ex; + } finally { + try { + if (didOpen) { + yield db.close(); + } + } catch (ex) {} + } + if (exceptionSeen) { + yield 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; + }, + + /* + * 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. + * Supported values: ie (windows), + * edge (windows), + * safari (mac), + * canary (mac/windows), + * chrome (mac/windows/linux), + * chromium (mac/windows/linux), + * 360se (windows), + * firefox. + * + * 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: 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 && migrator.sourceExists ? 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. + 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, false); + } + } else { + params = aParams; + } + + Services.ww.openWindow(aOpener, + "chrome://browser/content/migration/migration.xul", + "_blank", + features, + params); + }, + + /** + * Show the migration wizard for startup-migration. This should only be + * called by ProfileMigrator (see ProfileMigrator.js), which implements + * nsIProfileMigrator. + * + * @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.xul). + * @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) { + 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 = 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 = this.getMigrator(defaultBrowserKey); + if (migrator) + migratorKey = defaultBrowserKey; + } + } + + if (!migrator) { + // 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 (!gAvailableMigratorKeys.some(key => !!this.getMigrator(key))) { + // None of the keys produced a usable migrator, so finish up here: + this.finishMigration(); + return; + } + } + + let isRefresh = migrator && skipSourcePage && + migratorKey == AppConstants.MOZ_APP_NAME; + + if (!isRefresh && AutoMigrate.enabled) { + try { + AutoMigrate.migrate(aProfileStartup, migratorKey, aProfileToMigrate); + return; + } catch (ex) { + // If automigration failed, continue and show the dialog. + Cu.reportError(ex); + } + } + + 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, + }, + + 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; + }); + }, + + insertVisitsWrapper(places, options) { + this._importQuantities.history += places.length; + if (gKeepUndoData) { + this._updateHistoryUndo(places); + } + return PlacesUtils.asyncHistory.updatePlaces(places, options); + }, + + insertLoginWrapper(login) { + this._importQuantities.logins++; + let insertedLogin = LoginHelper.maybeImportLogin(login); + // 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 (insertedLogin && gKeepUndoData) { + let {guid, timePasswordChanged} = insertedLogin; + gUndoData.get("logins").push({guid, timePasswordChanged}); + } + }, + + initializeUndoData() { + gKeepUndoData = true; + gUndoData = new Map([["bookmarks", []], ["visits", []], ["logins", []]]); + }, + + _postProcessUndoData: Task.async(function*(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), () => {}); + }); + + yield 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(places) { + let visits = gUndoData.get("visits"); + let visitMap = new Map(visits.map(v => [v.url, v])); + for (let place of places) { + let visitCount = place.visits.length; + let first = Math.min.apply(Math, place.visits.map(v => v.visitDate)); + let last = Math.max.apply(Math, place.visits.map(v => v.visitDate)); + let url = place.uri.spec; + 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; + gMigrationBundle = null; + }, + + gAvailableMigratorKeys, + + MIGRATION_ENTRYPOINT_UNKNOWN: 0, + MIGRATION_ENTRYPOINT_FIRSTRUN: 1, + MIGRATION_ENTRYPOINT_FXREFRESH: 2, + MIGRATION_ENTRYPOINT_PLACES: 3, + MIGRATION_ENTRYPOINT_PASSWORDS: 4, + + _sourceNameToIdMapping: { + "nothing": 1, + "firefox": 2, + "edge": 3, + "ie": 4, + "chrome": 5, + "chromium": 6, + "canary": 7, + "safari": 8, + "360se": 9, + }, + getSourceIdForTelemetry(sourceName) { + return this._sourceNameToIdMapping[sourceName] || 0; + }, +}); diff --git a/browser/components/migration/ProfileMigrator.js b/browser/components/migration/ProfileMigrator.js new file mode 100644 index 000000000..f67823bae --- /dev/null +++ b/browser/components/migration/ProfileMigrator.js @@ -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"; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource:///modules/MigrationUtils.jsm"); + +function ProfileMigrator() { +} + +ProfileMigrator.prototype = { + migrate: MigrationUtils.startupMigration.bind(MigrationUtils), + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIProfileMigrator]), + classDescription: "Profile Migrator", + contractID: "@mozilla.org/toolkit/profile-migrator;1", + classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE") +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([ProfileMigrator]); diff --git a/browser/components/migration/SafariProfileMigrator.js b/browser/components/migration/SafariProfileMigrator.js new file mode 100644 index 000000000..6a2dbfcb1 --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.js @@ -0,0 +1,650 @@ +/* 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 Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); /* globals OS */ +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); /* globals MigratorPrototype */ + +XPCOMUtils.defineLazyModuleGetter(this, "Downloads", + "resource://gre/modules/Downloads.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PropertyListUtils", + "resource://gre/modules/PropertyListUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "FormHistory", + "resource://gre/modules/FormHistory.jsm"); + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return Task.spawn(function* () { + let dict = yield 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; + yield this._migrateCollection(children, collection); + }.bind(this)).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. + */ + _migrateCollection: Task.async(function* (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") + yield this._migrateCollection(children, this.TOOLBAR_COLLECTION); + else if (title == "BookmarksMenu") + yield this._migrateCollection(children, this.MENU_COLLECTION); + else if (title == "com.apple.ReadingList") + yield 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 == 0) + 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; + break; + } + case this.MENU_COLLECTION: { + folderGuid = PlacesUtils.bookmarks.menuGuid; + if (!MigrationUtils.isStartupMigration) { + folderGuid = + yield MigrationUtils.createImportedBookmarksFolder("Safari", folderGuid); + } + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = PlacesUtils.bookmarks.toolbarGuid; + if (!MigrationUtils.isStartupMigration) { + folderGuid = + yield MigrationUtils.createImportedBookmarksFolder("Safari", folderGuid); + } + 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). + folderGuid = (yield MigrationUtils.insertBookmarkWrapper({ + parentGuid: PlacesUtils.bookmarks.menuGuid, + type: PlacesUtils.bookmarks.TYPE_FOLDER, + title: MigrationUtils.getLocalizedString("importedSafariReadingList"), + })).guid; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) + throw new Error("Invalid folder GUID"); + + yield this._migrateEntries(entriesFiltered, folderGuid); + }), + + // migrate the given array of safari bookmarks to the given places + // folder. + _migrateEntries: Task.async(function* (entries, parentGuid) { + for (let entry of entries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let newFolderGuid = (yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, type: PlacesUtils.bookmarks.TYPE_FOLDER, title + })).guid; + + // Empty folders may not have a children array. + if (entry.has("Children")) + yield this._migrateEntries(entry.get("Children"), newFolderGuid, false); + } + else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + let title; + if (entry.has("URIDictionary")) + title = entry.get("URIDictionary").get("title"); + + try { + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid, url: entry.get("URLString"), title + }); + } catch (ex) { + Cu.reportError("Invalid Safari bookmark: " + ex); + } + } + } + }) +}; + +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 * 1000; + } + return 0; + }, + + migrate: function H_migrate(aCallback) { + PropertyListUtils.read(this._file, function migrateHistory(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"); + + // Safari's History file contains only top-level urls. It does not + // distinguish between typed urls and linked urls. + let transType = PlacesUtils.history.TRANSITION_LINK; + + let places = []; + let entries = aDict.get("WebHistoryDates"); + for (let entry of entries) { + if (entry.has("lastVisitedDate")) { + let visitDate = this._parseCocoaDate(entry.get("lastVisitedDate")); + try { + places.push({ uri: NetUtil.newURI(entry.get("")), + title: entry.get("title"), + visits: [{ transitionType: transType, + visitDate: visitDate }] }); + } + catch (ex) { + // Safari's History file may contain malformed URIs which + // will be ignored. + Cu.reportError(ex); + } + } + } + if (places.length > 0) { + MigrationUtils.insertVisitsWrapper(places, { + _success: false, + handleResult: function() { + // Importing any entry is considered a successful import. + this._success = true; + }, + handleError: function() {}, + handleCompletion: function() { + aCallback(this._success); + } + }); + } + else { + aCallback(false); + } + } + catch (ex) { + Cu.reportError(ex); + aCallback(false); + } + }.bind(this)); + } +}; + +/** + * 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 > 0; + this._callbacks.push(aCallback); + if (!alreadyReading) { + PropertyListUtils.read(this._file, function readPrefs(aDict) { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } + catch (ex) { + Cu.reportError(ex); + } + } + this._callbacks.splice(0); + }.bind(this)); + } + }, + + // Workaround for nsIBrowserProfileMigrator.sourceHomePageURL until + // it's replaced with an async method. + _readSync: function MPPL__readSync() { + if ("_dict" in this) + return this._dict; + + let inputStream = Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(Ci.nsIFileInputStream); + inputStream.init(this._file, -1, -1, 0); + let binaryStream = Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(Ci.nsIBinaryInputStream); + binaryStream.setInputStream(inputStream); + let bytes = binaryStream.readByteArray(inputStream.available()); + this._dict = PropertyListUtils._readFromArrayBufferSync( + new Uint8Array(bytes).buffer); + return this._dict; + } +}; + +function Preferences(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +Preferences.prototype = { + type: MigrationUtils.resourceTypes.SETTINGS, + + migrate: function MPR_migrate(aCallback) { + this._mainPreferencesPropertyList.read(aDict => { + Task.spawn(function* () { + if (!aDict) + throw new Error("Could not read preferences file"); + + this._dict = aDict; + + let invert = webkitVal => !webkitVal; + this._set("AutoFillPasswords", "signon.rememberSignons"); + this._set("OpenNewTabsInFront", "browser.tabs.loadInBackground", invert); + this._set("WebKitJavaScriptCanOpenWindowsAutomatically", + "dom.disable_open_during_load", invert); + + // layout.spellcheckDefault is a boolean stored as a number. + this._set("WebContinuousSpellCheckingEnabled", + "layout.spellcheckDefault", Number); + + // Auto-load images + // Firefox has an elaborate set of Image preferences. The correlation is: + // Mode: Safari Firefox + // Blocked FALSE 2 + // Allowed TRUE 1 + // Allowed, originating site only -- 3 + this._set("WebKitDisplayImagesKey", "permissions.default.image", + webkitVal => webkitVal ? 1 : 2); + + this._migrateFontSettings(); + yield this._migrateDownloadsFolder(); + }.bind(this)).then(() => aCallback(true), ex => { + Cu.reportError(ex); + aCallback(false); + }).catch(Cu.reportError); + }); + }, + + /** + * Attempts to migrates a preference from Safari. Returns whether the preference + * has been migrated. + * @param aSafariKey + * The dictionary key for the preference of Safari. + * @param aMozPref + * The gecko/firefox preference to which aSafariKey should be migrated + * @param [optional] aConvertFunction(aSafariValue) + * a function that converts the safari-preference value to the + * appropriate value for aMozPref. If it's not passed, then the + * Safari value is set as is. + * If aConvertFunction returns undefined, then aMozPref is not set + * at all. + * @return whether or not aMozPref was set. + */ + _set: function MPR_set(aSafariKey, aMozPref, aConvertFunction) { + if (this._dict.has(aSafariKey)) { + let safariVal = this._dict.get(aSafariKey); + let mozVal = aConvertFunction !== undefined ? + aConvertFunction(safariVal) : safariVal; + switch (typeof mozVal) { + case "string": + Services.prefs.setCharPref(aMozPref, mozVal); + break; + case "number": + Services.prefs.setIntPref(aMozPref, mozVal); + break; + case "boolean": + Services.prefs.setBoolPref(aMozPref, mozVal); + break; + case "undefined": + return false; + default: + throw new Error("Unexpected value type: " + (typeof mozVal)); + } + } + return true; + }, + + // Fonts settings are quite problematic for migration, for a couple of + // reasons: + // (a) Every font preference in Gecko is set for a particular language. + // In Safari, each font preference applies to all languages. + // (b) The current underlying implementation of nsIFontEnumerator cannot + // really tell you anything about a font: no matter what language or type + // you try to enumerate with EnumerateFonts, you get an array of all + // fonts in the systems (This also breaks our fonts dialog). + // (c) In Gecko, each langauge has a distinct serif and sans-serif font + // preference. Safari has only one default font setting. It seems that + // it checks if it's a serif or sans serif font, and when a site + // explicitly asks to use serif/sans-serif font, it uses the default font + // only if it applies to this type. + // (d) The solution of guessing the lang-group out of the default charset (as + // done in the old Safari migrator) can only work when: + // (1) The default charset preference is set. + // (2) It's not a unicode charset. + // For now, we use the language implied by the system locale as the + // lang-group. The only exception is minimal font size, which is an + // accessibility preference in Safari (under the Advanced tab). If it is set, + // we set it for all languages. + // As for the font type of the default font (serif/sans-serif), the default + // type for the given language is used (set in font.default.LANGGROUP). + _migrateFontSettings: function MPR__migrateFontSettings() { + // If "Never use font sizes smaller than [ ] is set", migrate it for all + // languages. + if (this._dict.has("WebKitMinimumFontSize")) { + let minimumSize = this._dict.get("WebKitMinimumFontSize"); + if (typeof minimumSize == "number") { + let prefs = Services.prefs.getChildList("font.minimum-size"); + for (let pref of prefs) { + Services.prefs.setIntPref(pref, minimumSize); + } + } + else { + Cu.reportError("WebKitMinimumFontSize was set to an invalid value: " + + minimumSize); + } + } + + // In theory, the lang group could be "x-unicode". This will result + // in setting the fonts for "Other Languages". + let lang = this._getLocaleLangGroup(); + + let anySet = false; + let fontType = Services.prefs.getCharPref("font.default." + lang); + anySet |= this._set("WebKitFixedFont", "font.name.monospace." + lang); + anySet |= this._set("WebKitDefaultFixedFontSize", "font.size.fixed." + lang); + anySet |= this._set("WebKitStandardFont", + "font.name." + fontType + "." + lang); + anySet |= this._set("WebKitDefaultFontSize", "font.size.variable." + lang); + + // If we set font settings for a particular language, we'll also set the + // fonts dialog to open with the fonts settings for that langauge. + if (anySet) + Services.prefs.setCharPref("font.language.group", lang); + }, + + // Get the language group for the system locale. + _getLocaleLangGroup: function MPR__getLocaleLangGroup() { + let locale = Services.locale.getLocaleComponentForUserAgent(); + + // See nsLanguageAtomService::GetLanguageGroup + let localeLangGroup = "x-unicode"; + let bundle = Services.strings.createBundle( + "resource://gre/res/langGroups.properties"); + try { + localeLangGroup = bundle.GetStringFromName(locale); + } + catch (ex) { + let hyphenAt = locale.indexOf("-"); + if (hyphenAt != -1) { + try { + localeLangGroup = bundle.GetStringFromName(locale.substr(0, hyphenAt)); + } + catch (ex2) { } + } + } + return localeLangGroup; + }, + + _migrateDownloadsFolder: Task.async(function* () { + if (!this._dict.has("DownloadsPath")) + return; + + let downloadsFolder = FileUtils.File(this._dict.get("DownloadsPath")); + + // If the download folder is set to the Desktop or to ~/Downloads, set the + // folderList pref appropriately so that "Desktop"/Downloads is shown with + // pretty name in the preferences dialog. + let folderListVal = 2; + if (downloadsFolder.equals(FileUtils.getDir("Desk", []))) { + folderListVal = 0; + } + else { + let systemDownloadsPath = yield Downloads.getSystemDownloadsDirectory(); + let systemDownloadsFolder = FileUtils.File(systemDownloadsPath); + if (downloadsFolder.equals(systemDownloadsFolder)) + folderListVal = 1; + } + Services.prefs.setIntPref("browser.download.folderList", folderListVal); + Services.prefs.setComplexValue("browser.download.dir", Ci.nsILocalFile, + downloadsFolder); + }), +}; + +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 > 0) { + let changes = recentSearchStrings.map((searchString) => ( + {op: "add", + fieldname: "searchbar-history", + value: searchString})); + FormHistory.update(changes); + } + } + }.bind(this), aCallback)); + } +}; + +// On OS X, the cookie-accept policy preference is stored in a separate +// property list. +function WebFoundationCookieBehavior(aWebFoundationFile) { + this._file = aWebFoundationFile; +} +WebFoundationCookieBehavior.prototype = { + type: MigrationUtils.resourceTypes.SETTINGS, + + migrate: function WFPL_migrate(aCallback) { + PropertyListUtils.read(this._file, MigrationUtils.wrapMigrateFunction( + function migrateCookieBehavior(aDict) { + if (!aDict) + throw new Error("Could not read com.apple.WebFoundation.plist"); + + if (aDict.has("NSHTTPAcceptCookies")) { + // Setting Safari Firefox + // Always Accept always 0 + // Accept from Originating current page 1 + // Never Accept never 2 + let acceptCookies = aDict.get("NSHTTPAcceptCookies"); + let cookieValue = 0; + if (acceptCookies == "never") { + cookieValue = 2; + } else if (acceptCookies == "current page") { + cookieValue = 1; + } + Services.prefs.setIntPref("network.cookie.cookieBehavior", + cookieValue); + } + }.bind(this), 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 Preferences(prefs)); + resources.push(new SearchStrings(prefs)); + } + + let wfFile = FileUtils.getFile("UsrPrfs", ["com.apple.WebFoundation.plist"]); + if (wfFile.exists()) + resources.push(new WebFoundationCookieBehavior(wfFile)); + + 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)); + }); +}; + +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; + } +}); + +Object.defineProperty(SafariProfileMigrator.prototype, "sourceHomePageURL", { + get: function get_sourceHomePageURL() { + if (this.mainPreferencesPropertyList) { + let dict = this.mainPreferencesPropertyList._readSync(); + if (dict.has("HomePage")) + return dict.get("HomePage"); + } + return ""; + } +}); + +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}"); + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([SafariProfileMigrator]); diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 000000000..d9fdb6c2c --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,82 @@ +<?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; + <!ENTITY % netErrorDTD SYSTEM "chrome://global/locale/netError.dtd"> + %netErrorDTD; + <!ENTITY % globalDTD SYSTEM "chrome://global/locale/global.dtd"> + %globalDTD; + <!ENTITY % restorepageDTD SYSTEM "chrome://browser/locale/aboutSessionRestore.dtd"> + %restorepageDTD; +]> + +<html xmlns="http://www.w3.org/1999/xhtml" xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <head> + <title>&welcomeback2.tabtitle;</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://global/skin/icons/information-16.png"/> + + <script type="application/javascript;version=1.8" src="chrome://browser/content/aboutSessionRestore.js"/> + </head> + + <body dir="&locale.dir;"> + + <div class="container"> + + <div class="title"> + <h1 class="title-text">&welcomeback2.pageTitle;</h1> + </div> + + <div class="description"> + + <p>&welcomeback2.pageInfo1;</p> + <!-- Note a href in the anchor below is added by JS --> + <p>&welcomeback2.beforelink.pageInfo2;<a id="linkMoreTroubleshooting" target="_blank">&welcomeback2.link.pageInfo2;</a>&welcomeback2.afterlink.pageInfo2;</p> + + <div> + <div class="radioRestoreContainer"> + <input class="radioRestoreButton" id="radioRestoreAll" type="radio" + name="restore" checked="checked"/> + <label class="radioRestoreLabel" for="radioRestoreAll">&welcomeback2.label.restoreAll;</label> + </div> + + <div class="radioRestoreContainer"> + <input class="radioRestoreButton" id="radioRestoreChoose" type="radio" + name="restore"/> + <label class="radioRestoreLabel" for="radioRestoreChoose">&welcomeback2.label.restoreSome;</label> + </div> + </div> + </div> + + <div class="tree-container"> + <xul:tree id="tabList" flex="1" seltype="single" hidecolumnpicker="true" + onclick="onListClick(event);" onkeydown="onListKeyDown(event);" + _window_label="&restorepage.windowLabel;"> + <xul:treecols> + <xul:treecol cycler="true" id="restore" type="checkbox" label="&restorepage.restoreHeader;"/> + <xul:splitter class="tree-splitter"/> + <xul:treecol primary="true" id="title" label="&restorepage.listHeader;" flex="1"/> + </xul:treecols> + <xul:treechildren flex="1"/> + </xul:tree> + </div> + + <div class="button-container"> + <xul:button class="primary" + id="errorTryAgain" + label="&welcomeback2.restoreButton;" + accesskey="&welcomeback2.restoreButton.access;" + oncommand="restoreSession();"/> + </div> + + <input type="text" id="sessionData" style="display: none;"/> + + </div> + </body> +</html> diff --git a/browser/components/migration/content/extra-migration-strings.properties b/browser/components/migration/content/extra-migration-strings.properties new file mode 100644 index 000000000..208906b31 --- /dev/null +++ b/browser/components/migration/content/extra-migration-strings.properties @@ -0,0 +1,14 @@ +# Automigration undo notification. +# %1$S will be replaced with the name of the browser we imported from, %2$S will be replaced with brandShortName +automigration.undo.message.all = Pick up where you left off. We’ve imported these sites and your bookmarks, history and passwords from %1$S into %2$S. +automigration.undo.message.bookmarks = Pick up where you left off. We’ve imported these sites and your bookmarks from %1$S into %2$S. +automigration.undo.message.bookmarks.logins = Pick up where you left off. We’ve imported these sites and your bookmarks and passwords from %1$S into %2$S. +automigration.undo.message.bookmarks.visits = Pick up where you left off. We’ve imported these sites and your bookmarks and history from %1$S into %2$S. +automigration.undo.message.logins = Pick up where you left off. We’ve imported your passwords from %1$S into %2$S. +automigration.undo.message.logins.visits = Pick up where you left off. We’ve imported these sites and your history and passwords from %1$S into %2$S. +automigration.undo.message.visits = Pick up where you left off. We’ve imported these sites and your history from %1$S into %2$S. +automigration.undo.keep2.label = OK, Got it +automigration.undo.keep2.accesskey = O +automigration.undo.dontkeep2.label = No Thanks +automigration.undo.dontkeep2.accesskey = N +automigration.undo.unknownbrowser = Unknown Browser diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js new file mode 100644 index 000000000..eb2175628 --- /dev/null +++ b/browser/components/migration/content/migration.js @@ -0,0 +1,549 @@ +/* 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 Cc = Components.classes; +var Ci = Components.interfaces; +var Cu = Components.utils; + +const kIMig = Ci.nsIBrowserProfileMigrator; +const kIPStartup = Ci.nsIProfileStartup; + +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource:///modules/MigrationUtils.jsm"); + +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, + + init: function () + { + let os = Services.obs; + os.addObserver(this, "Migration:Started", false); + os.addObserver(this, "Migration:ItemBeforeMigrate", false); + os.addObserver(this, "Migration:ItemAfterMigrate", false); + os.addObserver(this, "Migration:ItemError", false); + os.addObserver(this, "Migration:Ended", false); + + this._wiz = document.documentElement; + + 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; + + if (args.length > 1) { + 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._migrator.sourceProfiles; + 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.onImportSourcePageShow(); + }, + + uninit: function () + { + var os = Components.classes["@mozilla.org/observer-service;1"] + .getService(Components.interfaces.nsIObserverService); + 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(); + }, + + // 1 - Import Source + onImportSourcePageShow: function () + { + // Show warning message to close the selected browser when needed + function toggleCloseBrowserWarning() { + let visibility = "hidden"; + if (group.selectedItem.id != "nothing") { + let migrator = 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 = 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("importBookmarks").hidden = true; + 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: function () + { + 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")); + document.documentElement.cancel(); + return false; + } + + if (!this._migrator || (newSource != this._source)) { + // Create the migrator for the selected source. + this._migrator = MigrationUtils.getMigrator(newSource); + + this._itemsFlags = kIMig.ALL; + this._selectedProfile = null; + } + this._source = newSource; + + // check for more than one source profile + var sourceProfiles = this._migrator.sourceProfiles; + if (this._skipImportSourcePage) { + this._wiz.currentPage.next = "homePageImport"; + } + else if (sourceProfiles && sourceProfiles.length > 1) { + this._wiz.currentPage.next = "selectProfile"; + } + else { + if (this._autoMigrate) + this._wiz.currentPage.next = "homePageImport"; + else + this._wiz.currentPage.next = "importItems"; + + if (sourceProfiles && sourceProfiles.length == 1) + this._selectedProfile = sourceProfiles[0]; + else + this._selectedProfile = null; + } + return undefined; + }, + + // 2 - [Profile Selection] + onSelectProfilePageShow: function () + { + // 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.removeChild(profiles.firstChild); + + // 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._migrator.sourceProfiles; + + for (let profile of sourceProfiles) { + var item = document.createElement("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: function () + { + var profiles = document.getElementById("profiles"); + this._selectedProfile = this._migrator.sourceProfiles.find( + profile => profile.id == profiles.selectedItem.id + ) || null; + }, + + onSelectProfilePageAdvanced: function () + { + var profiles = document.getElementById("profiles"); + this._selectedProfile = this._migrator.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._wiz.currentPage.next = "homePageImport"; + }, + + // 3 - ImportItems + onImportItemsPageShow: function () + { + var dataSources = document.getElementById("dataSources"); + while (dataSources.hasChildNodes()) + dataSources.removeChild(dataSources.firstChild); + + var items = this._migrator.getMigrateData(this._selectedProfile, this._autoMigrate); + for (var i = 0; i < 16; ++i) { + var itemID = (items >> i) & 0x1 ? Math.pow(2, i) : 0; + if (itemID > 0) { + var checkbox = document.createElement("checkbox"); + checkbox.id = itemID; + checkbox.setAttribute("label", + MigrationUtils.getLocalizedString(itemID + "_" + this._source)); + dataSources.appendChild(checkbox); + if (!this._itemsFlags || this._itemsFlags & itemID) + checkbox.checked = true; + } + } + }, + + onImportItemsPageRewound: function () + { + this._wiz.canAdvance = true; + this.onImportItemsPageAdvanced(); + }, + + onImportItemsPageAdvanced: function () + { + 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); + } + }, + + onImportItemCommand: function () + { + 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; + }, + + // 4 - Home Page Selection + onHomePageMigrationPageShow: function () + { + // only want this on the first run + if (!this._autoMigrate) { + this._wiz.advance(); + return; + } + + var brandBundle = document.getElementById("brandBundle"); + var pageTitle, pageDesc, mainStr; + // These strings don't exist when not using official branding. If that's + // the case, just skip this page. + try { + pageTitle = brandBundle.getString("homePageMigrationPageTitle"); + pageDesc = brandBundle.getString("homePageMigrationDescription"); + mainStr = brandBundle.getString("homePageSingleStartMain"); + } + catch (e) { + this._wiz.advance(); + return; + } + + document.getElementById("homePageImport").setAttribute("label", pageTitle); + document.getElementById("homePageImportDesc").setAttribute("value", pageDesc); + + this._wiz._adjustWizardHeader(); + + var singleStart = document.getElementById("homePageSingleStart"); + singleStart.setAttribute("label", mainStr); + singleStart.setAttribute("value", "DEFAULT"); + + var appName = MigrationUtils.getBrowserName(this._source); + + // semi-wallpaper for crash when multiple profiles exist, since we haven't initialized mSourceProfile in places + this._migrator.getMigrateData(this._selectedProfile, this._autoMigrate); + + var oldHomePageURL = this._migrator.sourceHomePageURL; + + if (oldHomePageURL && appName) { + var oldHomePageLabel = + brandBundle.getFormattedString("homePageImport", [appName]); + var oldHomePage = document.getElementById("oldHomePage"); + oldHomePage.setAttribute("label", oldHomePageLabel); + oldHomePage.setAttribute("value", oldHomePageURL); + oldHomePage.removeAttribute("hidden"); + } + else { + // if we don't have at least two options, just advance + this._wiz.advance(); + } + }, + + onHomePageMigrationPageAdvanced: function () + { + // we might not have a selectedItem if we're in fallback mode + try { + var radioGroup = document.getElementById("homePageRadiogroup"); + + this._newHomePage = radioGroup.selectedItem.value; + } catch (ex) {} + }, + + // 5 - Migrating + onMigratingPageShow: function () + { + 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._migrator.getMigrateData(this._selectedProfile, this._autoMigrate); + + this._listItems("migratingItems"); + setTimeout(() => this.onMigratingMigrate(), 0); + }, + + onMigratingMigrate: function () + { + 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: function (aID) + { + var items = document.getElementById(aID); + while (items.hasChildNodes()) + items.removeChild(items.firstChild); + + var itemID; + for (var i = 0; i < 16; ++i) { + itemID = (this._itemsFlags >> i) & 0x1 ? Math.pow(2, i) : 0; + if (itemID > 0) { + var label = document.createElement("label"); + label.id = itemID + "_migrated"; + try { + label.setAttribute("value", + MigrationUtils.getLocalizedString(itemID + "_" + this._source)); + 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: function (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) { + let hasImportedHomepage = !!(this._newHomePage && this._newHomePage != "DEFAULT"); + Services.telemetry.getKeyedHistogramById("FX_MIGRATION_IMPORTED_HOMEPAGE") + .add(this._source, hasImportedHomepage); + if (this._newHomePage) { + try { + // set homepage properly + var prefSvc = Components.classes["@mozilla.org/preferences-service;1"] + .getService(Components.interfaces.nsIPrefService); + var prefBranch = prefSvc.getBranch(null); + + if (this._newHomePage == "DEFAULT") { + prefBranch.clearUserPref("browser.startup.homepage"); + } + else { + var str = Components.classes["@mozilla.org/supports-string;1"] + .createInstance(Components.interfaces.nsISupportsString); + str.data = this._newHomePage; + prefBranch.setComplexValue("browser.startup.homepage", + Components.interfaces.nsISupportsString, + str); + } + + var dirSvc = Components.classes["@mozilla.org/file/directory_service;1"] + .getService(Components.interfaces.nsIProperties); + var prefFile = dirSvc.get("ProfDS", Components.interfaces.nsIFile); + prefFile.append("prefs.js"); + prefSvc.savePrefFile(prefFile); + } catch (ex) { + dump(ex); + } + } + + // 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.SETTINGS: + type = "settings"; + break; + 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; + } + Cc["@mozilla.org/consoleservice;1"] + .getService(Ci.nsIConsoleService) + .logStringMessage("some " + type + " did not successfully migrate."); + Services.telemetry.getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(this._source, Math.log2(numericType)); + break; + } + }, + + onDonePageShow: function () + { + 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.xul b/browser/components/migration/content/migration.xul new file mode 100644 index 000000000..e85091002 --- /dev/null +++ b/browser/components/migration/content/migration.xul @@ -0,0 +1,109 @@ +<?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/" type="text/css"?> + +<!DOCTYPE dialog SYSTEM "chrome://browser/locale/migration/migration.dtd" > + +<wizard id="migrationWizard" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + windowtype="Browser:MigrationWizard" + title="&migrationWizard.title;" + onload="MigrationWizard.init()" + onunload="MigrationWizard.uninit()" + style="width: 40em;" + buttons="accept,cancel" + branded="true"> + + <script type="application/javascript" src="chrome://browser/content/migration/migration.js"/> + + <stringbundle id="brandBundle" src="chrome://branding/locale/brand.properties"/> + + <wizardpage id="importSource" pageid="importSource" next="selectProfile" + label="&importSource.title;" + onpageadvanced="return MigrationWizard.onImportSourcePageAdvanced();"> +#ifdef XP_WIN + <description id="importAll" control="importSourceGroup">&importFrom.label;</description> +#else + <description id="importAll" control="importSourceGroup">&importFromUnix.label;</description> +#endif + <description id="importBookmarks" control="importSourceGroup" hidden="true">&importFromBookmarks.label;</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" label="&importFromFirefox.label;" accesskey="&importFromFirefox.accesskey;"/> +#ifdef XP_WIN + <radio id="edge" label="&importFromEdge.label;" accesskey="&importFromEdge.accesskey;"/> + <radio id="ie" label="&importFromIE.label;" accesskey="&importFromIE.accesskey;"/> + <radio id="chrome" label="&importFromChrome.label;" accesskey="&importFromChrome.accesskey;"/> + <radio id="chromium" label="&importFromChromium.label;" accesskey="&importFromChromium.accesskey;"/> + <radio id="canary" label="&importFromCanary.label;" accesskey="&importFromCanary.accesskey;"/> + <radio id="360se" label="&importFrom360se.label;" accesskey="&importFrom360se.accesskey;"/> +#elifdef XP_MACOSX + <radio id="safari" label="&importFromSafari.label;" accesskey="&importFromSafari.accesskey;"/> + <radio id="chrome" label="&importFromChrome.label;" accesskey="&importFromChrome.accesskey;"/> + <radio id="chromium" label="&importFromChromium.label;" accesskey="&importFromChromium.accesskey;"/> + <radio id="canary" label="&importFromCanary.label;" accesskey="&importFromCanary.accesskey;"/> +#elifdef XP_UNIX + <radio id="chrome" label="&importFromChrome.label;" accesskey="&importFromChrome.accesskey;"/> + <radio id="chromium" label="&importFromChromium.label;" accesskey="&importFromChromium.accesskey;"/> +#endif + <radio id="nothing" label="&importFromNothing.label;" accesskey="&importFromNothing.accesskey;" hidden="true"/> + </radiogroup> + <label id="noSources" hidden="true">&noMigrationSources.label;</label> + <spacer flex="1"/> + <description class="header" id="closeSourceBrowser" style="visibility:hidden">&closeSourceBrowser.label;</description> + </wizardpage> + + <wizardpage id="selectProfile" pageid="selectProfile" label="&selectProfile.title;" + next="importItems" + onpageshow="return MigrationWizard.onSelectProfilePageShow();" + onpagerewound="return MigrationWizard.onSelectProfilePageRewound();" + onpageadvanced="return MigrationWizard.onSelectProfilePageAdvanced();"> + <description control="profiles">&selectProfile.label;</description> + + <radiogroup id="profiles" align="left"/> + </wizardpage> + + <wizardpage id="importItems" pageid="importItems" label="&importItems.title;" + next="homePageImport" + onpageshow="return MigrationWizard.onImportItemsPageShow();" + onpagerewound="return MigrationWizard.onImportItemsPageRewound();" + onpageadvanced="return MigrationWizard.onImportItemsPageAdvanced();" + oncommand="MigrationWizard.onImportItemCommand();"> + <description control="dataSources">&importItems.label;</description> + + <vbox id="dataSources" style="overflow: auto; -moz-appearance: listbox" align="left" flex="1" role="group"/> + </wizardpage> + + <wizardpage id="homePageImport" pageid="homePageImport" + next="migrating" + onpageshow="return MigrationWizard.onHomePageMigrationPageShow();" + onpageadvanced="return MigrationWizard.onHomePageMigrationPageAdvanced();"> + + <description id="homePageImportDesc" control="homePageRadioGroup"/> + <radiogroup id="homePageRadiogroup"> + <radio id="homePageSingleStart" selected="true" /> + <radio id="oldHomePage" hidden="true" /> + </radiogroup> + </wizardpage> + + <wizardpage id="migrating" pageid="migrating" label="&migrating.title;" + next="done" + onpageshow="MigrationWizard.onMigratingPageShow();"> + <description control="migratingItems">&migrating.label;</description> + + <vbox id="migratingItems" style="overflow: auto;" align="left" role="group"/> + </wizardpage> + + <wizardpage id="done" pageid="done" label="&done.title;" + onpageshow="MigrationWizard.onDonePageShow();"> + <description control="doneItems">&done.label;</description> + + <vbox id="doneItems" style="overflow: auto;" align="left" role="group"/> + </wizardpage> + +</wizard> + diff --git a/browser/components/migration/jar.mn b/browser/components/migration/jar.mn new file mode 100644 index 000000000..110788bc4 --- /dev/null +++ b/browser/components/migration/jar.mn @@ -0,0 +1,9 @@ +# 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.xul (content/migration.xul) + content/browser/migration/migration.js (content/migration.js) + content/browser/migration/extra-migration-strings.properties (content/extra-migration-strings.properties) + 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 000000000..751ea0cd9 --- /dev/null +++ b/browser/components/migration/moz.build @@ -0,0 +1,62 @@ +# -*- 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'] + +BROWSER_CHROME_MANIFESTS += [ 'tests/browser/browser.ini'] + +JAR_MANIFESTS += ['jar.mn'] + +XPIDL_SOURCES += [ + 'nsIBrowserProfileMigrator.idl', +] + +XPIDL_MODULE = 'migration' + +EXTRA_COMPONENTS += [ + 'ChromeProfileMigrator.js', + 'FirefoxProfileMigrator.js', + 'ProfileMigrator.js', +] + +EXTRA_PP_COMPONENTS += [ + 'BrowserProfileMigrators.manifest', +] + +EXTRA_JS_MODULES += [ + 'AutoMigrate.jsm', + 'MigrationUtils.jsm', +] + +if CONFIG['OS_ARCH'] == 'WINNT': + SOURCES += [ + 'nsIEHistoryEnumerator.cpp', + ] + EXTRA_COMPONENTS += [ + '360seProfileMigrator.js', + 'EdgeProfileMigrator.js', + 'IEProfileMigrator.js', + ] + EXTRA_JS_MODULES += [ + 'ESEDBReader.jsm', + 'MSMigrationUtils.jsm', + ] + DEFINES['HAS_360SE_MIGRATOR'] = True + DEFINES['HAS_IE_MIGRATOR'] = True + DEFINES['HAS_EDGE_MIGRATOR'] = True + +if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'cocoa': + EXTRA_COMPONENTS += [ + 'SafariProfileMigrator.js', + ] + DEFINES['HAS_SAFARI_MIGRATOR'] = True + +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 000000000..a251c3683 --- /dev/null +++ b/browser/components/migration/nsIBrowserProfileMigrator.idl @@ -0,0 +1,77 @@ +/* -*- 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; + const unsigned short SETTINGS = 0x0001; + 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 + * @param aDoingStartup "true" if the profile is not currently being used. + * @return bit field containing profile items (see above) + * @note a return value of 0 represents no items rather than ALL. + */ + unsigned short getMigrateData(in jsval aProfile, in boolean aDoingStartup); + + /** + * Get the last time data from this browser was modified + * @return a promise that resolves to a JS Date object + */ + jsval getLastUsedDate(); + + /** + * 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) + */ + readonly attribute boolean sourceExists; + + + /** + * An enumeration of available profiles. If the import source does + * not support profiles, this attribute is null. + */ + readonly attribute jsval sourceProfiles; + + /** + * The import source homepage. Returns null if not present/available + */ + readonly attribute AUTF8String sourceHomePageURL; + + + /** + * 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 000000000..116e9a860 --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.cpp @@ -0,0 +1,120 @@ +/* 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 "nsCOMArray.h" +#include "nsIURI.h" +#include "nsIVariant.h" +#include "nsNetUtil.h" +#include "nsString.h" +#include "nsWindowsMigrationUtils.h" +#include "prtime.h" + +//////////////////////////////////////////////////////////////////////////////// +//// nsIEHistoryEnumerator + +NS_IMPL_ISUPPORTS(nsIEHistoryEnumerator, nsISimpleEnumerator) + +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(NS_LITERAL_STRING("uri"), uri); + mCachedNextEntry->SetPropertyAsAString(NS_LITERAL_STRING("title"), title); + if (lastVisitTimeIsValid) { + mCachedNextEntry->SetPropertyAsInt64(NS_LITERAL_STRING("time"), lastVisited); + } + + *_retval = true; + } + + if (statURL.pwcsTitle) + ::CoTaskMemFree(statURL.pwcsTitle); + + return NS_OK; +} + +NS_IMETHODIMP +nsIEHistoryEnumerator::GetNext(nsISupports** _retval) +{ + *_retval = nullptr; + + if (!mCachedNextEntry) + 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 000000000..1572a8dd5 --- /dev/null +++ b/browser/components/migration/nsIEHistoryEnumerator.h @@ -0,0 +1,37 @@ +/* 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 "nsISimpleEnumerator.h" +#include "nsIWritablePropertyBag2.h" + +class nsIEHistoryEnumerator final : public nsISimpleEnumerator +{ +public: + NS_DECL_ISUPPORTS + NS_DECL_NSISIMPLEENUMERATOR + + nsIEHistoryEnumerator(); + +private: + ~nsIEHistoryEnumerator(); + + /** + * Initializes the history reader, if needed. + */ + void EnsureInitialized(); + + RefPtr<IUrlHistoryStg2> mIEHistory; + RefPtr<IEnumSTATURL> mURLEnumerator; + + nsCOMPtr<nsIWritablePropertyBag2> mCachedNextEntry; +}; + +#endif diff --git a/browser/components/migration/nsWindowsMigrationUtils.h b/browser/components/migration/nsWindowsMigrationUtils.h new file mode 100644 index 000000000..0288d93d3 --- /dev/null +++ b/browser/components/migration/nsWindowsMigrationUtils.h @@ -0,0 +1,36 @@ +/* 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/browser/.eslintrc.js b/browser/components/migration/tests/browser/.eslintrc.js new file mode 100644 index 000000000..3ea6eeb8c --- /dev/null +++ b/browser/components/migration/tests/browser/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + "extends": [ + "../../../../../testing/mochitest/browser.eslintrc.js", + "../../../../../testing/mochitest/mochitest.eslintrc.js", + ] +}; + diff --git a/browser/components/migration/tests/browser/browser.ini b/browser/components/migration/tests/browser/browser.ini new file mode 100644 index 000000000..94edfe7aa --- /dev/null +++ b/browser/components/migration/tests/browser/browser.ini @@ -0,0 +1,3 @@ +[browser_undo_notification.js] +[browser_undo_notification_wording.js] +[browser_undo_notification_multiple_dismissal.js] diff --git a/browser/components/migration/tests/browser/browser_undo_notification.js b/browser/components/migration/tests/browser/browser_undo_notification.js new file mode 100644 index 000000000..6c97922e0 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_undo_notification.js @@ -0,0 +1,67 @@ +"use strict"; + +let scope = {}; +Cu.import("resource:///modules/AutoMigrate.jsm", scope); +let oldCanUndo = scope.AutoMigrate.canUndo; +let oldUndo = scope.AutoMigrate.undo; +registerCleanupFunction(function() { + scope.AutoMigrate.canUndo = oldCanUndo; + scope.AutoMigrate.undo = oldUndo; +}); + +const kExpectedNotificationId = "automigration-undo"; + +add_task(function* autoMigrationUndoNotificationShows() { + let getNotification = browser => + gBrowser.getNotificationBox(browser).getNotificationWithValue(kExpectedNotificationId); + + scope.AutoMigrate.canUndo = () => true; + let undoCalled; + scope.AutoMigrate.undo = () => { undoCalled = true }; + for (let url of ["about:newtab", "about:home"]) { + undoCalled = false; + // Can't use pushPrefEnv because of bug 1323779 + Services.prefs.setCharPref("browser.migrate.automigrate.browser", "someunknownbrowser"); + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url, false); + let browser = tab.linkedBrowser; + if (!getNotification(browser)) { + info(`Notification for ${url} not immediately present, waiting for it.`); + yield BrowserTestUtils.waitForNotificationBar(gBrowser, browser, kExpectedNotificationId); + } + + ok(true, `Got notification for ${url}`); + let notification = getNotification(browser); + let notificationBox = notification.parentNode; + notification.querySelector("button.notification-button-default").click(); + ok(!undoCalled, "Undo should not be called when clicking the default button"); + is(notification, notificationBox._closedNotification, "Notification should be closing"); + yield BrowserTestUtils.removeTab(tab); + + undoCalled = false; + Services.prefs.setCharPref("browser.migrate.automigrate.browser", "chrome"); + tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url, false); + browser = tab.linkedBrowser; + if (!getNotification(browser)) { + info(`Notification for ${url} not immediately present, waiting for it.`); + yield BrowserTestUtils.waitForNotificationBar(gBrowser, browser, kExpectedNotificationId); + } + + ok(true, `Got notification for ${url}`); + notification = getNotification(browser); + notificationBox = notification.parentNode; + // Set up the survey: + yield SpecialPowers.pushPrefEnv({set: [ + ["browser.migrate.automigrate.undo-survey", "https://example.com/?browser=%IMPORTEDBROWSER%"], + ["browser.migrate.automigrate.undo-survey-locales", "en-US"], + ]}); + let tabOpenedPromise = BrowserTestUtils.waitForNewTab(gBrowser, "https://example.com/?browser=Google%20Chrome"); + notification.querySelector("button:not(.notification-button-default)").click(); + ok(undoCalled, "Undo should be called when clicking the non-default (Don't Keep) button"); + is(notification, notificationBox._closedNotification, "Notification should be closing"); + let surveyTab = yield tabOpenedPromise; + ok(surveyTab, "Should have opened a tab with a survey"); + yield BrowserTestUtils.removeTab(surveyTab); + yield BrowserTestUtils.removeTab(tab); + } +}); + diff --git a/browser/components/migration/tests/browser/browser_undo_notification_multiple_dismissal.js b/browser/components/migration/tests/browser/browser_undo_notification_multiple_dismissal.js new file mode 100644 index 000000000..90b5d0d08 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_undo_notification_multiple_dismissal.js @@ -0,0 +1,122 @@ +"use strict"; + + +const kExpectedNotificationId = "automigration-undo"; + +/** + * Pretend we can undo something, trigger a notification, pick the undo option, + * and verify that the notifications are all dismissed immediately. + */ +add_task(function* checkNotificationsDismissed() { + yield SpecialPowers.pushPrefEnv({set: [ + ["browser.migrate.automigrate.enabled", true], + ["browser.migrate.automigrate.ui.enabled", true], + ]}); + let getNotification = browser => + gBrowser.getNotificationBox(browser).getNotificationWithValue(kExpectedNotificationId); + + Services.prefs.setCharPref("browser.migrate.automigrate.browser", "someunknownbrowser"); + + let {guid, lastModified} = yield PlacesUtils.bookmarks.insert( + {title: "Some imported bookmark", parentGuid: PlacesUtils.bookmarks.toolbarGuid, url: "http://www.example.com"} + ); + + let testUndoData = { + visits: [], + bookmarks: [{guid, lastModified: lastModified.getTime()}], + logins: [], + }; + let path = OS.Path.join(OS.Constants.Path.profileDir, "initialMigrationMetadata.jsonlz4"); + registerCleanupFunction(() => { + return OS.File.remove(path, {ignoreAbsent: true}); + }); + yield OS.File.writeAtomic(path, JSON.stringify(testUndoData), { + encoding: "utf-8", + compression: "lz4", + tmpPath: path + ".tmp", + }); + + let firstTab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:home", false); + if (!getNotification(firstTab.linkedBrowser)) { + info(`Notification not immediately present on first tab, waiting for it.`); + yield BrowserTestUtils.waitForNotificationBar(gBrowser, firstTab.linkedBrowser, kExpectedNotificationId); + } + let secondTab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "about:home", false); + if (!getNotification(secondTab.linkedBrowser)) { + info(`Notification not immediately present on second tab, waiting for it.`); + yield BrowserTestUtils.waitForNotificationBar(gBrowser, secondTab.linkedBrowser, kExpectedNotificationId); + } + + // Create a listener for the removal in the first tab, and a listener for bookmarks removal, + // then click 'Don't keep' in the second tab, and verify that the notification is removed + // before we start removing bookmarks. + let haveRemovedBookmark = false; + let bmObserver; + let bookmarkRemovedPromise = new Promise(resolve => { + bmObserver = { + onItemRemoved(itemId, parentId, index, itemType, uri, removedGuid) { + if (guid == removedGuid) { + haveRemovedBookmark = true; + resolve(); + } + }, + }; + PlacesUtils.bookmarks.addObserver(bmObserver, false); + registerCleanupFunction(() => PlacesUtils.bookmarks.removeObserver(bmObserver)); + }); + + let firstTabNotificationRemovedPromise = new Promise(resolve => { + let notification = getNotification(firstTab.linkedBrowser); + // Save this reference because notification.parentNode will be null once it's removed. + let notificationBox = notification.parentNode; + let mut = new MutationObserver(mutations => { + // Yucky, but we have to detect either the removal via animation (with marginTop) + // or when the element is removed. We can't just detect the element being removed + // because this happens asynchronously (after the animation) and so it'd race + // with the rest of the undo happening. + for (let mutation of mutations) { + if (mutation.target == notification && mutation.attributeName == "style" && + parseInt(notification.style.marginTop, 10) < 0) { + ok(!haveRemovedBookmark, "Should not have removed bookmark yet"); + mut.disconnect(); + resolve(); + return; + } + if (mutation.target == notificationBox && mutation.removedNodes.length && + mutation.removedNodes[0] == notification) { + ok(!haveRemovedBookmark, "Should not have removed bookmark yet"); + mut.disconnect(); + resolve(); + return; + } + } + }); + mut.observe(notification.parentNode, {childList: true}); + mut.observe(notification, {attributes: true}); + }); + + let prefResetPromise = new Promise(resolve => { + const kObservedPref = "browser.migrate.automigrate.browser"; + let obs = () => { + Services.prefs.removeObserver(kObservedPref, obs); + ok(!Services.prefs.prefHasUserValue(kObservedPref), + "Pref should have been reset"); + resolve(); + }; + Services.prefs.addObserver(kObservedPref, obs, false); + }); + + // Click "Don't keep" button: + let notificationToActivate = getNotification(secondTab.linkedBrowser); + notificationToActivate.querySelector("button:not(.notification-button-default)").click(); + info("Waiting for notification to be removed in first (background) tab"); + yield firstTabNotificationRemovedPromise; + info("Waiting for bookmark to be removed"); + yield bookmarkRemovedPromise; + info("Waiting for prefs to be reset"); + yield prefResetPromise; + + info("Removing spare tabs"); + yield BrowserTestUtils.removeTab(firstTab); + yield BrowserTestUtils.removeTab(secondTab); +}); diff --git a/browser/components/migration/tests/browser/browser_undo_notification_wording.js b/browser/components/migration/tests/browser/browser_undo_notification_wording.js new file mode 100644 index 000000000..f0a9ceec9 --- /dev/null +++ b/browser/components/migration/tests/browser/browser_undo_notification_wording.js @@ -0,0 +1,67 @@ +"use strict"; + +let scope = {}; +Cu.import("resource:///modules/AutoMigrate.jsm", scope); +let oldCanUndo = scope.AutoMigrate.canUndo; +registerCleanupFunction(function() { + scope.AutoMigrate.canUndo = oldCanUndo; +}); + +const kExpectedNotificationId = "automigration-undo"; + +add_task(function* autoMigrationUndoNotificationShows() { + let getNotification = browser => + gBrowser.getNotificationBox(browser).getNotificationWithValue(kExpectedNotificationId); + let localizedVersionOf = str => { + if (str == "logins") { + return "passwords"; + } + if (str == "visits") { + return "history"; + } + return str; + }; + + scope.AutoMigrate.canUndo = () => true; + let url = "about:newtab"; + Services.prefs.setCharPref("browser.migrate.automigrate.browser", "someunknownbrowser"); + const kSubsets = [ + ["bookmarks", "logins", "visits"], + ["bookmarks", "logins"], + ["bookmarks", "visits"], + ["logins", "visits"], + ["bookmarks"], + ["logins"], + ["visits"], + ]; + const kAllItems = ["bookmarks", "logins", "visits"]; + for (let subset of kSubsets) { + let state = new Map(subset.map(item => [item, [{}]])); + scope.AutoMigrate._setImportedItemPrefFromState(state); + let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, url, false); + let browser = tab.linkedBrowser; + + if (!getNotification(browser)) { + info(`Notification for ${url} not immediately present, waiting for it.`); + yield BrowserTestUtils.waitForNotificationBar(gBrowser, browser, kExpectedNotificationId); + } + + ok(true, `Got notification for ${url}`); + let notification = getNotification(browser); + let notificationText = document.getAnonymousElementByAttribute(notification, "class", "messageText"); + notificationText = notificationText.textContent; + for (let potentiallyImported of kAllItems) { + let localizedImportItem = localizedVersionOf(potentiallyImported); + if (subset.includes(potentiallyImported)) { + ok(notificationText.includes(localizedImportItem), + "Expected notification to contain " + localizedImportItem); + } else { + ok(!notificationText.includes(localizedImportItem), + "Expected notification not to contain " + localizedImportItem); + } + } + + yield BrowserTestUtils.removeTab(tab); + } +}); + diff --git a/browser/components/migration/tests/marionette/manifest.ini b/browser/components/migration/tests/marionette/manifest.ini new file mode 100644 index 000000000..3f404e724 --- /dev/null +++ b/browser/components/migration/tests/marionette/manifest.ini @@ -0,0 +1,5 @@ +[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 000000000..b348a3dcd --- /dev/null +++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py @@ -0,0 +1,416 @@ +import os +import shutil + +from marionette_harness import MarionetteTestCase + + +class TestFirefoxRefresh(MarionetteTestCase): + _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" + + _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 createBookmark(self): + self.marionette.execute_script(""" + let url = arguments[0]; + let title = arguments[1]; + PlacesUtils.bookmarks.insertBookmark(PlacesUtils.bookmarks.bookmarksMenuFolder, + makeURI(url), 0, title); + """, script_args=[self._bookmarkURL, self._bookmarkText]) + + def createHistory(self): + error = self.runAsyncCode(""" + // Copied from PlacesTestUtils, which isn't available in Marionette tests. + let didReturn; + PlacesUtils.asyncHistory.updatePlaces( + [{title: arguments[1], uri: makeURI(arguments[0]), visits: [{ + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + visitDate: (Date.now() - 5000) * 1000, + referrerURI: makeURI("about:mozilla"), + }] + }], + { + handleError(resultCode, place) { + didReturn = true; + marionetteScriptFinished("Unexpected error in adding visit: " + resultCode); + }, + handleResult() {}, + handleCompletion() { + if (!didReturn) { + marionetteScriptFinished(false); + } + }, + } + ); + """, 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; + global.FormHistory.update(updateDefinition, { + handleError(error) { + finished = true; + marionetteScriptFinished(error); + }, + handleCompletion() { + if (!finished) { + marionetteScriptFinished(false); + } + } + }); + """, script_args=[self._formHistoryFieldName, self._formHistoryValue]) + if error: + print error + + 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); + """, script_args=[self._cookieHost, self._cookiePath, self._cookieName, self._cookieValue]) + + def createSession(self): + self.runAsyncCode(""" + 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); + marionetteScriptFinished(); + } + }); + } + } + }); + for (let url of expectedURLs) { + gBrowser.addTab(url); + } + """, script_args=[self._expectedURLs]) + + 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; + """) + self.assertEqual(loginCount, 1, "No other logins are present") + + def checkBookmark(self): + titleInBookmarks = self.marionette.execute_script(""" + let url = arguments[0]; + let bookmarkIds = PlacesUtils.bookmarks.getBookmarkIdsForURI(makeURI(url), {}, {}); + return bookmarkIds.length == 1 ? PlacesUtils.bookmarks.getItemTitle(bookmarkIds[0]) : ""; + """, script_args=[self._bookmarkURL]) + self.assertEqual(titleInBookmarks, self._bookmarkText) + + def checkHistory(self): + historyResults = self.runAsyncCode(""" + let placeInfos = []; + PlacesUtils.asyncHistory.getPlacesInfo(makeURI(arguments[0]), { + handleError(resultCode, place) { + placeInfos = null; + marionetteScriptFinished("Unexpected error in fetching visit: " + resultCode); + }, + handleResult(placeInfo) { + placeInfos.push(placeInfo); + }, + handleCompletion() { + if (placeInfos) { + if (!placeInfos.length) { + marionetteScriptFinished("No visits found"); + } else { + marionetteScriptFinished(placeInfos); + } + } + }, + }); + """, script_args=[self._historyURL]) + if type(historyResults) == str: + self.fail(historyResults) + return + + historyCount = len(historyResults) + self.assertEqual(historyCount, 1, "Should have exactly 1 entry for URI, got %d" % historyCount) + if historyCount == 1: + self.assertEqual(historyResults[0]['title'], self._historyTitle) + + def checkFormHistory(self): + formFieldResults = self.runAsyncCode(""" + let results = []; + global.FormHistory.search(["value"], {fieldname: arguments[0]}, { + handleError(error) { + results = error; + }, + handleResult(result) { + results.push(result); + }, + handleCompletion() { + marionetteScriptFinished(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 count; + let callbacks = { + handleResult: rv => count = rv, + handleCompletion() { + marionetteScriptFinished(count); + }, + }; + global.FormHistory.count({}, callbacks); + """) + self.assertEqual(formHistoryCount, 1, "There should be only 1 entry in the form history") + + def checkCookie(self): + cookieInfo = self.runCode(""" + try { + let cookieEnum = Services.cookies.getCookiesFromHost(arguments[0]); + let cookie = null; + while (cookieEnum.hasMoreElements()) { + let hostCookie = cookieEnum.getNext(); + hostCookie.QueryInterface(Ci.nsICookie2); + // 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"]) + + tabURIs = self.runAsyncCode(""" + let mm = gBrowser.selectedBrowser.messageManager; + let fs = function() { + content.document.getElementById("errorTryAgain").click(); + }; + 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() { + marionetteScriptFinished([... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec)); + }); + }, false); + mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true); + """) + self.assertSequenceEqual(tabURIs, ["about:blank"] + self._expectedURLs) + pass + + def checkProfile(self, hasMigrated=False): + self.checkPassword() + self.checkBookmark() + self.checkHistory() + self.checkFormHistory() + self.checkCookie() + if hasMigrated: + self.checkSession() + + def createProfileData(self): + self.savePassword() + self.createBookmark() + self.createHistory() + self.createFormHistory() + self.createCookie() + self.createSession() + + def setUpScriptData(self): + self.marionette.set_context(self.marionette.CONTEXT_CHROME) + self.marionette.execute_script(""" + 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; + """, new_sandbox=False, sandbox='system') + + def runCode(self, script, *args, **kwargs): + return self.marionette.execute_script(script, new_sandbox=False, sandbox='system', *args, **kwargs) + + def runAsyncCode(self, script, *args, **kwargs): + return self.marionette.execute_async_script(script, new_sandbox=False, sandbox='system', *args, **kwargs) + + def setUp(self): + MarionetteTestCase.setUp(self) + self.setUpScriptData() + + self.reset_profile_path = None + self.desktop_backup_path = None + + self.createProfileData() + + 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) + + # Some helpers to deal with removing a load of files + import errno, stat + def handleRemoveReadonly(func, path, exc): + excvalue = exc[1] + if func in (os.rmdir, os.remove) and excvalue.errno == errno.EACCES: + os.chmod(path, stat.S_IRWXU| stat.S_IRWXG| stat.S_IRWXO) # 0777 + func(path) + else: + raise + + if self.desktop_backup_path: + shutil.rmtree(self.desktop_backup_path, ignore_errors=False, onerror=handleRemoveReadonly) + + if self.reset_profile_path: + # Remove ourselves from profiles.ini + profileLeafName = os.path.basename(os.path.normpath(self.reset_profile_path)) + self.runCode(""" + let [salt, name] = arguments[0].split("."); + let profile = global.profSvc.getProfileByName(name); + profile.remove(false) + global.profSvc.flush(); + """, script_args=[profileLeafName]) + # And delete all the files. + shutil.rmtree(self.reset_profile_path, ignore_errors=False, onerror=handleRemoveReadonly) + + def doReset(self): + self.runCode(""" + // Ensure the current (temporary) profile is in profiles.ini: + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + let profileName = "marionette-test-profile-" + Date.now(); + 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 allMarionettePrefs = Services.prefs.getChildList("marionette."); + let prefObj = {}; + for (let pref of allMarionettePrefs) { + let prefSuffix = pref.substr("marionette.".length); + let prefVal = global.Preferences.get(pref); + prefObj[prefSuffix] = prefVal; + } + let marionetteInfo = JSON.stringify(prefObj); + env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", marionetteInfo); + env.set("MOZ_RESET_PROFILE_RESTART", "1"); + env.set("XRE_PROFILE_PATH", arguments[0]); + env.set("XRE_PROFILE_NAME", profileName); + """, script_args=[self.marionette.instance.profile.profile]) + + 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) + self.reset_profile_path = self.runCode(""" + let profD = Services.dirsvc.get("ProfD", Ci.nsIFile); + return profD.path; + """) + + # Determine the backup path + self.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], 1); + container.append(dirName); + container.append(arguments[0]); + return container.path; + """, script_args = [profileLeafName]) + + self.assertTrue(os.path.isdir(self.reset_profile_path), "Reset profile path should be present") + self.assertTrue(os.path.isdir(self.desktop_backup_path), "Backup profile path should be present") + + def testReset(self): + self.checkProfile() + + self.doReset() + + # Now check that we're doing OK... + self.checkProfile(hasMigrated=True) diff --git a/browser/components/migration/tests/unit/.eslintrc.js b/browser/components/migration/tests/unit/.eslintrc.js new file mode 100644 index 000000000..ba65517f9 --- /dev/null +++ b/browser/components/migration/tests/unit/.eslintrc.js @@ -0,0 +1,7 @@ +"use strict"; + +module.exports = { // eslint-disable-line no-undef + "extends": [ + "../../../../../testing/xpcshell/xpcshell.eslintrc.js" + ] +}; 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 000000000..914149c71 --- /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/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 000000000..83d855cb3 --- /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/Local State b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State new file mode 100644 index 000000000..01b99455e --- /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 000000000..40783c7b1 --- /dev/null +++ b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist 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 000000000..d3c258d54 --- /dev/null +++ b/browser/components/migration/tests/unit/head_migration.js @@ -0,0 +1,69 @@ +"use strict"; + +/* exported gProfD, promiseMigration, registerFakePath */ + +var { classes: Cc, interfaces: Ci, results: Cr, utils: Cu } = Components; + +Cu.importGlobalProperties([ "URL" ]); + +Cu.import("resource:///modules/MigrationUtils.jsm"); +Cu.import("resource://gre/modules/LoginHelper.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/PlacesUtils.jsm"); +Cu.import("resource://gre/modules/Preferences.jsm"); +Cu.import("resource://gre/modules/PromiseUtils.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://testing-common/TestUtils.jsm"); +Cu.import("resource://testing-common/PlacesTestUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "FileUtils", + "resource://gre/modules/FileUtils.jsm"); + +// Initialize profile. +var gProfD = do_get_profile(); + +Cu.import("resource://testing-common/AppInfo.jsm"); /* globals updateAppInfo */ +updateAppInfo(); + +/** + * Migrates the requested resource and waits for the migration to be complete. + */ +function promiseMigration(migrator, resourceType, aProfile = null) { + // Ensure resource migration is available. + let availableSources = migrator.getMigrateData(aProfile, false); + Assert.ok((availableSources & resourceType) > 0, "Resource supported by migrator"); + + return new Promise (resolve => { + Services.obs.addObserver(function onMigrationEnded() { + Services.obs.removeObserver(onMigrationEnded, "Migration:Ended"); + resolve(); + }, "Migration:Ended", false); + + migrator.migrate(resourceType, null, aProfile); + }); +} + +/** + * Replaces a directory service entry with a given nsIFile. + */ +function registerFakePath(key, file) { + // Register our own provider for the Library directory. + let provider = { + getFile(prop, persistent) { + persistent.value = true; + if (prop == key) { + return file; + } + throw Cr.NS_ERROR_FAILURE; + }, + QueryInterface: XPCOMUtils.generateQI([ Ci.nsIDirectoryServiceProvider ]) + }; + Services.dirsvc.QueryInterface(Ci.nsIDirectoryService) + .registerProvider(provider); + do_register_cleanup(() => { + Services.dirsvc.QueryInterface(Ci.nsIDirectoryService) + .unregisterProvider(provider); + }); +} 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 000000000..006693951 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_cookies.js @@ -0,0 +1,51 @@ +"use strict"; + +Cu.import("resource://gre/modules/ForgetAboutSite.jsm"); + +add_task(function* () { + registerFakePath("ULibDir", do_get_file("Library/")); + let migrator = MigrationUtils.getMigrator("chrome"); + + Assert.ok(migrator.sourceExists, "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. + yield 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 enumerator = Services.cookies.getCookiesFromHost(COOKIE.host, {}); + Assert.ok(enumerator.hasMoreElements(), "Cookies available"); + let foundCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + + for (let prop of Object.keys(COOKIE)) { + Assert.equal(foundCookie[prop], COOKIE[prop], "Check cookie " + prop); + } + + // Cleanup. + 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_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js new file mode 100644 index 000000000..49147bd61 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js @@ -0,0 +1,219 @@ +"use strict"; + +Cu.import("resource://gre/modules/OSCrypto.jsm"); +Cu.import("resource://gre/modules/Services.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", + hostname: "https://c9.io", + formSubmitURL: "https://c9.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, + { + id: 2, + username: "username@gmail.com", + password: "password2", + hostname: "https://accounts.google.com", + formSubmitURL: "https://accounts.google.com", + httpRealm: null, + usernameField: "Email", + passwordField: "Passwd", + timeCreated: 1437418446598, + timePasswordChanged: 1437418446598, + timesUsed: 6, + }, + { + id: 3, + username: "username", + password: "password3", + hostname: "https://www.facebook.com", + formSubmitURL: "https://www.facebook.com", + httpRealm: null, + usernameField: "email", + passwordField: "pass", + timeCreated: 1437418478851, + timePasswordChanged: 1437418478851, + timesUsed: 1, + }, + { + id: 4, + username: "user", + password: "password", + hostname: "http://httpbin.org", + formSubmitURL: null, + httpRealm: "me@kennethreitz.com", // Digest auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787462368, + timePasswordChanged: 1437787462368, + timesUsed: 1, + }, + { + id: 5, + username: "buser", + password: "bpassword", + hostname: "http://httpbin.org", + formSubmitURL: null, + httpRealm: "Fake Realm", // Basic auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787539233, + timePasswordChanged: 1437787539233, + timesUsed: 1, + }, +]; + +var crypto = new OSCrypto(); +var dbConn; + +function promiseSetPassword(login) { + return new Promise((resolve, reject) => { + let stmt = dbConn.createAsyncStatement(` + UPDATE logins + SET password_value = :password_value + WHERE rowid = :rowid + `); + let passwordValue = crypto.stringToArray(crypto.encryptData(login.password)); + stmt.bindBlobByName("password_value", passwordValue, passwordValue.length); + stmt.params.rowid = login.id; + + stmt.executeAsync({ + handleError(aError) { + reject("Error with the query: " + aError.message); + }, + + handleCompletion(aReason) { + if (aReason === Ci.mozIStorageStatementCallback.REASON_FINISHED) { + resolve(); + } else { + reject("Query has failed: " + aReason); + } + }, + }); + stmt.finalize(); + }); +} + +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.hostname, chromeLogin.hostname, + "The two logins ID " + id + " have the same hostname"); + Assert.equal(passwordManagerLogin.formSubmitURL, chromeLogin.formSubmitURL, + "The two logins ID " + id + " have the same formSubmitURL"); + 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.hostname, login.formSubmitURL, 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(function* setup() { + let loginDataFile = do_get_file("AppData/Local/Google/Chrome/User Data/Default/Login Data"); + dbConn = Services.storage.openUnsharedDatabase(loginDataFile); + registerFakePath("LocalAppData", do_get_file("AppData/Local/")); + + do_register_cleanup(() => { + Services.logins.removeAllLogins(); + dbConn.asyncClose(); + crypto.finalize(); + }); +}); + +add_task(function* test_importIntoEmptyDB() { + for (let login of TEST_LOGINS) { + yield promiseSetPassword(login); + } + + let migrator = MigrationUtils.getMigrator("chrome"); + Assert.ok(migrator.sourceExists, "Sanity check the source exists"); + + let logins = Services.logins.getAllLogins({}); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. + yield promiseMigration(migrator, MigrationUtils.resourceTypes.PASSWORDS, PROFILE); + + 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(function* test_importExistingLogins() { + let migrator = MigrationUtils.getMigrator("chrome"); + Assert.ok(migrator.sourceExists, "Sanity check the source exists"); + + Services.logins.removeAllLogins(); + 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. + yield promiseMigration(migrator, MigrationUtils.resourceTypes.PASSWORDS, PROFILE); + + 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_Edge_availability.js b/browser/components/migration/tests/unit/test_Edge_availability.js new file mode 100644 index 000000000..dba0e27bb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_availability.js @@ -0,0 +1,20 @@ +"use strict"; + +const EDGE_AVAILABLE_MIGRATIONS = + MigrationUtils.resourceTypes.COOKIES | + MigrationUtils.resourceTypes.BOOKMARKS | + MigrationUtils.resourceTypes.HISTORY | + MigrationUtils.resourceTypes.PASSWORDS; + +add_task(function* () { + let migrator = MigrationUtils.getMigrator("edge"); + Cu.import("resource://gre/modules/AppConstants.jsm"); + Assert.equal(!!(migrator && migrator.sourceExists), AppConstants.isPlatformAndVersionAtLeast("win", "10"), + "Edge should be available for migration if and only if we're on Win 10+"); + if (migrator) { + let migratableData = migrator.getMigrateData(null, false); + Assert.equal(migratableData, EDGE_AVAILABLE_MIGRATIONS, + "All the data types we expect should be available"); + } +}); + 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 000000000..56ff612d5 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js @@ -0,0 +1,471 @@ +"use strict"; + +Cu.import("resource://gre/modules/ctypes.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +let eseBackStage = Cu.import("resource:///modules/ESEDBReader.jsm"); +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, tableName, columns, rows) { + if (!initializedESE) { + initializedESE = true; + loadLibraries(); + + KERNEL.SystemTimeToFileTime = gLibs.kernel.declare("SystemTimeToFileTime", + ctypes.default_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; + + 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(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 itemsInDB = [ + { + 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, + }, + ]; + eseDBWritingHelpers.setupDB(db, "Favorites", [ + {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"}, + ], itemsInDB); + + let migrator = Cc["@mozilla.org/profile/migrator;1?app=browser&type=edge"] + .createInstance(Ci.nsIBrowserProfileMigrator); + let bookmarksMigrator = migrator.wrappedJSObject.getESEMigratorForTesting(db); + Assert.ok(bookmarksMigrator.exists, "Should recognize table we just created"); + + let source = MigrationUtils.getLocalizedString("sourceNameEdge"); + let sourceLabel = MigrationUtils.getLocalizedString("importedBookmarksFolder", [source]); + + let seenBookmarks = []; + let bookmarkObserver = { + onItemAdded(itemId, parentId, index, itemType, url, title, dateAdded, itemGuid, parentGuid) { + if (title.startsWith("Deleted")) { + ok(false, "Should not see deleted items being bookmarked!"); + } + seenBookmarks.push({itemId, parentId, index, itemType, url, title, dateAdded, itemGuid, parentGuid}); + }, + onBeginUpdateBatch() {}, + onEndUpdateBatch() {}, + onItemRemoved() {}, + onItemChanged() {}, + onItemVisited() {}, + onItemMoved() {}, + }; + PlacesUtils.bookmarks.addObserver(bookmarkObserver, false); + + let migrateResult = yield 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.bookmarks.removeObserver(bookmarkObserver); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal(seenBookmarks.length, 7, "Should have seen 7 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, 1, "Should have a single folder added to the menu"); + 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 = menuParents[0].itemGuid; + let toolbarParentGuid = toolbarParents[0].itemGuid; + + let expectedTitlesInMenu = itemsInDB.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 = itemsInDB.filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf").map(item => item.Title); + + let edgeNameStr = MigrationUtils.getLocalizedString("sourceNameEdge"); + let importParentFolderName = MigrationUtils.getLocalizedString("importedBookmarksFolder", [edgeNameStr]); + + 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.itemGuid == menuParentGuid || bookmark.itemGuid == 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.itemGuid == bookmark.parentGuid); + Assert.equal(parent && parent.title, "Folder", "Subfoldered item should be in subfolder labeled 'Folder'"); + } + + let dbItem = itemsInDB.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.itemGuid), "This item should be one of the containers"); + } else { + Assert.equal(dbItem.URL || null, bookmark.url && bookmark.url.spec, "URL is correct"); + Assert.equal(dbItem.DateUpdated.valueOf(), (new Date(bookmark.dateAdded / 1000)).valueOf(), "Date added is correct"); + } + } +}); + 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 000000000..1ce016a7d --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE7_passwords.js @@ -0,0 +1,397 @@ +"use strict"; + +Cu.import("resource://gre/modules/AppConstants.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "WindowsRegistry", + "resource://gre/modules/WindowsRegistry.jsm"); +XPCOMUtils.defineLazyModuleGetter(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", + hostname: "https://twitter.com", + formSubmitURL: "", + 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", + hostname: "https://www.facebook.com", + formSubmitURL: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439326966000, + timeLastUsed: 1439326966000, + timePasswordChanged: 1439326966000, + timesUsed: 1, + }, + { + username: "username1", + password: "password1", + hostname: "https://www.facebook.com", + formSubmitURL: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439326997000, + timeLastUsed: 1439326997000, + timePasswordChanged: 1439326997000, + timesUsed: 1, + }, + { + username: "username2", + password: "password2", + hostname: "https://www.facebook.com", + formSubmitURL: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439327040000, + timeLastUsed: 1439327040000, + timePasswordChanged: 1439327040000, + timesUsed: 1, + }, + { + username: "username3", + password: "password3", + hostname: "https://www.facebook.com", + formSubmitURL: "", + 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", + hostname: "https://login.live.com", + formSubmitURL: "", + 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: [ + { + username: "購読を", + password: "Bitte bestätigen", + hostname: "http://www.reddit.com", + formSubmitURL: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439340874000, + timeLastUsed: 1439340874000, + timePasswordChanged: 1439340874000, + timesUsed: 1, + }, + { + username: "重置密码", + password: "Пароль успешно сброшен. Войти", + hostname: "http://www.reddit.com", + formSubmitURL: "", + httpRealm: null, + usernameField: "", + passwordField: "", + timeCreated: 1439340971000, + timeLastUsed: 1439340971000, + timePasswordChanged: 1439340971000, + timesUsed: 1, + }, + { + username: "بسم الله الرحمن", + password: "veuillez le vérifier à nouveau", + hostname: "http://www.reddit.com", + formSubmitURL: "", + 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, null, null); +} + +add_task(function* setup() { + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + Assert.throws(() => getFirstResourceOfType(MigrationUtils.resourceTypes.PASSWORDS), + "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(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 + 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(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 + + do_register_cleanup(() => { + Services.logins.removeAllLogins(); + 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, true)); + Assert.ok(migrator.exists, "The migrator has to exist"); + uris.push(website.uri); + hashes.push(website.hash); + + 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 000000000..a166c0502 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js @@ -0,0 +1,44 @@ +"use strict"; + +add_task(function* () { + let migrator = MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(migrator.sourceExists); + + // Wait for the imported bookmarks. Check that "From Internet Explorer" + // folders are created in the menu and on the toolbar. + let source = MigrationUtils.getLocalizedString("sourceNameIE"); + let label = MigrationUtils.getLocalizedString("importedBookmarksFolder", [source]); + + let expectedParents = [ PlacesUtils.bookmarksMenuFolderId, + PlacesUtils.toolbarFolderId ]; + + let itemCount = 0; + let bmObserver = { + onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle) { + if (aTitle != label) { + itemCount++; + } + if (expectedParents.length > 0 && aTitle == label) { + let index = expectedParents.indexOf(aParentId); + Assert.notEqual(index, -1); + expectedParents.splice(index, 1); + } + }, + onBeginUpdateBatch() {}, + onEndUpdateBatch() {}, + onItemRemoved() {}, + onItemChanged() {}, + onItemVisited() {}, + onItemMoved() {}, + }; + PlacesUtils.bookmarks.addObserver(bmObserver, false); + + yield promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.bookmarks.removeObserver(bmObserver); + Assert.equal(MigrationUtils._importQuantities.bookmarks, itemCount, + "Ensure telemetry matches actual number of imported items."); + + // Check the bookmarks have been imported to all the expected parents. + Assert.equal(expectedParents.length, 0, "Got all the expected parents"); +}); 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 000000000..37a7462f2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_cookies.js @@ -0,0 +1,111 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetter(this, "ctypes", + "resource://gre/modules/ctypes.jsm"); + +add_task(function* () { + let migrator = MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(migrator.sourceExists); + + 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 + ); + */ + let setIECookie = wininet.declare("InternetSetCookieW", + ctypes.default_abi, + BOOL, + LPCTSTR, + LPCTSTR, + LPCTSTR); + + /* + BOOL InternetGetCookieW( + _In_ LPCTSTR lpszUrl, + _In_ LPCTSTR lpszCookieName, + _Out_ LPCTSTR lpszCookieData, + _Inout_ LPDWORD lpdwSize + ); + */ + let getIECookie = wininet.declare("InternetGetCookieW", + ctypes.default_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(); + + do_register_cleanup(() => { + // 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"); + do_print("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. + yield 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 enumerator = Services.cookies.getCookiesFromHost(COOKIE.host, {}); + Assert.ok(enumerator.hasMoreElements()); + let foundCookie = enumerator.getNext().QueryInterface(Ci.nsICookie2); + + 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_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js new file mode 100644 index 000000000..edc32dc72 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js @@ -0,0 +1,46 @@ +"use strict"; + +add_task(function* () { + registerFakePath("ULibDir", do_get_file("Library/")); + + let migrator = MigrationUtils.getMigrator("safari"); + // Sanity check for the source. + Assert.ok(migrator.sourceExists); + + // Wait for the imported bookmarks. Check that "From Safari" + // folders are created on the toolbar. + let source = MigrationUtils.getLocalizedString("sourceNameSafari"); + let label = MigrationUtils.getLocalizedString("importedBookmarksFolder", [source]); + + let expectedParents = [ PlacesUtils.toolbarFolderId ]; + let itemCount = 0; + + let bmObserver = { + onItemAdded(aItemId, aParentId, aIndex, aItemType, aURI, aTitle) { + if (aTitle != label) { + itemCount++; + } + if (expectedParents.length > 0 && aTitle == label) { + let index = expectedParents.indexOf(aParentId); + Assert.ok(index != -1, "Found expected parent"); + expectedParents.splice(index, 1); + } + }, + onBeginUpdateBatch() {}, + onEndUpdateBatch() {}, + onItemRemoved() {}, + onItemChanged() {}, + onItemVisited() {}, + onItemMoved() {}, + }; + PlacesUtils.bookmarks.addObserver(bmObserver, false); + + yield promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.bookmarks.removeObserver(bmObserver); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(!expectedParents.length, "No more expected parents"); + Assert.equal(itemCount, 13, "Should import all 13 items."); + // Check that the telemetry matches: + Assert.equal(MigrationUtils._importQuantities.bookmarks, itemCount, "Telemetry reporting correct."); +}); diff --git a/browser/components/migration/tests/unit/test_automigration.js b/browser/components/migration/tests/unit/test_automigration.js new file mode 100644 index 000000000..bc9076a6c --- /dev/null +++ b/browser/components/migration/tests/unit/test_automigration.js @@ -0,0 +1,695 @@ +"use strict"; + +let AutoMigrateBackstage = Cu.import("resource:///modules/AutoMigrate.jsm"); /* globals AutoMigrate */ + +let gShimmedMigratorKeyPicker = null; +let gShimmedMigrator = null; + +const kUsecPerMin = 60 * 1000000; + +// This is really a proxy on MigrationUtils, but if we specify that directly, +// we get in trouble because the object itself is frozen, and Proxies can't +// return a different value to an object when directly proxying a frozen +// object. +AutoMigrateBackstage.MigrationUtils = new Proxy({}, { + get(target, name) { + if (name == "getMigratorKeyForDefaultBrowser" && gShimmedMigratorKeyPicker) { + return gShimmedMigratorKeyPicker; + } + if (name == "getMigrator" && gShimmedMigrator) { + return function() { return gShimmedMigrator }; + } + return MigrationUtils[name]; + }, +}); + +do_register_cleanup(function() { + AutoMigrateBackstage.MigrationUtils = MigrationUtils; +}); + +// This should be replaced by using History.fetch with a fetchVisits option, +// once that becomes available +function* visitsForURL(url) +{ + let visitCount = 0; + let db = yield PlacesUtils.promiseDBConnection(); + visitCount = yield db.execute( + `SELECT count(*) FROM moz_historyvisits v + JOIN moz_places h ON h.id = v.place_id + WHERE url_hash = hash(:url) AND url = :url`, + {url}); + visitCount = visitCount[0].getInt64(0); + return visitCount; +} + + +/** + * Test automatically picking a browser to migrate from + */ +add_task(function* checkMigratorPicking() { + Assert.throws(() => AutoMigrate.pickMigrator("firefox"), + /Can't automatically migrate from Firefox/, + "Should throw when explicitly picking Firefox."); + + Assert.throws(() => AutoMigrate.pickMigrator("gobbledygook"), + /migrator object is not available/, + "Should throw when passing unknown migrator key"); + gShimmedMigratorKeyPicker = function() { + return "firefox"; + }; + Assert.throws(() => AutoMigrate.pickMigrator(), + /Can't automatically migrate from Firefox/, + "Should throw when implicitly picking Firefox."); + gShimmedMigratorKeyPicker = function() { + return "gobbledygook"; + }; + Assert.throws(() => AutoMigrate.pickMigrator(), + /migrator object is not available/, + "Should throw when an unknown migrator is the default"); + gShimmedMigratorKeyPicker = function() { + return ""; + }; + Assert.throws(() => AutoMigrate.pickMigrator(), + /Could not determine default browser key/, + "Should throw when an unknown migrator is the default"); +}); + + +/** + * Test automatically picking a profile to migrate from + */ +add_task(function* checkProfilePicking() { + let fakeMigrator = {sourceProfiles: [{id: "a"}, {id: "b"}]}; + let profB = fakeMigrator.sourceProfiles[1]; + Assert.throws(() => AutoMigrate.pickProfile(fakeMigrator), + /Don't know how to pick a profile when more/, + "Should throw when there are multiple profiles."); + Assert.throws(() => AutoMigrate.pickProfile(fakeMigrator, "c"), + /Profile specified was not found/, + "Should throw when the profile supplied doesn't exist."); + let profileToMigrate = AutoMigrate.pickProfile(fakeMigrator, "b"); + Assert.equal(profileToMigrate, profB, "Should return profile supplied"); + + fakeMigrator.sourceProfiles = null; + Assert.throws(() => AutoMigrate.pickProfile(fakeMigrator, "c"), + /Profile specified but only a default profile found./, + "Should throw when the profile supplied doesn't exist."); + profileToMigrate = AutoMigrate.pickProfile(fakeMigrator); + Assert.equal(profileToMigrate, null, "Should return default profile when that's the only one."); + + fakeMigrator.sourceProfiles = []; + Assert.throws(() => AutoMigrate.pickProfile(fakeMigrator), + /No profile data found/, + "Should throw when no profile data is present."); + + fakeMigrator.sourceProfiles = [{id: "a"}]; + let profA = fakeMigrator.sourceProfiles[0]; + profileToMigrate = AutoMigrate.pickProfile(fakeMigrator); + Assert.equal(profileToMigrate, profA, "Should return the only profile if only one is present."); +}); + +/** + * Test the complete automatic process including browser and profile selection, + * and actual migration (which implies startup) + */ +add_task(function* checkIntegration() { + gShimmedMigrator = { + get sourceProfiles() { + do_print("Read sourceProfiles"); + return null; + }, + getMigrateData(profileToMigrate) { + this._getMigrateDataArgs = profileToMigrate; + return Ci.nsIBrowserProfileMigrator.BOOKMARKS; + }, + migrate(types, startup, profileToMigrate) { + this._migrateArgs = [types, startup, profileToMigrate]; + }, + }; + gShimmedMigratorKeyPicker = function() { + return "gobbledygook"; + }; + AutoMigrate.migrate("startup"); + Assert.strictEqual(gShimmedMigrator._getMigrateDataArgs, null, + "getMigrateData called with 'null' as a profile"); + + let {BOOKMARKS, HISTORY, PASSWORDS} = Ci.nsIBrowserProfileMigrator; + let expectedTypes = BOOKMARKS | HISTORY | PASSWORDS; + Assert.deepEqual(gShimmedMigrator._migrateArgs, [expectedTypes, "startup", null], + "migrate called with 'null' as a profile"); +}); + +/** + * Test the undo preconditions and a no-op undo in the automigrator. + */ +add_task(function* checkUndoPreconditions() { + let shouldAddData = false; + gShimmedMigrator = { + get sourceProfiles() { + do_print("Read sourceProfiles"); + return null; + }, + getMigrateData(profileToMigrate) { + this._getMigrateDataArgs = profileToMigrate; + return Ci.nsIBrowserProfileMigrator.BOOKMARKS; + }, + migrate(types, startup, profileToMigrate) { + this._migrateArgs = [types, startup, profileToMigrate]; + if (shouldAddData) { + // Insert a login and check that that worked. + MigrationUtils.insertLoginWrapper({ + hostname: "www.mozilla.org", + formSubmitURL: "http://www.mozilla.org", + username: "user", + password: "pass", + }); + } + TestUtils.executeSoon(function() { + Services.obs.notifyObservers(null, "Migration:Ended", undefined); + }); + }, + }; + + gShimmedMigratorKeyPicker = function() { + return "gobbledygook"; + }; + AutoMigrate.migrate("startup"); + let migrationFinishedPromise = TestUtils.topicObserved("Migration:Ended"); + Assert.strictEqual(gShimmedMigrator._getMigrateDataArgs, null, + "getMigrateData called with 'null' as a profile"); + + let {BOOKMARKS, HISTORY, PASSWORDS} = Ci.nsIBrowserProfileMigrator; + let expectedTypes = BOOKMARKS | HISTORY | PASSWORDS; + Assert.deepEqual(gShimmedMigrator._migrateArgs, [expectedTypes, "startup", null], + "migrate called with 'null' as a profile"); + + yield migrationFinishedPromise; + Assert.ok(Preferences.has("browser.migrate.automigrate.browser"), + "Should have set browser pref"); + Assert.ok(!(yield AutoMigrate.canUndo()), "Should not be able to undo migration, as there's no data"); + gShimmedMigrator._migrateArgs = null; + gShimmedMigrator._getMigrateDataArgs = null; + Preferences.reset("browser.migrate.automigrate.browser"); + shouldAddData = true; + + AutoMigrate.migrate("startup"); + migrationFinishedPromise = TestUtils.topicObserved("Migration:Ended"); + Assert.strictEqual(gShimmedMigrator._getMigrateDataArgs, null, + "getMigrateData called with 'null' as a profile"); + Assert.deepEqual(gShimmedMigrator._migrateArgs, [expectedTypes, "startup", null], + "migrate called with 'null' as a profile"); + + yield migrationFinishedPromise; + let storedLogins = Services.logins.findLogins({}, "www.mozilla.org", + "http://www.mozilla.org", null); + Assert.equal(storedLogins.length, 1, "Should have 1 login"); + + Assert.ok(Preferences.has("browser.migrate.automigrate.browser"), + "Should have set browser pref"); + Assert.ok((yield AutoMigrate.canUndo()), "Should be able to undo migration, as now there's data"); + + yield AutoMigrate.undo(); + Assert.ok(true, "Should be able to finish an undo cycle."); + + // Check that the undo removed the passwords: + storedLogins = Services.logins.findLogins({}, "www.mozilla.org", + "http://www.mozilla.org", null); + Assert.equal(storedLogins.length, 0, "Should have no logins"); +}); + +/** + * Fake a migration and then try to undo it to verify all data gets removed. + */ +add_task(function* checkUndoRemoval() { + MigrationUtils.initializeUndoData(); + Preferences.set("browser.migrate.automigrate.browser", "automationbrowser"); + // Insert a login and check that that worked. + MigrationUtils.insertLoginWrapper({ + hostname: "www.mozilla.org", + formSubmitURL: "http://www.mozilla.org", + username: "user", + password: "pass", + }); + let storedLogins = Services.logins.findLogins({}, "www.mozilla.org", + "http://www.mozilla.org", null); + Assert.equal(storedLogins.length, 1, "Should have 1 login"); + + // Insert a bookmark and check that we have exactly 1 bookmark for that URI. + yield MigrationUtils.insertBookmarkWrapper({ + parentGuid: PlacesUtils.bookmarks.toolbarGuid, + url: "http://www.example.org/", + title: "Some example bookmark", + }); + + let bookmark = yield PlacesUtils.bookmarks.fetch({url: "http://www.example.org/"}); + Assert.ok(bookmark, "Should have a bookmark before undo"); + Assert.equal(bookmark.title, "Some example bookmark", "Should have correct bookmark before undo."); + + // Insert 2 history visits + let now_uSec = Date.now() * 1000; + let visitedURI = Services.io.newURI("http://www.example.com/", null, null); + let frecencyUpdatePromise = new Promise(resolve => { + let expectedChanges = 2; + let observer = { + onFrecencyChanged: function() { + if (!--expectedChanges) { + PlacesUtils.history.removeObserver(observer); + resolve(); + } + }, + }; + PlacesUtils.history.addObserver(observer, false); + }); + yield MigrationUtils.insertVisitsWrapper([{ + uri: visitedURI, + visits: [ + { + transitionType: PlacesUtils.history.TRANSITION_LINK, + visitDate: now_uSec, + }, + { + transitionType: PlacesUtils.history.TRANSITION_LINK, + visitDate: now_uSec - 100 * kUsecPerMin, + }, + ] + }]); + yield frecencyUpdatePromise; + + // Verify that both visits get reported. + let opts = PlacesUtils.history.getNewQueryOptions(); + opts.resultType = opts.RESULTS_AS_VISIT; + let query = PlacesUtils.history.getNewQuery(); + query.uri = visitedURI; + let visits = PlacesUtils.history.executeQuery(query, opts); + visits.root.containerOpen = true; + Assert.equal(visits.root.childCount, 2, "Should have 2 visits"); + // Clean up: + visits.root.containerOpen = false; + + yield AutoMigrate.saveUndoState(); + + // Verify that we can undo, then undo: + Assert.ok(AutoMigrate.canUndo(), "Should be possible to undo migration"); + yield AutoMigrate.undo(); + + let histograms = [ + "FX_STARTUP_MIGRATION_UNDO_BOOKMARKS_ERRORCOUNT", + "FX_STARTUP_MIGRATION_UNDO_LOGINS_ERRORCOUNT", + "FX_STARTUP_MIGRATION_UNDO_VISITS_ERRORCOUNT", + ]; + for (let histogramId of histograms) { + let keyedHistogram = Services.telemetry.getKeyedHistogramById(histogramId); + let histogramData = keyedHistogram.snapshot().automationbrowser; + Assert.equal(histogramData.sum, 0, `Should have reported 0 errors to ${histogramId}.`); + Assert.greaterOrEqual(histogramData.counts[0], 1, `Should have reported value of 0 one time to ${histogramId}.`); + } + histograms = [ + "FX_STARTUP_MIGRATION_UNDO_BOOKMARKS_MS", + "FX_STARTUP_MIGRATION_UNDO_LOGINS_MS", + "FX_STARTUP_MIGRATION_UNDO_VISITS_MS", + "FX_STARTUP_MIGRATION_UNDO_TOTAL_MS", + ]; + for (let histogramId of histograms) { + Assert.greater(Services.telemetry.getKeyedHistogramById(histogramId).snapshot().automationbrowser.sum, 0, + `Should have reported non-zero time spent using undo for ${histogramId}`); + } + + // Check that the undo removed the history visits: + visits = PlacesUtils.history.executeQuery(query, opts); + visits.root.containerOpen = true; + Assert.equal(visits.root.childCount, 0, "Should have no more visits"); + visits.root.containerOpen = false; + + // Check that the undo removed the bookmarks: + bookmark = yield PlacesUtils.bookmarks.fetch({url: "http://www.example.org/"}); + Assert.ok(!bookmark, "Should have no bookmarks after undo"); + + // Check that the undo removed the passwords: + storedLogins = Services.logins.findLogins({}, "www.mozilla.org", + "http://www.mozilla.org", null); + Assert.equal(storedLogins.length, 0, "Should have no logins"); +}); + +add_task(function* checkUndoBookmarksState() { + MigrationUtils.initializeUndoData(); + const {TYPE_FOLDER, TYPE_BOOKMARK} = PlacesUtils.bookmarks; + let title = "Some example bookmark"; + let url = "http://www.example.com"; + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + let {guid, lastModified} = yield MigrationUtils.insertBookmarkWrapper({ + title, url, parentGuid + }); + Assert.deepEqual((yield MigrationUtils.stopAndRetrieveUndoData()).get("bookmarks"), + [{lastModified, parentGuid, guid, type: TYPE_BOOKMARK}]); + + MigrationUtils.initializeUndoData(); + ({guid, lastModified} = yield MigrationUtils.insertBookmarkWrapper({ + title, parentGuid, type: TYPE_FOLDER + })); + let folder = {guid, lastModified, parentGuid, type: TYPE_FOLDER}; + let folderGuid = folder.guid; + ({guid, lastModified} = yield MigrationUtils.insertBookmarkWrapper({ + title, url, parentGuid: folderGuid + })); + let kid1 = {guid, lastModified, parentGuid: folderGuid, type: TYPE_BOOKMARK}; + ({guid, lastModified} = yield MigrationUtils.insertBookmarkWrapper({ + title, url, parentGuid: folderGuid + })); + let kid2 = {guid, lastModified, parentGuid: folderGuid, type: TYPE_BOOKMARK}; + + let bookmarksUndo = (yield MigrationUtils.stopAndRetrieveUndoData()).get("bookmarks"); + Assert.equal(bookmarksUndo.length, 3); + // We expect that the last modified time from first kid #1 and then kid #2 + // has been propagated to the folder: + folder.lastModified = kid2.lastModified; + // Not just using deepEqual on the entire array (which should work) because + // the failure messages get truncated by xpcshell which is unhelpful. + Assert.deepEqual(bookmarksUndo[0], folder); + Assert.deepEqual(bookmarksUndo[1], kid1); + Assert.deepEqual(bookmarksUndo[2], kid2); + yield PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(function* testBookmarkRemovalByUndo() { + const {TYPE_FOLDER} = PlacesUtils.bookmarks; + MigrationUtils.initializeUndoData(); + let title = "Some example bookmark"; + let url = "http://www.mymagicaluniqueurl.com"; + let parentGuid = PlacesUtils.bookmarks.toolbarGuid; + let {guid} = yield MigrationUtils.insertBookmarkWrapper({ + title: "Some folder", parentGuid, type: TYPE_FOLDER + }); + let folderGuid = guid; + let itemsToRemove = []; + ({guid} = yield MigrationUtils.insertBookmarkWrapper({ + title: "Inner folder", parentGuid: folderGuid, type: TYPE_FOLDER + })); + let innerFolderGuid = guid; + itemsToRemove.push(innerFolderGuid); + + ({guid} = yield MigrationUtils.insertBookmarkWrapper({ + title: "Inner inner folder", parentGuid: innerFolderGuid, type: TYPE_FOLDER + })); + itemsToRemove.push(guid); + + ({guid} = yield MigrationUtils.insertBookmarkWrapper({ + title: "Inner nested item", url: "http://inner-nested-example.com", parentGuid: guid + })); + itemsToRemove.push(guid); + + ({guid} = yield MigrationUtils.insertBookmarkWrapper({ + title, url, parentGuid: folderGuid + })); + itemsToRemove.push(guid); + + for (let toBeRemovedGuid of itemsToRemove) { + let dbResultForGuid = yield PlacesUtils.bookmarks.fetch(toBeRemovedGuid); + Assert.ok(dbResultForGuid, "Should be able to find items that will be removed."); + } + let bookmarkUndoState = (yield MigrationUtils.stopAndRetrieveUndoData()).get("bookmarks"); + // Now insert a separate item into this folder, not related to the migration. + let newItem = yield PlacesUtils.bookmarks.insert( + {title: "Not imported", parentGuid: folderGuid, url: "http://www.example.com"} + ); + + yield AutoMigrate._removeUnchangedBookmarks(bookmarkUndoState); + Assert.ok(true, "Successfully removed imported items."); + + let itemFromDB = yield PlacesUtils.bookmarks.fetch(newItem.guid); + Assert.ok(itemFromDB, "Item we inserted outside of migration is still there."); + itemFromDB = yield PlacesUtils.bookmarks.fetch(folderGuid); + Assert.ok(itemFromDB, "Folder we inserted in migration is still there because of new kids."); + for (let removedGuid of itemsToRemove) { + let dbResultForGuid = yield PlacesUtils.bookmarks.fetch(removedGuid); + let dbgStr = dbResultForGuid && dbResultForGuid.title; + Assert.equal(null, dbResultForGuid, "Should not be able to find items that should have been removed, but found " + dbgStr); + } + yield PlacesUtils.bookmarks.eraseEverything(); +}); + +add_task(function* checkUndoLoginsState() { + MigrationUtils.initializeUndoData(); + MigrationUtils.insertLoginWrapper({ + username: "foo", + password: "bar", + hostname: "https://example.com", + formSubmitURL: "https://example.com/", + timeCreated: new Date(), + }); + let storedLogins = Services.logins.findLogins({}, "https://example.com", "", ""); + let storedLogin = storedLogins[0]; + storedLogin.QueryInterface(Ci.nsILoginMetaInfo); + let {guid, timePasswordChanged} = storedLogin; + let undoLoginData = (yield MigrationUtils.stopAndRetrieveUndoData()).get("logins"); + Assert.deepEqual([{guid, timePasswordChanged}], undoLoginData); + Services.logins.removeAllLogins(); +}); + +add_task(function* testLoginsRemovalByUndo() { + MigrationUtils.initializeUndoData(); + MigrationUtils.insertLoginWrapper({ + username: "foo", + password: "bar", + hostname: "https://example.com", + formSubmitURL: "https://example.com/", + timeCreated: new Date(), + }); + MigrationUtils.insertLoginWrapper({ + username: "foo", + password: "bar", + hostname: "https://example.org", + formSubmitURL: "https://example.org/", + timeCreated: new Date(new Date().getTime() - 10000), + }); + // This should update the existing login + LoginHelper.maybeImportLogin({ + username: "foo", + password: "bazzy", + hostname: "https://example.org", + formSubmitURL: "https://example.org/", + timePasswordChanged: new Date(), + }); + Assert.equal(1, LoginHelper.searchLoginsWithObject({hostname: "https://example.org", formSubmitURL: "https://example.org/"}).length, + "Should be only 1 login for example.org (that was updated)"); + let undoLoginData = (yield MigrationUtils.stopAndRetrieveUndoData()).get("logins"); + + yield AutoMigrate._removeUnchangedLogins(undoLoginData); + Assert.equal(0, LoginHelper.searchLoginsWithObject({hostname: "https://example.com", formSubmitURL: "https://example.com/"}).length, + "unchanged example.com entry should have been removed."); + Assert.equal(1, LoginHelper.searchLoginsWithObject({hostname: "https://example.org", formSubmitURL: "https://example.org/"}).length, + "changed example.org entry should have persisted."); + Services.logins.removeAllLogins(); +}); + +add_task(function* checkUndoVisitsState() { + MigrationUtils.initializeUndoData(); + yield MigrationUtils.insertVisitsWrapper([{ + uri: NetUtil.newURI("http://www.example.com/"), + title: "Example", + visits: [{ + visitDate: new Date("2015-07-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-09-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-08-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }, { + uri: NetUtil.newURI("http://www.example.org/"), + title: "Example", + visits: [{ + visitDate: new Date("2016-04-03").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-08-03").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }, { + uri: NetUtil.newURI("http://www.example.com/"), + title: "Example", + visits: [{ + visitDate: new Date("2015-10-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }]); + let undoVisitData = (yield MigrationUtils.stopAndRetrieveUndoData()).get("visits"); + Assert.deepEqual(Array.from(undoVisitData.map(v => v.url)).sort(), + ["http://www.example.com/", "http://www.example.org/"]); + Assert.deepEqual(undoVisitData.find(v => v.url == "http://www.example.com/"), { + url: "http://www.example.com/", + visitCount: 4, + first: new Date("2015-07-10").getTime() * 1000, + last: new Date("2015-10-10").getTime() * 1000, + }); + Assert.deepEqual(undoVisitData.find(v => v.url == "http://www.example.org/"), { + url: "http://www.example.org/", + visitCount: 2, + first: new Date("2015-08-03").getTime() * 1000, + last: new Date("2016-04-03").getTime() * 1000, + }); + + yield PlacesTestUtils.clearHistory(); +}); + +add_task(function* checkUndoVisitsState() { + MigrationUtils.initializeUndoData(); + yield MigrationUtils.insertVisitsWrapper([{ + uri: NetUtil.newURI("http://www.example.com/"), + title: "Example", + visits: [{ + visitDate: new Date("2015-07-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-09-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-08-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }, { + uri: NetUtil.newURI("http://www.example.org/"), + title: "Example", + visits: [{ + visitDate: new Date("2016-04-03").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }, { + visitDate: new Date("2015-08-03").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }, { + uri: NetUtil.newURI("http://www.example.com/"), + title: "Example", + visits: [{ + visitDate: new Date("2015-10-10").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }, { + uri: NetUtil.newURI("http://www.mozilla.org/"), + title: "Example", + visits: [{ + visitDate: new Date("2015-01-01").getTime() * 1000, + transitionType: Ci.nsINavHistoryService.TRANSITION_LINK, + }], + }]); + + // We have to wait until frecency updates have been handled in order + // to accurately determine whether we're doing the right thing. + let frecencyUpdatesHandled = new Promise(resolve => { + PlacesUtils.history.addObserver({ + onFrecencyChanged(aURI) { + if (aURI.spec == "http://www.unrelated.org/") { + PlacesUtils.history.removeObserver(this); + resolve(); + } + } + }, false); + }); + yield PlacesUtils.history.insertMany([{ + url: "http://www.example.com/", + title: "Example", + visits: [{ + date: new Date("2015-08-16"), + }], + }, { + url: "http://www.example.org/", + title: "Example", + visits: [{ + date: new Date("2016-01-03"), + }, { + date: new Date("2015-05-03"), + }], + }, { + url: "http://www.unrelated.org/", + title: "Unrelated", + visits: [{ + date: new Date("2015-09-01"), + }], + }]); + yield frecencyUpdatesHandled; + let undoVisitData = (yield MigrationUtils.stopAndRetrieveUndoData()).get("visits"); + + let frecencyChangesExpected = new Map([ + ["http://www.example.com/", PromiseUtils.defer()], + ["http://www.example.org/", PromiseUtils.defer()] + ]); + let uriDeletedExpected = new Map([ + ["http://www.mozilla.org/", PromiseUtils.defer()], + ]); + let wrongMethodDeferred = PromiseUtils.defer(); + let observer = { + onBeginUpdateBatch: function() {}, + onEndUpdateBatch: function() {}, + onVisit: function(uri) { + wrongMethodDeferred.reject(new Error("Unexpected call to onVisit " + uri.spec)); + }, + onTitleChanged: function(uri) { + wrongMethodDeferred.reject(new Error("Unexpected call to onTitleChanged " + uri.spec)); + }, + onClearHistory: function() { + wrongMethodDeferred.reject("Unexpected call to onClearHistory"); + }, + onPageChanged: function(uri) { + wrongMethodDeferred.reject(new Error("Unexpected call to onPageChanged " + uri.spec)); + }, + onFrecencyChanged: function(aURI) { + do_print("frecency change"); + Assert.ok(frecencyChangesExpected.has(aURI.spec), + "Should be expecting frecency change for " + aURI.spec); + frecencyChangesExpected.get(aURI.spec).resolve(); + }, + onManyFrecenciesChanged: function() { + do_print("Many frecencies changed"); + wrongMethodDeferred.reject(new Error("This test can't deal with onManyFrecenciesChanged to be called")); + }, + onDeleteURI: function(aURI) { + do_print("delete uri"); + Assert.ok(uriDeletedExpected.has(aURI.spec), + "Should be expecting uri deletion for " + aURI.spec); + uriDeletedExpected.get(aURI.spec).resolve(); + }, + }; + PlacesUtils.history.addObserver(observer, false); + + yield AutoMigrate._removeSomeVisits(undoVisitData); + PlacesUtils.history.removeObserver(observer); + yield Promise.all(uriDeletedExpected.values()); + yield Promise.all(frecencyChangesExpected.values()); + + Assert.equal(yield visitsForURL("http://www.example.com/"), 1, + "1 example.com visit (out of 5) should have persisted despite being within the range, due to limiting"); + Assert.equal(yield visitsForURL("http://www.mozilla.org/"), 0, + "0 mozilla.org visits should have persisted (out of 1)."); + Assert.equal(yield visitsForURL("http://www.example.org/"), 2, + "2 example.org visits should have persisted (out of 4)."); + Assert.equal(yield visitsForURL("http://www.unrelated.org/"), 1, + "1 unrelated.org visits should have persisted as it's not involved in the import."); + yield PlacesTestUtils.clearHistory(); +}); + +add_task(function* checkHistoryRemovalCompletion() { + AutoMigrate._errorMap = {bookmarks: 0, visits: 0, logins: 0}; + yield AutoMigrate._removeSomeVisits([{url: "http://www.example.com/", limit: -1}]); + ok(true, "Removing visits should complete even if removing some visits failed."); + Assert.equal(AutoMigrate._errorMap.visits, 1, "Should have logged the error for visits."); + + // Unfortunately there's not a reliable way to make removing bookmarks be + // unhappy unless the DB is messed up (e.g. contains children but has + // parents removed already). + yield AutoMigrate._removeUnchangedBookmarks([ + {guid: PlacesUtils.bookmarks, lastModified: new Date(0), parentGuid: 0}, + {guid: "gobbledygook", lastModified: new Date(0), parentGuid: 0}, + ]); + ok(true, "Removing bookmarks should complete even if some items are gone or bogus."); + Assert.equal(AutoMigrate._errorMap.bookmarks, 0, + "Should have ignored removing non-existing (or builtin) bookmark."); + + + yield AutoMigrate._removeUnchangedLogins([ + {guid: "gobbledygook", timePasswordChanged: new Date(0)}, + ]); + ok(true, "Removing logins should complete even if logins don't exist."); + Assert.equal(AutoMigrate._errorMap.logins, 0, + "Should have ignored removing non-existing logins."); +}); 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 000000000..a276f52f8 --- /dev/null +++ b/browser/components/migration/tests/unit/test_fx_telemetry.js @@ -0,0 +1,288 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +/* globals do_get_tempdir */ + +"use strict"; + +function run_test() { + run_next_test(); +} + +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(); + let enumerator = dir.directoryEntries; + while (enumerator.hasMoreElements()) { + let file = enumerator.getNext().QueryInterface(Ci.nsIFile); + 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(function* test_empty() { + let [srcDir, targetDir] = getTestDirs(); + let ok = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true with empty directories"); + // check both are empty + checkDirectoryContains(srcDir, {}); + checkDirectoryContains(targetDir, {}); +}); + +add_task(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 = yield 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(function* test_fallback_fhr_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Test that we fall back to migrating FHR state if the datareporting + // state file does not exist. + let stateContent = JSON.stringify({ + clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c", + }); + let subDir = createSubDir(srcDir, "healthreport"); + writeToFile(subDir, "state.json", stateContent); + + // Perform migration. + let ok = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + "healthreport": { + "state.json": stateContent, + }, + }); +}); + + +add_task(function* test_datareporting_not_dir() { + let [srcDir, targetDir] = getTestDirs(); + + writeToFile(srcDir, "datareporting", "I'm a file but should be a directory"); + + let ok = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true even though the directory was a file"); + + checkDirectoryContains(targetDir, {}); +}); + +add_task(function* test_datareporting_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with an empty 'datareporting' subdir. + createSubDir(srcDir, "datareporting"); + let ok = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, { + "datareporting": {}, + }); +}); + +add_task(function* test_healthreport_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with no 'datareporting' and an empty 'healthreport' subdir. + createSubDir(srcDir, "healthreport"); + let ok = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, {}); +}); + +add_task(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 = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + "datareporting" : { + "state.json": shouldBeCopied, + "session-state.json": shouldBeCopied, + } + }); +}); + +add_task(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 = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + "datareporting" : { + "state.json": stateContent, + } + }); +}); + +add_task(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 = yield promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + "datareporting" : { + "session-state.json": sessionStateContent, + } + }); +}); + +add_task(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 = yield 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 000000000..1b9f0a5f1 --- /dev/null +++ b/browser/components/migration/tests/unit/xpcshell.ini @@ -0,0 +1,26 @@ +[DEFAULT] +head = head_migration.js +tail = +firefox-appdir = browser +skip-if = toolkit == 'android' +support-files = + Library/** + AppData/** + +[test_automigration.js] +[test_Chrome_cookies.js] +skip-if = os != "mac" # Relies on ULibDir +[test_Chrome_passwords.js] +skip-if = os != "win" +[test_Edge_availability.js] +[test_Edge_db_migration.js] +skip-if = os != "win" || os_version == "5.1" || os_version == "5.2" # Relies on post-XP bits of ESEDB +[test_fx_telemetry.js] +[test_IE_bookmarks.js] +skip-if = os != "win" +[test_IE_cookies.js] +skip-if = os != "win" +[test_IE7_passwords.js] +skip-if = os != "win" +[test_Safari_bookmarks.js] +skip-if = os != "mac" |