From 9e3c08db40b8916968b9f30096c7be3f00ce9647 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 21 Apr 2024 13:44:51 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- browser/components/migration/.eslintrc.js | 43 + .../migration/360seMigrationUtils.sys.mjs | 191 ++++ .../migration/ChromeMacOSLoginCrypto.sys.mjs | 185 ++++ .../migration/ChromeMigrationUtils.sys.mjs | 469 ++++++++ .../migration/ChromeProfileMigrator.sys.mjs | 1018 +++++++++++++++++ .../migration/ChromeWindowsLoginCrypto.sys.mjs | 176 +++ browser/components/migration/ESEDBReader.sys.mjs | 800 +++++++++++++ .../migration/EdgeProfileMigrator.sys.mjs | 589 ++++++++++ browser/components/migration/FileMigrators.sys.mjs | 329 ++++++ .../migration/FirefoxProfileMigrator.sys.mjs | 397 +++++++ .../components/migration/IEProfileMigrator.sys.mjs | 402 +++++++ .../InternalTestingProfileMigrator.sys.mjs | 63 ++ .../components/migration/MSMigrationUtils.sys.mjs | 754 +++++++++++++ .../components/migration/MigrationUtils.sys.mjs | 1171 ++++++++++++++++++++ .../migration/MigrationWizardChild.sys.mjs | 328 ++++++ .../migration/MigrationWizardParent.sys.mjs | 651 +++++++++++ browser/components/migration/MigratorBase.sys.mjs | 587 ++++++++++ .../components/migration/ProfileMigrator.sys.mjs | 15 + .../migration/SafariProfileMigrator.sys.mjs | 674 +++++++++++ browser/components/migration/components.conf | 37 + .../migration/content/aboutWelcomeBack.xhtml | 126 +++ .../components/migration/content/brands/360.png | Bin 0 -> 21075 bytes .../components/migration/content/brands/brave.png | Bin 0 -> 7099 bytes .../components/migration/content/brands/canary.png | Bin 0 -> 7463 bytes .../components/migration/content/brands/chrome.png | Bin 0 -> 8353 bytes .../migration/content/brands/chromium.png | Bin 0 -> 6408 bytes .../components/migration/content/brands/edge.png | Bin 0 -> 11899 bytes .../migration/content/brands/edgebeta.png | Bin 0 -> 12273 bytes browser/components/migration/content/brands/ie.png | Bin 0 -> 6871 bytes .../components/migration/content/brands/opera.png | Bin 0 -> 5403 bytes .../migration/content/brands/operagx.png | Bin 0 -> 8222 bytes .../components/migration/content/brands/safari.png | Bin 0 -> 20520 bytes .../migration/content/brands/vivaldi.png | Bin 0 -> 7535 bytes .../migration/content/migration-dialog-window.html | 34 + .../migration/content/migration-dialog-window.js | 82 ++ .../content/migration-wizard-constants.mjs | 76 ++ .../migration/content/migration-wizard.mjs | 1088 ++++++++++++++++++ browser/components/migration/content/migration.js | 812 ++++++++++++++ .../components/migration/content/migration.xhtml | 113 ++ browser/components/migration/docs/index.rst | 16 + .../components/migration/docs/migration-utils.rst | 5 + .../docs/migration-wizard-architecture-diagram.svg | 128 +++ .../components/migration/docs/migration-wizard.rst | 77 ++ browser/components/migration/docs/migrators.rst | 112 ++ browser/components/migration/jar.mn | 33 + browser/components/migration/moz.build | 85 ++ .../components/migration/nsEdgeMigrationUtils.cpp | 61 + .../components/migration/nsEdgeMigrationUtils.h | 24 + .../components/migration/nsIEHistoryEnumerator.cpp | 116 ++ .../components/migration/nsIEHistoryEnumerator.h | 39 + .../components/migration/nsIEdgeMigrationUtils.idl | 23 + .../migration/nsIKeychainMigrationUtils.idl | 12 + .../migration/nsKeychainMigrationUtils.h | 23 + .../migration/nsKeychainMigrationUtils.mm | 62 ++ .../components/migration/nsWindowsMigrationUtils.h | 33 + .../components/migration/tests/browser/browser.ini | 26 + .../tests/browser/browser_aboutwelcome_behavior.js | 100 ++ .../tests/browser/browser_dialog_cancel_close.js | 55 + .../migration/tests/browser/browser_dialog_open.js | 55 + .../tests/browser/browser_dialog_resize.js | 29 + .../tests/browser/browser_disabled_migrator.js | 131 +++ .../tests/browser/browser_do_migration.js | 195 ++++ .../tests/browser/browser_entrypoint_telemetry.js | 105 ++ .../tests/browser/browser_file_migration.js | 185 ++++ .../browser_ie_edge_bookmarks_success_strings.js | 89 ++ .../tests/browser/browser_no_browsers_state.js | 92 ++ .../tests/browser/browser_only_file_migrators.js | 71 ++ .../tests/browser/browser_safari_passwords.js | 401 +++++++ .../tests/browser/browser_safari_permissions.js | 133 +++ .../migration/tests/browser/dummy_file.csv | 1 + browser/components/migration/tests/browser/head.js | 350 ++++++ .../components/migration/tests/chrome/chrome.ini | 4 + .../tests/chrome/test_migration_wizard.html | 1154 +++++++++++++++++++ .../migration/tests/marionette/manifest.ini | 4 + .../tests/marionette/test_refresh_firefox.py | 690 ++++++++++++ .../Local/Google/Chrome/User Data/Default/Favicons | Bin 0 -> 49152 bytes .../Google/Chrome/User Data/Default/Login Data | Bin 0 -> 24576 bytes .../Local/Google/Chrome/User Data/Default/Web Data | Bin 0 -> 108544 bytes .../Local/Google/Chrome/User Data/Local State | 5 + .../Google/Chrome/User Data/Default/Login Data | Bin 0 -> 24576 bytes .../0f3ab103a522f4463ecacc36d34eb996/360sefav.dat | Bin 0 -> 6144 bytes .../Roaming/360se6/User Data/Default/360Bookmarks | 1 + .../DailyBackup/360default_ori_2020_08_28.favdb | 3 + .../DailyBackup/360default_ori_2021_12_02.favdb | 3 + .../DailyBackup/360sefav_new_2020_08_28.favdb | 1 + .../DailyBackup/360sefav_new_2021_12_02.favdb | 1 + .../0f3ab103a522f4463ecacc36d34eb996/360sefav.dat | 1 + .../DailyBackup/360sefav_2020_08_28.favdb | 3 + .../360se6/User Data/Default4SE9Test/Bookmarks | 3 + .../AppData/Roaming/360se6/User Data/Local State | 12 + .../Google/Chrome/Default/Cookies | Bin 0 -> 10240 bytes .../fake-app-1/1.0_0/_locales/en_US/messages.json | 9 + .../Extensions/fake-app-1/1.0_0/manifest.json | 10 + .../1.0_0/_locales/en_US/messages.json | 9 + .../fake-extension-1/1.0_0/manifest.json | 5 + .../fake-extension-2/1.0_0/manifest.json | 5 + .../Google/Chrome/Default/HistoryCorrupt | Bin 0 -> 91558 bytes .../Google/Chrome/Default/HistoryMaster | Bin 0 -> 118784 bytes .../Google/Chrome/Default/Login Data | Bin 0 -> 24576 bytes .../Application Support/Google/Chrome/Local State | 22 + .../tests/unit/Library/Safari/Bookmarks.plist | Bin 0 -> 2252 bytes .../unit/Library/Safari/Favicon Cache/favicons.db | Bin 0 -> 40960 bytes .../Library/Safari/Favicon Cache/favicons.db-lock | 0 .../Library/Safari/Favicon Cache/favicons.db-shm | Bin 0 -> 32768 bytes .../Library/Safari/Favicon Cache/favicons.db-wal | Bin 0 -> 280192 bytes .../favicons/04860FA3D07D8936B87D2B965317C6E9 | Bin 0 -> 24838 bytes .../favicons/19A777E4F7BDA0C0E350D6C681B6E271 | Bin 0 -> 15086 bytes .../favicons/2558A57A1AE576AA31F0DCD1364B3F42 | Bin 0 -> 5558 bytes .../favicons/57D1907A1EBDA1889AA85B8AB7A90804 | Bin 0 -> 5558 bytes .../favicons/6EEDD53B65A19CB364EB6FB07DEACF80 | Bin 0 -> 22382 bytes .../favicons/7F65370AD319C7B294EDF2E2BEBA880F | Bin 0 -> 2734 bytes .../favicons/999E2BD5CD612AA550F222A1088DB3D8 | Bin 0 -> 15406 bytes .../favicons/9D8A6E2153D42043A7AE0430B41D374A | Bin 0 -> 5558 bytes .../favicons/A21F634481CF5188329FD2052F07ADBC | Bin 0 -> 5558 bytes .../favicons/BC2288B5BA9B7BE352BA586257442E08 | Bin 0 -> 22382 bytes .../favicons/CFFC3831D8E7201BF8B77728FC79B52B | Bin 0 -> 15406 bytes .../favicons/F3FA61DDA95B78A8B5F2C392C0382137 | Bin 0 -> 5430 bytes .../tests/unit/Library/Safari/HistoryTemplate.db | Bin 0 -> 98304 bytes .../Google/Chrome/Default/Login Data | Bin 0 -> 24576 bytes .../Application Support/Google/Chrome/Local State | 22 + .../migration/tests/unit/bookmarks.exported.html | 32 + .../migration/tests/unit/bookmarks.exported.json | 194 ++++ .../migration/tests/unit/head_migration.js | 261 +++++ .../tests/unit/insertIEHistory/InsertIEHistory.cpp | 37 + .../migration/tests/unit/insertIEHistory/moz.build | 19 + .../tests/unit/test_360seMigrationUtils.js | 164 +++ .../migration/tests/unit/test_360se_bookmarks.js | 62 ++ .../tests/unit/test_BookmarksFileMigrator.js | 120 ++ .../tests/unit/test_ChromeMigrationUtils.js | 86 ++ .../tests/unit/test_ChromeMigrationUtils_path.js | 141 +++ ...test_ChromeMigrationUtils_path_chromium_snap.js | 55 + .../migration/tests/unit/test_Chrome_bookmarks.js | 199 ++++ .../tests/unit/test_Chrome_corrupt_history.js | 83 ++ .../tests/unit/test_Chrome_credit_cards.js | 239 ++++ .../migration/tests/unit/test_Chrome_formdata.js | 118 ++ .../migration/tests/unit/test_Chrome_history.js | 206 ++++ .../migration/tests/unit/test_Chrome_passwords.js | 373 +++++++ .../unit/test_Chrome_passwords_emptySource.js | 24 + .../migration/tests/unit/test_Edge_db_migration.js | 849 ++++++++++++++ .../tests/unit/test_Edge_registry_migration.js | 81 ++ .../migration/tests/unit/test_IE_bookmarks.js | 30 + .../migration/tests/unit/test_IE_history.js | 187 ++++ .../tests/unit/test_MigrationUtils_timedRetry.js | 29 + .../tests/unit/test_PasswordFileMigrator.js | 83 ++ .../migration/tests/unit/test_Safari_bookmarks.js | 85 ++ .../migration/tests/unit/test_Safari_history.js | 101 ++ .../migration/tests/unit/test_fx_telemetry.js | 393 +++++++ .../components/migration/tests/unit/xpcshell.ini | 58 + 148 files changed, 20353 insertions(+) create mode 100644 browser/components/migration/.eslintrc.js create mode 100644 browser/components/migration/360seMigrationUtils.sys.mjs create mode 100644 browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs create mode 100644 browser/components/migration/ChromeMigrationUtils.sys.mjs create mode 100644 browser/components/migration/ChromeProfileMigrator.sys.mjs create mode 100644 browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs create mode 100644 browser/components/migration/ESEDBReader.sys.mjs create mode 100644 browser/components/migration/EdgeProfileMigrator.sys.mjs create mode 100644 browser/components/migration/FileMigrators.sys.mjs create mode 100644 browser/components/migration/FirefoxProfileMigrator.sys.mjs create mode 100644 browser/components/migration/IEProfileMigrator.sys.mjs create mode 100644 browser/components/migration/InternalTestingProfileMigrator.sys.mjs create mode 100644 browser/components/migration/MSMigrationUtils.sys.mjs create mode 100644 browser/components/migration/MigrationUtils.sys.mjs create mode 100644 browser/components/migration/MigrationWizardChild.sys.mjs create mode 100644 browser/components/migration/MigrationWizardParent.sys.mjs create mode 100644 browser/components/migration/MigratorBase.sys.mjs create mode 100644 browser/components/migration/ProfileMigrator.sys.mjs create mode 100644 browser/components/migration/SafariProfileMigrator.sys.mjs create mode 100644 browser/components/migration/components.conf create mode 100644 browser/components/migration/content/aboutWelcomeBack.xhtml create mode 100644 browser/components/migration/content/brands/360.png create mode 100644 browser/components/migration/content/brands/brave.png create mode 100644 browser/components/migration/content/brands/canary.png create mode 100644 browser/components/migration/content/brands/chrome.png create mode 100644 browser/components/migration/content/brands/chromium.png create mode 100644 browser/components/migration/content/brands/edge.png create mode 100644 browser/components/migration/content/brands/edgebeta.png create mode 100644 browser/components/migration/content/brands/ie.png create mode 100644 browser/components/migration/content/brands/opera.png create mode 100644 browser/components/migration/content/brands/operagx.png create mode 100644 browser/components/migration/content/brands/safari.png create mode 100644 browser/components/migration/content/brands/vivaldi.png create mode 100644 browser/components/migration/content/migration-dialog-window.html create mode 100644 browser/components/migration/content/migration-dialog-window.js create mode 100644 browser/components/migration/content/migration-wizard-constants.mjs create mode 100644 browser/components/migration/content/migration-wizard.mjs create mode 100644 browser/components/migration/content/migration.js create mode 100644 browser/components/migration/content/migration.xhtml create mode 100644 browser/components/migration/docs/index.rst create mode 100644 browser/components/migration/docs/migration-utils.rst create mode 100644 browser/components/migration/docs/migration-wizard-architecture-diagram.svg create mode 100644 browser/components/migration/docs/migration-wizard.rst create mode 100644 browser/components/migration/docs/migrators.rst create mode 100644 browser/components/migration/jar.mn create mode 100644 browser/components/migration/moz.build create mode 100644 browser/components/migration/nsEdgeMigrationUtils.cpp create mode 100644 browser/components/migration/nsEdgeMigrationUtils.h create mode 100644 browser/components/migration/nsIEHistoryEnumerator.cpp create mode 100644 browser/components/migration/nsIEHistoryEnumerator.h create mode 100644 browser/components/migration/nsIEdgeMigrationUtils.idl create mode 100644 browser/components/migration/nsIKeychainMigrationUtils.idl create mode 100644 browser/components/migration/nsKeychainMigrationUtils.h create mode 100644 browser/components/migration/nsKeychainMigrationUtils.mm create mode 100644 browser/components/migration/nsWindowsMigrationUtils.h create mode 100644 browser/components/migration/tests/browser/browser.ini create mode 100644 browser/components/migration/tests/browser/browser_aboutwelcome_behavior.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_cancel_close.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_open.js create mode 100644 browser/components/migration/tests/browser/browser_dialog_resize.js create mode 100644 browser/components/migration/tests/browser/browser_disabled_migrator.js create mode 100644 browser/components/migration/tests/browser/browser_do_migration.js create mode 100644 browser/components/migration/tests/browser/browser_entrypoint_telemetry.js create mode 100644 browser/components/migration/tests/browser/browser_file_migration.js create mode 100644 browser/components/migration/tests/browser/browser_ie_edge_bookmarks_success_strings.js create mode 100644 browser/components/migration/tests/browser/browser_no_browsers_state.js create mode 100644 browser/components/migration/tests/browser/browser_only_file_migrators.js create mode 100644 browser/components/migration/tests/browser/browser_safari_passwords.js create mode 100644 browser/components/migration/tests/browser/browser_safari_permissions.js create mode 100644 browser/components/migration/tests/browser/dummy_file.csv create mode 100644 browser/components/migration/tests/browser/head.js create mode 100644 browser/components/migration/tests/chrome/chrome.ini create mode 100644 browser/components/migration/tests/chrome/test_migration_wizard.html create mode 100644 browser/components/migration/tests/marionette/manifest.ini create mode 100644 browser/components/migration/tests/marionette/test_refresh_firefox.py create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data create mode 100644 browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State create mode 100644 browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks create mode 100644 browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data create mode 100644 browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State create mode 100644 browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B create mode 100644 browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 create mode 100644 browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db create mode 100644 browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data create mode 100644 browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State create mode 100644 browser/components/migration/tests/unit/bookmarks.exported.html create mode 100644 browser/components/migration/tests/unit/bookmarks.exported.json create mode 100644 browser/components/migration/tests/unit/head_migration.js create mode 100644 browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp create mode 100644 browser/components/migration/tests/unit/insertIEHistory/moz.build create mode 100644 browser/components/migration/tests/unit/test_360seMigrationUtils.js create mode 100644 browser/components/migration/tests/unit/test_360se_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_BookmarksFileMigrator.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js create mode 100644 browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_corrupt_history.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_credit_cards.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_formdata.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_history.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_passwords.js create mode 100644 browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js create mode 100644 browser/components/migration/tests/unit/test_Edge_db_migration.js create mode 100644 browser/components/migration/tests/unit/test_Edge_registry_migration.js create mode 100644 browser/components/migration/tests/unit/test_IE_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_IE_history.js create mode 100644 browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js create mode 100644 browser/components/migration/tests/unit/test_PasswordFileMigrator.js create mode 100644 browser/components/migration/tests/unit/test_Safari_bookmarks.js create mode 100644 browser/components/migration/tests/unit/test_Safari_history.js create mode 100644 browser/components/migration/tests/unit/test_fx_telemetry.js create mode 100644 browser/components/migration/tests/unit/xpcshell.ini (limited to 'browser/components/migration') diff --git a/browser/components/migration/.eslintrc.js b/browser/components/migration/.eslintrc.js new file mode 100644 index 0000000000..236a559d13 --- /dev/null +++ b/browser/components/migration/.eslintrc.js @@ -0,0 +1,43 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +module.exports = { + extends: ["plugin:mozilla/require-jsdoc"], + rules: { + "block-scoped-var": "error", + complexity: ["error", { max: 22 }], + "max-nested-callbacks": ["error", 3], + "no-extend-native": "error", + "no-fallthrough": [ + "error", + { + commentPattern: + ".*[Ii]ntentional(?:ly)?\\s+fall(?:ing)?[\\s-]*through.*", + }, + ], + "no-multi-str": "error", + "no-return-assign": "error", + "no-shadow": "error", + "no-unused-vars": ["error", { args: "after-used", vars: "all" }], + strict: ["error", "global"], + yoda: "error", + }, + + overrides: [ + { + files: ["tests/browser/head.js", "tests/unit/head*.js"], + rules: { + "no-unused-vars": [ + "error", + { + args: "none", + vars: "local", + }, + ], + }, + }, + ], +}; diff --git a/browser/components/migration/360seMigrationUtils.sys.mjs b/browser/components/migration/360seMigrationUtils.sys.mjs new file mode 100644 index 0000000000..91bf263d74 --- /dev/null +++ b/browser/components/migration/360seMigrationUtils.sys.mjs @@ -0,0 +1,191 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter( + lazy, + "filenamesRegex", + () => /^360(?:default_ori|sefav)_([0-9_]+)\.favdb$/i +); + +const kBookmarksFileName = "360sefav.dat"; + +function Bookmarks(aProfileFolder) { + let file = aProfileFolder.clone(); + file.append(kBookmarksFileName); + + this._file = file; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return this._file.exists() && this._file.isReadable(); + }, + + migrate(aCallback) { + return (async () => { + let folderMap = new Map(); + let toolbarBMs = []; + + let connection = await lazy.Sqlite.openConnection({ + path: this._file.path, + }); + + try { + let rows = await connection.execute( + `WITH RECURSIVE + bookmark(id, parent_id, is_folder, title, url, pos) AS ( + VALUES(0, -1, 1, '', '', 0) + UNION + SELECT f.id, f.parent_id, f.is_folder, f.title, f.url, f.pos + FROM tb_fav AS f + JOIN bookmark AS b ON f.parent_id = b.id + ORDER BY f.pos ASC + ) + SELECT id, parent_id, is_folder, title, url FROM bookmark WHERE id` + ); + + for (let row of rows) { + let id = parseInt(row.getResultByName("id"), 10); + let parent_id = parseInt(row.getResultByName("parent_id"), 10); + let is_folder = parseInt(row.getResultByName("is_folder"), 10); + let title = row.getResultByName("title"); + let url = row.getResultByName("url"); + + let bmToInsert; + + if (is_folder) { + bmToInsert = { + children: [], + title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + }; + folderMap.set(id, bmToInsert); + } else { + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from 360se because of exception: ${ex}` + ); + continue; + } + + bmToInsert = { + title, + url, + }; + } + + if (folderMap.has(parent_id)) { + folderMap.get(parent_id).children.push(bmToInsert); + } else if (parent_id === 0) { + toolbarBMs.push(bmToInsert); + } + } + } finally { + await connection.close(); + } + + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, +}; + +export var Qihoo360seMigrationUtils = { + async getAlternativeBookmarks({ bookmarksPath, localState }) { + let lastModificationDate = new Date(0); + let path = bookmarksPath; + let profileFolder = PathUtils.parent(bookmarksPath); + + if (await IOUtils.exists(bookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(bookmarksPath); + lastModificationDate = new Date(lastModified); + } catch (ex) { + console.error(ex); + } + } + + // Somewhat similar to source profiles, but for bookmarks only + let subDir = + (localState.sync_login_info && localState.sync_login_info.filepath) || ""; + + if (subDir) { + let legacyBookmarksPath = PathUtils.join( + profileFolder, + subDir, + kBookmarksFileName + ); + if (await IOUtils.exists(legacyBookmarksPath)) { + try { + let { lastModified } = await IOUtils.stat(legacyBookmarksPath); + lastModificationDate = new Date(lastModified); + path = legacyBookmarksPath; + } catch (ex) { + console.error(ex); + } + } + } + + let dailyBackupPath = PathUtils.join(profileFolder, subDir, "DailyBackup"); + for (const entry of await IOUtils.getChildren(dailyBackupPath, { + ignoreAbsent: true, + })) { + let filename = PathUtils.filename(entry); + let matches = lazy.filenamesRegex.exec(filename); + if (!matches) { + continue; + } + + let entryDate = new Date(matches[1].replace(/_/g, "-")); + if (entryDate < lastModificationDate) { + continue; + } + + lastModificationDate = entryDate; + path = entry; + } + + if (PathUtils.filename(path) === kBookmarksFileName) { + let resource = this.getLegacyBookmarksResource(PathUtils.parent(path)); + return { resource }; + } + return { path }; + }, + + getLegacyBookmarksResource(aParentFolder) { + let parentFolder; + try { + parentFolder = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile); + parentFolder.initWithPath(aParentFolder); + } catch (ex) { + console.error(ex); + return null; + } + + let bookmarks = new Bookmarks(parentFolder); + return bookmarks.exists ? bookmarks : null; + }, +}; diff --git a/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs new file mode 100644 index 0000000000..595bbc28c4 --- /dev/null +++ b/browser/components/migration/ChromeMacOSLoginCrypto.sys.mjs @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on macOS. + */ + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "gKeychainUtils", + "@mozilla.org/profile/migrator/keychainmigrationutils;1", + "nsIKeychainMigrationUtils" +); + +const gTextEncoder = new TextEncoder(); +const gTextDecoder = new TextDecoder(); + +/** + * From macOS' CommonCrypto/CommonCryptor.h + */ +const kCCBlockSizeAES128 = 16; + +/* Chromium constants */ + +/** + * kSalt from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=43&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const SALT = "saltysalt"; + +/** + * kDerivedKeySizeInBits from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=46&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const DERIVED_KEY_SIZE_BITS = 128; + +/** + * kEncryptionIterations from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=49&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ITERATIONS = 1003; + +/** + * kEncryptionVersionPrefix from Chromium. + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=61&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const ENCRYPTION_VERSION_PREFIX = "v10"; + +/** + * The initialization vector is 16 space characters (character code 32 in decimal). + * + * @see https://cs.chromium.org/chromium/src/components/os_crypt/os_crypt_mac.mm?l=220&rcl=1771751f87e3e99bb6cd67b5d0e159ae487f8db0 + */ +const IV = new Uint8Array(kCCBlockSizeAES128).fill(32); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. This isn't implemented as OSCrypto_mac.js since + * it isn't calling into encryption functions provided by macOS but instead + * relies on OS encryption key storage in Keychain. The algorithms here are + * specific to what is needed for Chrome login storage on macOS. + */ +export class ChromeMacOSLoginCrypto { + /** + * @param {string} serviceName of the Keychain Item to use to derive a key. + * @param {string} accountName of the Keychain Item to use to derive a key. + * @param {string?} [testingPassphrase = null] A string to use as the passphrase + * to derive a key for testing purposes rather than retrieving + * it from the macOS Keychain since we don't yet have a way to + * mock the Keychain auth dialog. + */ + constructor(serviceName, accountName, testingPassphrase = null) { + // We still exercise the keychain migration utils code when using a + // `testingPassphrase` in order to get some test coverage for that + // component, even though it's expected to throw since a login item with the + // service name and account name usually won't be found. + let encKey = testingPassphrase; + try { + encKey = lazy.gKeychainUtils.getGenericPassword(serviceName, accountName); + } catch (ex) { + if (!testingPassphrase) { + throw ex; + } + } + + this.ALGORITHM = "AES-CBC"; + + this._keyPromise = crypto.subtle + .importKey("raw", gTextEncoder.encode(encKey), "PBKDF2", false, [ + "deriveKey", + ]) + .then(key => { + return crypto.subtle.deriveKey( + { + name: "PBKDF2", + salt: gTextEncoder.encode(SALT), + iterations: ITERATIONS, + hash: "SHA-1", + }, + key, + { name: this.ALGORITHM, length: DERIVED_KEY_SIZE_BITS }, + false, + ["decrypt", "encrypt"] + ); + }) + .catch(console.error); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + let len = binary_string.length; + let bytes = new Uint8Array(len); + for (var i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {Array} ciphertextArray ciphertext prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertextArray) { + let ciphertext = this.arrayToString(ciphertextArray); + if (!ciphertext.startsWith(ENCRYPTION_VERSION_PREFIX)) { + throw new Error("Unknown encryption version"); + } + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + let plaintext = await crypto.subtle.decrypt( + { name: this.ALGORITHM, iv: IV }, + key, + this.stringToArray(ciphertext.substring(ENCRYPTION_VERSION_PREFIX.length)) + ); + return gTextDecoder.decode(plaintext); + } + + /** + * @param {USVString} plaintext to encrypt + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext) { + let key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + let ciphertext = await crypto.subtle.encrypt( + { name: this.ALGORITHM, iv: IV }, + key, + gTextEncoder.encode(plaintext) + ); + return ( + ENCRYPTION_VERSION_PREFIX + + String.fromCharCode(...new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ChromeMigrationUtils.sys.mjs b/browser/components/migration/ChromeMigrationUtils.sys.mjs new file mode 100644 index 0000000000..99a8987bef --- /dev/null +++ b/browser/components/migration/ChromeMigrationUtils.sys.mjs @@ -0,0 +1,469 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +const S100NS_FROM1601TO1970 = 0x19db1ded53e8000; +const S100NS_PER_MS = 10; + +export var ChromeMigrationUtils = { + // Supported browsers with importable logins. + CONTEXTUAL_LOGIN_IMPORT_BROWSERS: ["chrome", "chromium-edge", "chromium"], + + _extensionVersionDirectoryNames: {}, + + // The cache for the locale strings. + // For example, the data could be: + // { + // "profile-id-1": { + // "extension-id-1": { + // "name": { + // "message": "Fake App 1" + // } + // }, + // } + _extensionLocaleStrings: {}, + + get supportsLoginsForPlatform() { + return ["macosx", "win"].includes(AppConstants.platform); + }, + + /** + * Get all extensions installed in a specific profile. + * + * @param {string} profileId - A Chrome user profile ID. For example, "Profile 1". + * @returns {Array} All installed Chrome extensions information. + */ + async getExtensionList(profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let path = await this.getExtensionPath(profileId); + let extensionList = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + let extensionInformation = await this.getExtensionInformation( + name, + profileId + ); + if (extensionInformation) { + extensionList.push(extensionInformation); + } + } + } + } catch (ex) { + console.error(ex); + } + return extensionList; + }, + + /** + * Get information of a specific Chrome extension. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {object} The Chrome extension information. + */ + async getExtensionInformation(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionInformation = null; + try { + let manifestPath = await this.getExtensionPath(profileId); + manifestPath = PathUtils.join(manifestPath, extensionId); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + let directories = await this._getSortedByVersionSubDirectoryNames( + manifestPath + ); + if (!directories[0]) { + return null; + } + + manifestPath = PathUtils.join( + manifestPath, + directories[0], + "manifest.json" + ); + let manifest = await IOUtils.readJSON(manifestPath); + // No app attribute means this is a Chrome extension not a Chrome app. + if (!manifest.app) { + const DEFAULT_LOCALE = manifest.default_locale; + let name = await this._getLocaleString( + manifest.name, + DEFAULT_LOCALE, + extensionId, + profileId + ); + let description = await this._getLocaleString( + manifest.description, + DEFAULT_LOCALE, + extensionId, + profileId + ); + if (name) { + extensionInformation = { + id: extensionId, + name, + description, + }; + } else { + throw new Error("Cannot read the Chrome extension's name property."); + } + } + } catch (ex) { + console.error(ex); + } + return extensionInformation; + }, + + /** + * Get the manifest's locale string. + * + * @param {string} key - The key of a locale string, for example __MSG_name__. + * @param {string} locale - The specific language of locale string. + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {string} The locale string. + */ + async _getLocaleString(key, locale, extensionId, profileId) { + // Return the key string if it is not a locale key. + // The key string starts with "__MSG_" and ends with "__". + // For example, "__MSG_name__". + // https://developer.chrome.com/apps/i18n + if (!key.startsWith("__MSG_") || !key.endsWith("__")) { + return key; + } + + let localeString = null; + try { + let localeFile; + if ( + this._extensionLocaleStrings[profileId] && + this._extensionLocaleStrings[profileId][extensionId] + ) { + localeFile = this._extensionLocaleStrings[profileId][extensionId]; + } else { + if (!this._extensionLocaleStrings[profileId]) { + this._extensionLocaleStrings[profileId] = {}; + } + let localeFilePath = await this.getExtensionPath(profileId); + localeFilePath = PathUtils.join(localeFilePath, extensionId); + let directories = await this._getSortedByVersionSubDirectoryNames( + localeFilePath + ); + // If there are multiple sub-directories in the extension directory, + // read the files in the latest directory. + localeFilePath = PathUtils.join( + localeFilePath, + directories[0], + "_locales", + locale, + "messages.json" + ); + localeFile = await IOUtils.readJSON(localeFilePath); + this._extensionLocaleStrings[profileId][extensionId] = localeFile; + } + const PREFIX_LENGTH = 6; + const SUFFIX_LENGTH = 2; + // Get the locale key from the string with locale prefix and suffix. + // For example, it will get the "name" sub-string from the "__MSG_name__" string. + key = key.substring(PREFIX_LENGTH, key.length - SUFFIX_LENGTH); + if (localeFile[key] && localeFile[key].message) { + localeString = localeFile[key].message; + } + } catch (ex) { + console.error(ex); + } + return localeString; + }, + + /** + * Check that a specific extension is installed or not. + * + * @param {string} extensionId - The extension ID. + * @param {string} profileId - The user profile's ID. + * @returns {boolean} Return true if the extension is installed otherwise return false. + */ + async isExtensionInstalled(extensionId, profileId) { + if (profileId === undefined) { + profileId = await this.getLastUsedProfileId(); + } + let extensionPath = await this.getExtensionPath(profileId); + let isInstalled = await IOUtils.exists( + PathUtils.join(extensionPath, extensionId) + ); + return isInstalled; + }, + + /** + * Get the last used user profile's ID. + * + * @returns {string} The last used user profile's ID. + */ + async getLastUsedProfileId() { + let localState = await this.getLocalState(); + return localState ? localState.profile.last_used : "Default"; + }, + + /** + * Get the local state file content. + * + * @param {string} dataPath the type of Chrome data we're looking for (Chromium, Canary, etc.) + * @returns {object} The JSON-based content. + */ + async getLocalState(dataPath = "Chrome") { + let localState = null; + try { + let localStatePath = PathUtils.join( + await this.getDataPath(dataPath), + "Local State" + ); + localState = JSON.parse(await IOUtils.readUTF8(localStatePath)); + } catch (ex) { + // Don't report the error if it's just a file not existing. + if (ex.name != "NotFoundError") { + console.error(ex); + } + throw ex; + } + return localState; + }, + + /** + * Get the path of Chrome extension directory. + * + * @param {string} profileId - The user profile's ID. + * @returns {string} The path of Chrome extension directory. + */ + async getExtensionPath(profileId) { + return PathUtils.join(await this.getDataPath(), profileId, "Extensions"); + }, + + /** + * Get the path of an application data directory. + * + * @param {string} chromeProjectName - The Chrome project name, e.g. "Chrome", "Canary", etc. + * Defaults to "Chrome". + * @returns {string} The path of application data directory. + */ + async getDataPath(chromeProjectName = "Chrome") { + const SUB_DIRECTORIES = { + win: { + Brave: [ + ["LocalAppData", "BraveSoftware", "Brave-Browser", "User Data"], + ], + Chrome: [["LocalAppData", "Google", "Chrome", "User Data"]], + "Chrome Beta": [["LocalAppData", "Google", "Chrome Beta", "User Data"]], + Chromium: [["LocalAppData", "Chromium", "User Data"]], + Canary: [["LocalAppData", "Google", "Chrome SxS", "User Data"]], + Edge: [["LocalAppData", "Microsoft", "Edge", "User Data"]], + "Edge Beta": [["LocalAppData", "Microsoft", "Edge Beta", "User Data"]], + "360 SE": [["AppData", "360se6", "User Data"]], + Opera: [["AppData", "Opera Software", "Opera Stable"]], + "Opera GX": [["AppData", "Opera Software", "Opera GX Stable"]], + Vivaldi: [["LocalAppData", "Vivaldi", "User Data"]], + }, + macosx: { + Brave: [ + ["ULibDir", "Application Support", "BraveSoftware", "Brave-Browser"], + ], + Chrome: [["ULibDir", "Application Support", "Google", "Chrome"]], + Chromium: [["ULibDir", "Application Support", "Chromium"]], + Canary: [["ULibDir", "Application Support", "Google", "Chrome Canary"]], + Edge: [["ULibDir", "Application Support", "Microsoft Edge"]], + "Edge Beta": [ + ["ULibDir", "Application Support", "Microsoft Edge Beta"], + ], + "Opera GX": [ + ["ULibDir", "Application Support", "com.operasoftware.OperaGX"], + ], + Opera: [["ULibDir", "Application Support", "com.operasoftware.Opera"]], + Vivaldi: [["ULibDir", "Application Support", "Vivaldi"]], + }, + linux: { + Brave: [["Home", ".config", "BraveSoftware", "Brave-Browser"]], + Chrome: [["Home", ".config", "google-chrome"]], + "Chrome Beta": [["Home", ".config", "google-chrome-beta"]], + "Chrome Dev": [["Home", ".config", "google-chrome-unstable"]], + Chromium: [ + ["Home", ".config", "chromium"], + ["Home", "snap", "chromium", "common", "chromium"], + ], + // Opera GX is not available on Linux. + // Canary is not available on Linux. + // Edge is not available on Linux. + Opera: [["Home", ".config", "opera"]], + Vivaldi: [["Home", ".config", "vivaldi"]], + }, + }; + let options = SUB_DIRECTORIES[AppConstants.platform][chromeProjectName]; + if (!options) { + return null; + } + + for (let subfolders of options) { + let rootDir = subfolders[0]; + try { + let targetPath = Services.dirsvc.get(rootDir, Ci.nsIFile).path; + targetPath = PathUtils.join(targetPath, ...subfolders.slice(1)); + if (await IOUtils.exists(targetPath)) { + return targetPath; + } + } catch (ex) { + // The path logic here shouldn't error, so log it: + console.error(ex); + } + } + return null; + }, + + /** + * Get the directory objects sorted by version number. + * + * @param {string} path - The path to the extension directory. + * otherwise return all file/directory object. + * @returns {Array} The file/directory object array. + */ + async _getSortedByVersionSubDirectoryNames(path) { + if (this._extensionVersionDirectoryNames[path]) { + return this._extensionVersionDirectoryNames[path]; + } + + let entries = []; + try { + for (const child of await IOUtils.getChildren(path)) { + const info = await IOUtils.stat(child); + if (info.type === "directory") { + const name = PathUtils.filename(child); + entries.push(name); + } + } + } catch (ex) { + console.error(ex); + entries = []; + } + + // The directory name is the version number string of the extension. + // For example, it could be "1.0_0", "1.0_1", "1.0_2", 1.1_0, 1.1_1, or 1.1_2. + // The "1.0_1" strings mean that the "1.0_0" directory is existed and you install the version 1.0 again. + // https://chromium.googlesource.com/chromium/src/+/0b58a813992b539a6b555ad7959adfad744b095a/chrome/common/extensions/extension_file_util_unittest.cc + entries.sort((a, b) => Services.vc.compare(b, a)); + + this._extensionVersionDirectoryNames[path] = entries; + return entries; + }, + + /** + * Convert Chrome time format to Date object. Google Chrome uses FILETIME / 10 as time. + * FILETIME is based on the same structure of Windows. + * + * @param {number} aTime Chrome time + * @param {string|number|Date} aFallbackValue a date or timestamp (valid argument + * for the Date constructor) that will be used if the chrometime value passed is + * invalid. + * @returns {Date} converted Date object + */ + chromeTimeToDate(aTime, aFallbackValue) { + // The date value may be 0 in some cases. Because of the subtraction below, + // that'd generate a date before the unix epoch, which can upset consumers + // due to the unix timestamp then being negative. Catch this case: + if (!aTime) { + return new Date(aFallbackValue); + } + return new Date((aTime * S100NS_PER_MS - S100NS_FROM1601TO1970) / 10000); + }, + + /** + * Convert Date object to Chrome time format. For details on Chrome time, see + * chromeTimeToDate. + * + * @param {Date|number} aDate Date object or integer equivalent + * @returns {number} Chrome time + */ + dateToChromeTime(aDate) { + return (aDate * 10000 + S100NS_FROM1601TO1970) / S100NS_PER_MS; + }, + + /** + * Returns an array of chromium browser ids that have importable logins. + */ + _importableLoginsCache: null, + async getImportableLogins(formOrigin) { + // Only provide importable if we actually support importing. + if (!this.supportsLoginsForPlatform) { + return undefined; + } + + // Lazily fill the cache with all importable login browsers. + if (!this._importableLoginsCache) { + this._importableLoginsCache = new Map(); + + // Just handle these chromium-based browsers for now. + for (const browserId of this.CONTEXTUAL_LOGIN_IMPORT_BROWSERS) { + // Skip if there's no profile data. + const migrator = await lazy.MigrationUtils.getMigrator(browserId); + if (!migrator) { + continue; + } + + // Check each profile for logins. + const dataPath = await migrator._getChromeUserDataPathIfExists(); + for (const profile of await migrator.getSourceProfiles()) { + const path = PathUtils.join(dataPath, profile.id, "Login Data"); + // Skip if login data is missing. + if (!(await IOUtils.exists(path))) { + console.error(`Missing file at ${path}`); + continue; + } + + try { + for (const row of await lazy.MigrationUtils.getRowsFromDBWithoutLocks( + path, + `Importable ${browserId} logins`, + `SELECT origin_url + FROM logins + WHERE blacklisted_by_user = 0` + )) { + const url = row.getString(0); + try { + // Initialize an array if it doesn't exist for the origin yet. + const origin = lazy.LoginHelper.getLoginOrigin(url); + const entries = this._importableLoginsCache.get(origin) || []; + if (!entries.length) { + this._importableLoginsCache.set(origin, entries); + } + + // Add the browser if it doesn't exist yet. + if (!entries.includes(browserId)) { + entries.push(browserId); + } + } catch (ex) { + console.error( + `Failed to process importable url ${url} from ${browserId} ${ex}` + ); + } + } + } catch (ex) { + console.error( + `Failed to get importable logins from ${browserId} ${ex}` + ); + } + } + } + } + return this._importableLoginsCache.get(formOrigin); + }, +}; diff --git a/browser/components/migration/ChromeProfileMigrator.sys.mjs b/browser/components/migration/ChromeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..fc20dc6e94 --- /dev/null +++ b/browser/components/migration/ChromeProfileMigrator.sys.mjs @@ -0,0 +1,1018 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + * vim: sw=2 ts=2 sts=2 et */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const AUTH_TYPE = { + SCHEME_HTML: 0, + SCHEME_BASIC: 1, + SCHEME_DIGEST: 2, +}; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + ChromeMigrationUtils: "resource:///modules/ChromeMigrationUtils.sys.mjs", + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Qihoo360seMigrationUtils: "resource:///modules/360seMigrationUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(lazy, { + NetUtil: "resource://gre/modules/NetUtil.jsm", +}); + +/** + * Converts an array of chrome bookmark objects into one our own places code + * understands. + * + * @param {object[]} items Chrome Bookmark items to be inserted on this parent + * @param {set} bookmarkURLAccumulator Accumulate all imported bookmark urls to be used for importing favicons + * @param {Function} errorAccumulator function that gets called with any errors + * thrown so we don't drop them on the floor. + * @returns {object[]} + */ +function convertBookmarks(items, bookmarkURLAccumulator, errorAccumulator) { + let itemsToInsert = []; + for (let item of items) { + try { + if (item.type == "url") { + if (item.url.trim().startsWith("chrome:")) { + // Skip invalid internal URIs. Creating an actual URI always reports + // messages to the console because Gecko has its own concept of how + // chrome:// URIs should be formed, so we avoid doing that. + continue; + } + if (item.url.trim().startsWith("edge:")) { + // Don't import internal Microsoft Edge URIs as they won't resolve within Firefox. + continue; + } + itemsToInsert.push({ url: item.url, title: item.name }); + bookmarkURLAccumulator.add({ url: item.url }); + } else if (item.type == "folder") { + let folderItem = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: item.name, + }; + folderItem.children = convertBookmarks( + item.children, + bookmarkURLAccumulator, + errorAccumulator + ); + itemsToInsert.push(folderItem); + } + } catch (ex) { + console.error(ex); + errorAccumulator(ex); + } + } + return itemsToInsert; +} + +/** + * Chrome profile migrator. This can also be used as a parent class for + * migrators for browsers that are variants of Chrome. + */ +export class ChromeProfileMigrator extends MigratorBase { + static get key() { + return "chrome"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chrome.png"; + } + + get _chromeUserDataPathSuffix() { + return "Chrome"; + } + + _keychainServiceName = "Chrome Safe Storage"; + + _keychainAccountName = "Chrome"; + + async _getChromeUserDataPathIfExists() { + if (this._chromeUserDataPath) { + return this._chromeUserDataPath; + } + let path = await lazy.ChromeMigrationUtils.getDataPath( + this._chromeUserDataPathSuffix + ); + let exists = path && (await IOUtils.exists(path)); + if (exists) { + this._chromeUserDataPath = path; + } else { + this._chromeUserDataPath = null; + } + return this._chromeUserDataPath; + } + + async getResources(aProfile) { + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (chromeUserDataPath) { + let profileFolder = chromeUserDataPath; + if (aProfile) { + profileFolder = PathUtils.join(chromeUserDataPath, aProfile.id); + } + if (await IOUtils.exists(profileFolder)) { + let possibleResourcePromises = [ + GetBookmarksResource(profileFolder, this.constructor.key), + GetHistoryResource(profileFolder), + GetFormdataResource(profileFolder), + ]; + if (lazy.ChromeMigrationUtils.supportsLoginsForPlatform) { + possibleResourcePromises.push( + this._GetPasswordsResource(profileFolder), + this._GetPaymentMethodsResource(profileFolder) + ); + } + + // Some of these Promises might reject due to things like database + // corruptions. We absorb those rejections here and filter them + // out so that we only try to import the resources that don't appear + // corrupted. + let possibleResources = await Promise.allSettled( + possibleResourcePromises + ); + return possibleResources + .filter(promise => { + return promise.status == "fulfilled" && promise.value !== null; + }) + .map(promise => promise.value); + } + } + return []; + } + + async getLastUsedDate() { + let sourceProfiles = await this.getSourceProfiles(); + if (!sourceProfiles) { + return new Date(0); + } + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return new Date(0); + } + let datePromises = sourceProfiles.map(async profile => { + let basePath = PathUtils.join(chromeUserDataPath, profile.id); + let fileDatePromises = ["Bookmarks", "History", "Cookies"].map( + async leafName => { + let path = PathUtils.join(basePath, leafName); + let info = await IOUtils.stat(path).catch(() => null); + return info ? info.lastModified : 0; + } + ); + let dates = await Promise.all(fileDatePromises); + return Math.max(...dates); + }); + let datesOuter = await Promise.all(datePromises); + datesOuter.push(0); + return new Date(Math.max(...datesOuter)); + } + + async getSourceProfiles() { + if ("__sourceProfiles" in this) { + return this.__sourceProfiles; + } + + let chromeUserDataPath = await this._getChromeUserDataPathIfExists(); + if (!chromeUserDataPath) { + return []; + } + + let localState; + let profiles = []; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState( + this._chromeUserDataPathSuffix + ); + let info_cache = localState.profile.info_cache; + for (let profileFolderName in info_cache) { + profiles.push({ + id: profileFolderName, + name: info_cache[profileFolderName].name || profileFolderName, + }); + } + } catch (e) { + // Avoid reporting NotFoundErrors from trying to get local state. + if (localState || e.name != "NotFoundError") { + console.error("Error detecting Chrome profiles: ", e); + } + // If we weren't able to detect any profiles above, fallback to the Default profile. + let defaultProfilePath = PathUtils.join(chromeUserDataPath, "Default"); + if (await IOUtils.exists(defaultProfilePath)) { + profiles = [ + { + id: "Default", + name: "Default", + }, + ]; + } + } + + let profileResources = await Promise.all( + profiles.map(async profile => ({ + profile, + resources: await this.getResources(profile), + })) + ); + + // Only list profiles from which any data can be imported + this.__sourceProfiles = profileResources + .filter(({ resources }) => { + return resources && !!resources.length; + }, this) + .map(({ profile }) => profile); + return this.__sourceProfiles; + } + + async _GetPasswordsResource(aProfileFolder) { + let loginPath = PathUtils.join(aProfileFolder, "Login Data"); + if (!(await IOUtils.exists(loginPath))) { + return null; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + let countQuery = `SELECT COUNT(*) FROM logins WHERE blacklisted_by_user = 0`; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + countQuery + ); + + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + + return { + type: MigrationUtils.resourceTypes.PASSWORDS, + + async migrate(aCallback) { + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + loginPath, + "Chrome passwords", + `SELECT origin_url, action_url, username_element, username_value, + password_element, password_value, signon_realm, scheme, date_created, + times_used FROM logins WHERE blacklisted_by_user = 0` + ).catch(ex => { + console.error(ex); + aCallback(false); + }); + // If the promise was rejected we will have already called aCallback, + // so we can just return here. + if (!rows) { + return; + } + + // If there are no relevant rows, return before initializing crypto and + // thus prompting for Keychain access on macOS. + if (!rows.length) { + aCallback(true); + return; + } + + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let logins = []; + let fallbackCreationDate = new Date(); + for (let row of rows) { + try { + let origin_url = lazy.NetUtil.newURI( + row.getResultByName("origin_url") + ); + // Ignore entries for non-http(s)/ftp URLs because we likely can't + // use them anyway. + const kValidSchemes = new Set(["https", "http", "ftp"]); + if (!kValidSchemes.has(origin_url.scheme)) { + continue; + } + let loginInfo = { + username: row.getResultByName("username_value"), + password: await crypto.decryptData( + row.getResultByName("password_value"), + null + ), + origin: origin_url.prePath, + formActionOrigin: null, + httpRealm: null, + usernameElement: row.getResultByName("username_element"), + passwordElement: row.getResultByName("password_element"), + timeCreated: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("date_created") + 0, + fallbackCreationDate + ).getTime(), + timesUsed: row.getResultByName("times_used") + 0, + }; + + switch (row.getResultByName("scheme")) { + case AUTH_TYPE.SCHEME_HTML: + let action_url = row.getResultByName("action_url"); + if (!action_url) { + // If there is no action_url, store the wildcard "" value. + // See the `formActionOrigin` IDL comments. + loginInfo.formActionOrigin = ""; + break; + } + let action_uri = lazy.NetUtil.newURI(action_url); + if (!kValidSchemes.has(action_uri.scheme)) { + continue; // This continues the outer for loop. + } + loginInfo.formActionOrigin = action_uri.prePath; + break; + case AUTH_TYPE.SCHEME_BASIC: + case AUTH_TYPE.SCHEME_DIGEST: + // signon_realm format is URIrealm, so we need remove URI + loginInfo.httpRealm = row + .getResultByName("signon_realm") + .substring(loginInfo.origin.length + 1); + break; + default: + throw new Error( + "Login data scheme type not supported: " + + row.getResultByName("scheme") + ); + } + logins.push(loginInfo); + } catch (e) { + console.error(e); + } + } + try { + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + } + if (crypto.finalize) { + crypto.finalize(); + } + aCallback(true); + }, + }; + } + async _GetPaymentMethodsResource(aProfileFolder) { + if ( + !Services.prefs.getBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ) + ) { + return null; + } + + let paymentMethodsPath = PathUtils.join(aProfileFolder, "Web Data"); + + if (!(await IOUtils.exists(paymentMethodsPath))) { + return null; + } + + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + paymentMethodsPath, + "Chrome Credit Cards", + "SELECT name_on_card, card_number_encrypted, expiration_month, expiration_year FROM credit_cards" + ).catch(ex => { + console.error(ex); + }); + + if (!rows?.length) { + return null; + } + + let { + _chromeUserDataPathSuffix, + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase = null, + } = this; + + return { + type: MigrationUtils.resourceTypes.PAYMENT_METHODS, + + async migrate(aCallback) { + let crypto; + try { + if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + crypto = new ChromeWindowsLoginCrypto(_chromeUserDataPathSuffix); + } else if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + crypto = new ChromeMacOSLoginCrypto( + _keychainServiceName, + _keychainAccountName, + _keychainMockPassphrase + ); + } else { + aCallback(false); + return; + } + } catch (ex) { + // Handle the user canceling Keychain access or other OSCrypto errors. + console.error(ex); + aCallback(false); + return; + } + + let cards = []; + for (let row of rows) { + cards.push({ + "cc-name": row.getResultByName("name_on_card"), + "cc-number": await crypto.decryptData( + row.getResultByName("card_number_encrypted"), + null + ), + "cc-exp-month": parseInt( + row.getResultByName("expiration_month"), + 10 + ), + "cc-exp-year": parseInt(row.getResultByName("expiration_year"), 10), + }); + } + + await MigrationUtils.insertCreditCardsWrapper(cards); + aCallback(true); + }, + }; + } +} + +async function GetBookmarksResource(aProfileFolder, aBrowserKey) { + let bookmarksPath = PathUtils.join(aProfileFolder, "Bookmarks"); + let faviconsPath = PathUtils.join(aProfileFolder, "Favicons"); + + if (aBrowserKey === "chromium-360se") { + let localState = {}; + try { + localState = await lazy.ChromeMigrationUtils.getLocalState("360 SE"); + } catch (ex) { + console.error(ex); + } + + let alternativeBookmarks = + await lazy.Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath, + localState, + }); + if (alternativeBookmarks.resource) { + return alternativeBookmarks.resource; + } + + bookmarksPath = alternativeBookmarks.path; + } + + if (!(await IOUtils.exists(bookmarksPath))) { + return null; + } + // check to read JSON bookmarks structure and see if any bookmarks exist else return null + // Parse Chrome bookmark file that is JSON format + let bookmarkJSON = await IOUtils.readJSON(bookmarksPath); + let other = bookmarkJSON.roots.other.children.length; + let bookmarkBar = bookmarkJSON.roots.bookmark_bar.children.length; + let synced = bookmarkJSON.roots.synced.children.length; + + if (!other && !bookmarkBar && !synced) { + return null; + } + return { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate(aCallback) { + return (async function () { + let gotErrors = false; + let errorGatherer = function () { + gotErrors = true; + }; + + let faviconRows = []; + try { + faviconRows = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconsPath, + "Chrome Bookmark Favicons", + `select fav.id, fav.url, map.page_url, bit.image_data FROM favicons as fav + INNER JOIN favicon_bitmaps bit ON (fav.id = bit.icon_id) + INNER JOIN icon_mapping map ON (map.icon_id = bit.icon_id)` + ); + } catch (ex) { + console.error(ex); + } + // Create Hashmap for favicons + let faviconMap = new Map(); + for (let faviconRow of faviconRows) { + // First, try to normalize the URI: + try { + let uri = lazy.NetUtil.newURI( + faviconRow.getResultByName("page_url") + ); + faviconMap.set(uri.spec, { + faviconData: faviconRow.getResultByName("image_data"), + uri, + }); + } catch (e) { + // Couldn't parse the URI, so just skip it. + continue; + } + } + + let roots = bookmarkJSON.roots; + let bookmarkURLAccumulator = new Set(); + + // Importing bookmark bar items + if (roots.bookmark_bar.children && roots.bookmark_bar.children.length) { + // Toolbar + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + let bookmarks = convertBookmarks( + roots.bookmark_bar.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing Other Bookmarks items + if (roots.other.children && roots.other.children.length) { + // Other Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.other.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Importing synced Bookmarks items + if (roots.synced.children && roots.synced.children.length) { + // Synced Bookmarks + let parentGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + let bookmarks = convertBookmarks( + roots.synced.children, + bookmarkURLAccumulator, + errorGatherer + ); + await MigrationUtils.insertManyBookmarksWrapper( + bookmarks, + parentGuid + ); + } + + // Find all favicons with associated bookmarks + let favicons = []; + for (let bookmark of bookmarkURLAccumulator) { + try { + let uri = lazy.NetUtil.newURI(bookmark.url); + let favicon = faviconMap.get(uri.spec); + if (favicon) { + favicons.push(favicon); + } + } catch (e) { + // Couldn't parse the bookmark URI, so just skip + continue; + } + } + + // Import Bookmark Favicons + MigrationUtils.insertManyFavicons(favicons); + if (gotErrors) { + throw new Error("The migration included errors."); + } + })().then( + () => aCallback(true), + () => aCallback(false) + ); + }, + }; +} + +async function GetHistoryResource(aProfileFolder) { + let historyPath = PathUtils.join(aProfileFolder, "History"); + if (!(await IOUtils.exists(historyPath))) { + return null; + } + let countQuery = "SELECT COUNT(*) FROM urls WHERE hidden = 0"; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.HISTORY, + + migrate(aCallback) { + (async function () { + 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"; + let maxAge = lazy.ChromeMigrationUtils.dateToChromeTime( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + query += " AND last_visit_time > " + maxAge; + + if (LIMIT) { + query += " ORDER BY last_visit_time DESC LIMIT " + LIMIT; + } + + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + historyPath, + "Chrome history", + query + ); + let pageInfos = []; + let fallbackVisitDate = new Date(); + for (let row of rows) { + try { + // if having typed_count, we changes transition type to typed. + let transition = lazy.PlacesUtils.history.TRANSITIONS.LINK; + if (row.getResultByName("typed_count") > 0) { + transition = lazy.PlacesUtils.history.TRANSITIONS.TYPED; + } + + pageInfos.push({ + title: row.getResultByName("title"), + url: new URL(row.getResultByName("url")), + visits: [ + { + transition, + date: lazy.ChromeMigrationUtils.chromeTimeToDate( + row.getResultByName("last_visit_time"), + fallbackVisitDate + ), + }, + ], + }); + } catch (e) { + console.error(e); + } + } + + if (pageInfos.length) { + await MigrationUtils.insertVisitsWrapper(pageInfos); + } + })().then( + () => { + aCallback(true); + }, + ex => { + console.error(ex); + aCallback(false); + } + ); + }, + }; +} + +async function GetFormdataResource(aProfileFolder) { + let formdataPath = PathUtils.join(aProfileFolder, "Web Data"); + if (!(await IOUtils.exists(formdataPath))) { + return null; + } + let countQuery = "SELECT COUNT(*) FROM autofill"; + + let countRows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + countQuery + ); + if (!countRows[0].getResultByName("COUNT(*)")) { + return null; + } + return { + type: MigrationUtils.resourceTypes.FORMDATA, + + async migrate(aCallback) { + let query = + "SELECT name, value, count, date_created, date_last_used FROM autofill"; + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + formdataPath, + "Chrome formdata", + query + ); + let addOps = []; + for (let row of rows) { + try { + let fieldname = row.getResultByName("name"); + let value = row.getResultByName("value"); + if (fieldname && value) { + addOps.push({ + op: "add", + fieldname, + value, + timesUsed: row.getResultByName("count"), + firstUsed: row.getResultByName("date_created") * 1000, + lastUsed: row.getResultByName("date_last_used") * 1000, + }); + } + } catch (e) { + console.error(e); + } + } + + try { + await lazy.FormHistory.update(addOps); + } catch (e) { + console.error(e); + aCallback(false); + return; + } + + aCallback(true); + }, + }; +} + +/** + * Chromium migrator + */ +export class ChromiumProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/chromium.png"; + } + + _chromeUserDataPathSuffix = "Chromium"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Canary + * Not available on Linux + */ +export class CanaryProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "canary"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-canary"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/canary.png"; + } + + get _chromeUserDataPathSuffix() { + return "Canary"; + } + + get _keychainServiceName() { + return "Chromium Safe Storage"; + } + + get _keychainAccountName() { + return "Chromium"; + } +} + +/** + * Chrome Dev - Linux only (not available in Mac and Windows) + */ +export class ChromeDevMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-dev"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-dev"; + } + + _chromeUserDataPathSuffix = "Chrome Dev"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Chrome Beta migrator + */ +export class ChromeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chrome-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chrome-beta"; + } + + _chromeUserDataPathSuffix = "Chrome Beta"; + _keychainServiceName = "Chromium Safe Storage"; + _keychainAccountName = "Chromium"; +} + +/** + * Brave migrator + */ +export class BraveProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "brave"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-brave"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/brave.png"; + } + + _chromeUserDataPathSuffix = "Brave"; + _keychainServiceName = "Brave Browser Safe Storage"; + _keychainAccountName = "Brave Browser"; +} + +/** + * Edge (Chromium-based) migrator + */ +export class ChromiumEdgeMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + _chromeUserDataPathSuffix = "Edge"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Edge Beta (Chromium-based) migrator + */ +export class ChromiumEdgeBetaMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-edge-beta"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-edge-beta"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edgebeta.png"; + } + + _chromeUserDataPathSuffix = "Edge Beta"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Chromium 360 migrator + */ +export class Chromium360seMigrator extends ChromeProfileMigrator { + static get key() { + return "chromium-360se"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-chromium-360se"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/360.png"; + } + + _chromeUserDataPathSuffix = "360 SE"; + _keychainServiceName = "Microsoft Edge Safe Storage"; + _keychainAccountName = "Microsoft Edge"; +} + +/** + * Opera migrator + */ +export class OperaProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/opera.png"; + } + + _chromeUserDataPathSuffix = "Opera"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Opera GX migrator + */ +export class OperaGXProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "opera-gx"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-opera-gx"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/operagx.png"; + } + + _chromeUserDataPathSuffix = "Opera GX"; + _keychainServiceName = "Opera Safe Storage"; + _keychainAccountName = "Opera"; + + getSourceProfiles() { + return null; + } +} + +/** + * Vivaldi migrator + */ +export class VivaldiProfileMigrator extends ChromeProfileMigrator { + static get key() { + return "vivaldi"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-vivaldi"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/vivaldi.png"; + } + + _chromeUserDataPathSuffix = "Vivaldi"; + _keychainServiceName = "Vivaldi Safe Storage"; + _keychainAccountName = "Vivaldi"; +} diff --git a/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs new file mode 100644 index 0000000000..41d38e52d3 --- /dev/null +++ b/browser/components/migration/ChromeWindowsLoginCrypto.sys.mjs @@ -0,0 +1,176 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Class to handle encryption and decryption of logins stored in Chrome/Chromium + * on Windows. + */ + +import { ChromeMigrationUtils } from "resource:///modules/ChromeMigrationUtils.sys.mjs"; + +import { OSCrypto } from "resource://gre/modules/OSCrypto_win.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +/** + * These constants should match those from Chromium. + * + * @see https://source.chromium.org/chromium/chromium/src/+/master:components/os_crypt/os_crypt_win.cc + */ +const AEAD_KEY_LENGTH = 256 / 8; +const ALGORITHM_NAME = "AES-GCM"; +const DPAPI_KEY_PREFIX = "DPAPI"; +const ENCRYPTION_VERSION_PREFIX = "v10"; +const NONCE_LENGTH = 96 / 8; + +const gTextDecoder = new TextDecoder(); +const gTextEncoder = new TextEncoder(); + +/** + * Instances of this class have a shape similar to OSCrypto so it can be dropped + * into code which uses that. The algorithms here are + * specific to what is needed for Chrome login storage on Windows. + */ +export class ChromeWindowsLoginCrypto { + /** + * @param {string} userDataPathSuffix The unique identifier for the variant of + * Chrome that is having its logins imported. These are the keys in the + * SUB_DIRECTORIES object in ChromeMigrationUtils.getDataPath. + */ + constructor(userDataPathSuffix) { + this.osCrypto = new OSCrypto(); + + // Lazily decrypt the key from "Chrome"s local state using OSCrypto and save + // it as the master key to decrypt or encrypt passwords. + XPCOMUtils.defineLazyGetter(this, "_keyPromise", async () => { + let keyData; + try { + // NB: For testing, allow directory service to be faked before getting. + const localState = await ChromeMigrationUtils.getLocalState( + userDataPathSuffix + ); + const withHeader = atob(localState.os_crypt.encrypted_key); + if (!withHeader.startsWith(DPAPI_KEY_PREFIX)) { + throw new Error("Invalid key format"); + } + const encryptedKey = withHeader.slice(DPAPI_KEY_PREFIX.length); + keyData = this.osCrypto.decryptData(encryptedKey, null, "bytes"); + } catch (ex) { + console.error(`${userDataPathSuffix} os_crypt key: ${ex}`); + + // Use a generic key that will fail for actually encrypted data, but for + // testing it'll be consistent for both encrypting and decrypting. + keyData = AEAD_KEY_LENGTH; + } + return crypto.subtle.importKey( + "raw", + new Uint8Array(keyData), + ALGORITHM_NAME, + false, + ["decrypt", "encrypt"] + ); + }); + } + + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this.osCrypto.finalize(); + } + + /** + * Convert an array containing only two bytes unsigned numbers to a string. + * + * @param {number[]} arr - the array that needs to be converted. + * @returns {string} the string representation of the array. + */ + arrayToString(arr) { + let str = ""; + for (let i = 0; i < arr.length; i++) { + str += String.fromCharCode(arr[i]); + } + return str; + } + + stringToArray(binary_string) { + const len = binary_string.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binary_string.charCodeAt(i); + } + return bytes; + } + + /** + * @param {string} ciphertext ciphertext optionally prefixed by the encryption version + * (see ENCRYPTION_VERSION_PREFIX). + * @returns {string} plaintext password + */ + async decryptData(ciphertext) { + const ciphertextString = this.arrayToString(ciphertext); + return ciphertextString.startsWith(ENCRYPTION_VERSION_PREFIX) + ? this._decryptV10(ciphertext) + : this._decryptUnversioned(ciphertextString); + } + + async _decryptUnversioned(ciphertext) { + return this.osCrypto.decryptData(ciphertext); + } + + async _decryptV10(ciphertext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot decrypt without a key"); + } + + // Split the nonce/iv from the rest of the encrypted value and decrypt. + const nonceIndex = ENCRYPTION_VERSION_PREFIX.length; + const cipherIndex = nonceIndex + NONCE_LENGTH; + const iv = new Uint8Array(ciphertext.slice(nonceIndex, cipherIndex)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const cipherArray = new Uint8Array(ciphertext.slice(cipherIndex)); + const plaintext = await crypto.subtle.decrypt(algorithm, key, cipherArray); + return gTextDecoder.decode(new Uint8Array(plaintext)); + } + + /** + * @param {USVString} plaintext to encrypt + * @param {?string} version to encrypt default unversioned + * @returns {string} encrypted string consisting of UTF-16 code units prefixed + * by the ENCRYPTION_VERSION_PREFIX. + */ + async encryptData(plaintext, version = undefined) { + return version === ENCRYPTION_VERSION_PREFIX + ? this._encryptV10(plaintext) + : this._encryptUnversioned(plaintext); + } + + async _encryptUnversioned(plaintext) { + return this.osCrypto.encryptData(plaintext); + } + + async _encryptV10(plaintext) { + const key = await this._keyPromise; + if (!key) { + throw new Error("Cannot encrypt without a key"); + } + + // Encrypt and concatenate the prefix, nonce/iv and encrypted value. + const iv = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); + const algorithm = { + name: ALGORITHM_NAME, + iv, + }; + const plainArray = gTextEncoder.encode(plaintext); + const ciphertext = await crypto.subtle.encrypt(algorithm, key, plainArray); + return ( + ENCRYPTION_VERSION_PREFIX + + this.arrayToString(iv) + + this.arrayToString(new Uint8Array(ciphertext)) + ); + } +} diff --git a/browser/components/migration/ESEDBReader.sys.mjs b/browser/components/migration/ESEDBReader.sys.mjs new file mode 100644 index 0000000000..53cff13636 --- /dev/null +++ b/browser/components/migration/ESEDBReader.sys.mjs @@ -0,0 +1,800 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyGetter(lazy, "log", () => { + let { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" + ); + let consoleOptions = { + maxLogLevelPref: "browser.esedbreader.loglevel", + prefix: "ESEDBReader", + }; + return new ConsoleAPI(consoleOptions); +}); + +// We have a globally unique identifier for ESE instances. A new one +// is used for each different database opened. +let gESEInstanceCounter = 0; + +// We limit the length of strings that we read from databases. +const MAX_STR_LENGTH = 64 * 1024; + +// Kernel-related types: +export const KERNEL = {}; + +KERNEL.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: ctypes.uint32_t }, + { dwHighDateTime: ctypes.uint32_t }, +]); +KERNEL.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: ctypes.uint16_t }, + { wMonth: ctypes.uint16_t }, + { wDayOfWeek: ctypes.uint16_t }, + { wDay: ctypes.uint16_t }, + { wHour: ctypes.uint16_t }, + { wMinute: ctypes.uint16_t }, + { wSecond: ctypes.uint16_t }, + { wMilliseconds: ctypes.uint16_t }, +]); + +// DB column types, cribbed from the ESE header +export var COLUMN_TYPES = { + JET_coltypBit: 1 /* True, False, or NULL */, + JET_coltypUnsignedByte: 2 /* 1-byte integer, unsigned */, + JET_coltypShort: 3 /* 2-byte integer, signed */, + JET_coltypLong: 4 /* 4-byte integer, signed */, + JET_coltypCurrency: 5 /* 8 byte integer, signed */, + JET_coltypIEEESingle: 6 /* 4-byte IEEE single precision */, + JET_coltypIEEEDouble: 7 /* 8-byte IEEE double precision */, + JET_coltypDateTime: 8 /* Integral date, fractional time */, + JET_coltypBinary: 9 /* Binary data, < 255 bytes */, + JET_coltypText: 10 /* ANSI text, case insensitive, < 255 bytes */, + JET_coltypLongBinary: 11 /* Binary data, long value */, + JET_coltypLongText: 12 /* ANSI text, long value */, + + JET_coltypUnsignedLong: 14 /* 4-byte unsigned integer */, + JET_coltypLongLong: 15 /* 8-byte signed integer */, + JET_coltypGUID: 16 /* 16-byte globally unique identifier */, +}; + +// Not very efficient, but only used for error messages +function getColTypeName(numericValue) { + return ( + Object.keys(COLUMN_TYPES).find(t => COLUMN_TYPES[t] == numericValue) || + "unknown" + ); +} + +// All type constants and method wrappers go on this object: +export const ESE = {}; + +ESE.JET_ERR = ctypes.long; +ESE.JET_PCWSTR = ctypes.char16_t.ptr; +// The ESE header calls this JET_API_PTR, but because it isn't ever used as a +// pointer, I opted for a different name. +// Note that this is defined differently on 32 vs. 64-bit in the header. +ESE.JET_API_ITEM = + ctypes.voidptr_t.size == 4 ? ctypes.unsigned_long : ctypes.uint64_t; +ESE.JET_INSTANCE = ESE.JET_API_ITEM; +ESE.JET_SESID = ESE.JET_API_ITEM; +ESE.JET_TABLEID = ESE.JET_API_ITEM; +ESE.JET_COLUMNID = ctypes.unsigned_long; +ESE.JET_GRBIT = ctypes.unsigned_long; +ESE.JET_COLTYP = ctypes.unsigned_long; +ESE.JET_DBID = ctypes.unsigned_long; + +ESE.JET_COLUMNDEF = new ctypes.StructType("JET_COLUMNDEF", [ + { cbStruct: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { coltyp: ESE.JET_COLTYP }, + { wCountry: ctypes.unsigned_short }, // sepcifies the country/region for the column definition + { langid: ctypes.unsigned_short }, + { cp: ctypes.unsigned_short }, + { wCollate: ctypes.unsigned_short } /* Must be 0 */, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, +]); + +// Track open databases +let gOpenDBs = new Map(); + +// Track open libraries +export let gLibs = {}; + +function convertESEError(errorCode) { + switch (errorCode) { + case -1213 /* JET_errPageSizeMismatch */: + case -1002 /* JET_errInvalidName*/: + case -1507 /* JET_errColumnNotFound */: + // The DB format has changed and we haven't updated this migration code: + return "The database format has changed, error code: " + errorCode; + case -1032 /* JET_errFileAccessDenied */: + case -1207 /* JET_errDatabaseLocked */: + case -1302 /* JET_errTableLocked */: + return "The database or table is locked, error code: " + errorCode; + case -1305 /* JET_errObjectNotFound */: + return "The table/object was not found."; + case -1809 /* JET_errPermissionDenied*/: + case -1907 /* JET_errAccessDenied */: + return "Access or permission denied, error code: " + errorCode; + case -1044 /* JET_errInvalidFilename */: + return "Invalid file name"; + case -1811 /* JET_errFileNotFound */: + return "File not found"; + case -550 /* JET_errDatabaseDirtyShutdown */: + return "Database in dirty shutdown state (without the requisite logs?)"; + case -514 /* JET_errBadLogVersion */: + return "Database log version does not match the version of ESE in use."; + default: + return "Unknown error: " + errorCode; + } +} + +function handleESEError( + method, + methodName, + shouldThrow = true, + errorLog = true +) { + return function () { + let rv; + try { + rv = method.apply(null, arguments); + } catch (ex) { + lazy.log.error("Error calling into ctypes method", methodName, ex); + throw ex; + } + let resultCode = parseInt(rv.toString(10), 10); + if (resultCode < 0) { + if (errorLog) { + lazy.log.error("Got error " + resultCode + " calling " + methodName); + } + if (shouldThrow) { + throw new Error(convertESEError(rv)); + } + } else if (resultCode > 0 && errorLog) { + lazy.log.warn("Got warning " + resultCode + " calling " + methodName); + } + return resultCode; + }; +} + +export function declareESEFunction(methodName, ...args) { + let declaration = ["Jet" + methodName, ctypes.winapi_abi, ESE.JET_ERR].concat( + args + ); + let ctypeMethod = gLibs.ese.declare.apply(gLibs.ese, declaration); + ESE[methodName] = handleESEError(ctypeMethod, methodName); + ESE["FailSafe" + methodName] = handleESEError(ctypeMethod, methodName, false); + ESE["Manual" + methodName] = handleESEError( + ctypeMethod, + methodName, + false, + false + ); +} + +function declareESEFunctions() { + declareESEFunction( + "GetDatabaseFileInfoW", + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "GetSystemParameterW", + ESE.JET_INSTANCE, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM.ptr, + ESE.JET_PCWSTR, + ctypes.unsigned_long + ); + declareESEFunction( + "SetSystemParameterW", + ESE.JET_INSTANCE.ptr, + ESE.JET_SESID, + ctypes.unsigned_long, + ESE.JET_API_ITEM, + ESE.JET_PCWSTR + ); + declareESEFunction("CreateInstanceW", ESE.JET_INSTANCE.ptr, ESE.JET_PCWSTR); + declareESEFunction("Init", ESE.JET_INSTANCE.ptr); + + declareESEFunction( + "BeginSessionW", + ESE.JET_INSTANCE, + ESE.JET_SESID.ptr, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR + ); + declareESEFunction( + "AttachDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_GRBIT + ); + declareESEFunction("DetachDatabaseW", ESE.JET_SESID, ESE.JET_PCWSTR); + declareESEFunction( + "OpenDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "OpenTableW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_TABLEID.ptr + ); + + declareESEFunction( + "GetColumnInfoW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long + ); + + declareESEFunction( + "Move", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.long, + ESE.JET_GRBIT + ); + + declareESEFunction( + "RetrieveColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr, + ESE.JET_GRBIT, + ctypes.voidptr_t + ); + + declareESEFunction("CloseTable", ESE.JET_SESID, ESE.JET_TABLEID); + declareESEFunction( + "CloseDatabase", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_GRBIT + ); + + declareESEFunction("EndSession", ESE.JET_SESID, ESE.JET_GRBIT); + + declareESEFunction("Term", ESE.JET_INSTANCE); +} + +function unloadLibraries() { + lazy.log.debug("Unloading"); + if (gOpenDBs.size) { + lazy.log.error("Shouldn't unload libraries before DBs are closed!"); + for (let db of gOpenDBs.values()) { + db._close(); + } + } + for (let k of Object.keys(ESE)) { + delete ESE[k]; + } + gLibs.ese.close(); + gLibs.kernel.close(); + delete gLibs.ese; + delete gLibs.kernel; +} + +export function loadLibraries() { + Services.obs.addObserver(unloadLibraries, "xpcom-shutdown"); + gLibs.ese = ctypes.open("esent.dll"); + gLibs.kernel = ctypes.open("kernel32.dll"); + KERNEL.FileTimeToSystemTime = gLibs.kernel.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + ctypes.int, + KERNEL.FILETIME.ptr, + KERNEL.SYSTEMTIME.ptr + ); + + declareESEFunctions(); +} + +function ESEDB(rootPath, dbPath, logPath) { + lazy.log.info("Created db"); + this.rootPath = rootPath; + this.dbPath = dbPath; + this.logPath = logPath; + this._references = 0; + this._init(); +} + +ESEDB.prototype = { + rootPath: null, + dbPath: null, + logPath: null, + _opened: false, + _attached: false, + _sessionCreated: false, + _instanceCreated: false, + _dbId: null, + _sessionId: null, + _instanceId: null, + + _init() { + if (!gLibs.ese) { + loadLibraries(); + } + this.incrementReferenceCounter(); + this._internalOpen(); + }, + + _internalOpen() { + try { + let dbinfo = new ctypes.unsigned_long(); + ESE.GetDatabaseFileInfoW( + this.dbPath, + dbinfo.address(), + ctypes.unsigned_long.size, + 17 + ); + + let pageSize = ctypes.UInt64.lo(dbinfo.value); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + pageSize, + null + ); + + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbreader-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + this.rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + this.logPath + ); + + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + const JET_bitDbReadOnly = 1; + ESE.AttachDatabaseW(this._sessionId, this.dbPath, JET_bitDbReadOnly); + this._attached = true; + this._dbId = new ESE.JET_DBID(); + ESE.OpenDatabaseW( + this._sessionId, + this.dbPath, + null, + this._dbId.address(), + JET_bitDbReadOnly + ); + this._opened = true; + } catch (ex) { + try { + this._close(); + } catch (innerException) { + console.error(innerException); + } + // Make sure caller knows we failed. + throw ex; + } + gOpenDBs.set(this.dbPath, this); + }, + + checkForColumn(tableName, columnName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let columnInfo; + try { + columnInfo = this._getColumnInfo(tableName, [{ name: columnName }]); + } catch (ex) { + return null; + } + return columnInfo[0]; + }, + + tableExists(tableName) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableId = new ESE.JET_TABLEID(); + let rv = ESE.ManualOpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + if (rv == -1305 /* JET_errObjectNotFound */) { + return false; + } + if (rv < 0) { + lazy.log.error("Got error " + rv + " calling OpenTableW"); + throw new Error(convertESEError(rv)); + } + + if (rv > 0) { + lazy.log.error("Got warning " + rv + " calling OpenTableW"); + } + ESE.FailSafeCloseTable(this._sessionId, tableId); + return true; + }, + + *tableItems(tableName, columns) { + if (!this._opened) { + throw new Error("The database was closed!"); + } + + let tableOpened = false; + let tableId; + try { + tableId = this._openTable(tableName); + tableOpened = true; + + let columnInfo = this._getColumnInfo(tableName, columns); + + let rv = ESE.ManualMove( + this._sessionId, + tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + if (rv == -1603 /* JET_errNoCurrentRecord */) { + // There are no rows in the table. + this._closeTable(tableId); + return; + } + if (rv != 0) { + throw new Error(convertESEError(rv)); + } + + do { + let rowContents = {}; + for (let column of columnInfo) { + let [buffer, bufferSize] = this._getBufferForColumn(column); + // We handle errors manually so we accurately deal with NULL values. + let err = ESE.ManualRetrieveColumn( + this._sessionId, + tableId, + column.id, + buffer.address(), + bufferSize, + null, + 0, + null + ); + rowContents[column.name] = this._convertResult(column, buffer, err); + } + yield rowContents; + } while ( + ESE.ManualMove(this._sessionId, tableId, 1 /* JET_MoveNext */, 0) === 0 + ); + } catch (ex) { + if (tableOpened) { + this._closeTable(tableId); + } + throw ex; + } + this._closeTable(tableId); + }, + + _openTable(tableName) { + let tableId = new ESE.JET_TABLEID(); + ESE.OpenTableW( + this._sessionId, + this._dbId, + tableName, + null, + 0, + 4 /* JET_bitTableReadOnly */, + tableId.address() + ); + return tableId; + }, + + _getBufferForColumn(column) { + let buffer; + if (column.type == "string") { + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + // size on the column is in bytes, 2 bytes to a wchar, so: + let charCount = column.dbSize >> 1; + buffer = new wchar_tArray(charCount); + } else if (column.type == "boolean") { + buffer = new ctypes.uint8_t(); + } else if (column.type == "date") { + buffer = new KERNEL.FILETIME(); + } else if (column.type == "guid") { + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(column.dbSize); + } else { + throw new Error("Unknown type " + column.type); + } + return [buffer, buffer.constructor.size]; + }, + + _convertResult(column, buffer, err) { + if (err != 0) { + if (err == 1004) { + // Deal with null values: + buffer = null; + } else { + console.error( + "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) { + console.error( + "Buffer size for guid field ", + column.id, + " should have been 16!" + ); + return ""; + } + let rv = "{"; + for (let i = 0; i < 16; i++) { + if (i == 4 || i == 6 || i == 8 || i == 10) { + rv += "-"; + } + let byteValue = buffer.addressOfElement(i).contents; + // Ensure there's a leading 0 + rv += ("0" + byteValue.toString(16)).substr(-2); + } + return rv + "}"; + } + if (column.type == "date") { + if (!buffer) { + return null; + } + let systemTime = new KERNEL.SYSTEMTIME(); + let result = KERNEL.FileTimeToSystemTime( + buffer.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return new Date( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) + ); + } + return undefined; + }, + + _getColumnInfo(tableName, columns) { + let rv = []; + for (let column of columns) { + let columnInfoFromDB = new ESE.JET_COLUMNDEF(); + ESE.GetColumnInfoW( + this._sessionId, + this._dbId, + tableName, + column.name, + columnInfoFromDB.address(), + ESE.JET_COLUMNDEF.size, + 0 /* JET_ColInfo */ + ); + let dbType = parseInt(columnInfoFromDB.coltyp.toString(10), 10); + let dbSize = parseInt(columnInfoFromDB.cbMax.toString(10), 10); + if (column.type == "string") { + if ( + dbType != COLUMN_TYPES.JET_coltypLongText && + dbType != COLUMN_TYPES.JET_coltypText + ) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected text type, got type " + + getColTypeName(dbType) + ); + } + if (dbSize > MAX_STR_LENGTH) { + throw new Error( + "Column " + + column.name + + " has more than 64k data in it. This API is not designed to handle data that large." + ); + } + } else if (column.type == "boolean") { + if (dbType != COLUMN_TYPES.JET_coltypBit) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected bit type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "date") { + if (dbType != COLUMN_TYPES.JET_coltypLongLong) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected long long type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type == "guid") { + if (dbType != COLUMN_TYPES.JET_coltypGUID) { + throw new Error( + "Invalid column type for column " + + column.name + + "; expected guid type, got type " + + getColTypeName(dbType) + ); + } + } else if (column.type) { + throw new Error( + "Unknown column type " + + column.type + + " requested for column " + + column.name + + ", don't know what to do." + ); + } + + rv.push({ + name: column.name, + id: columnInfoFromDB.columnid, + type: column.type, + dbSize, + dbType, + }); + } + return rv; + }, + + _closeTable(tableId) { + ESE.FailSafeCloseTable(this._sessionId, tableId); + }, + + _close() { + this._internalClose(); + gOpenDBs.delete(this.dbPath); + }, + + _internalClose() { + if (this._opened) { + lazy.log.debug("close db"); + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + lazy.log.debug("finished close db"); + this._opened = false; + } + if (this._attached) { + lazy.log.debug("detach db"); + ESE.FailSafeDetachDatabaseW(this._sessionId, this.dbPath); + this._attached = false; + } + if (this._sessionCreated) { + lazy.log.debug("end session"); + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + lazy.log.debug("term"); + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, + + incrementReferenceCounter() { + this._references++; + }, + + decrementReferenceCounter() { + this._references--; + if (this._references <= 0) { + this._close(); + } + }, +}; + +export let ESEDBReader = { + openDB(rootDir, dbFile, logDir) { + let dbFilePath = dbFile.path; + if (gOpenDBs.has(dbFilePath)) { + let db = gOpenDBs.get(dbFilePath); + db.incrementReferenceCounter(); + return db; + } + // ESE is really picky about the trailing slashes according to the docs, + // so we do as we're told and ensure those are there: + return new ESEDB(rootDir.path + "\\", dbFilePath, logDir.path + "\\"); + }, + + async dbLocked(dbFile) { + const utils = Cc[ + "@mozilla.org/profile/migrator/edgemigrationutils;1" + ].createInstance(Ci.nsIEdgeMigrationUtils); + + const locked = await utils.isDbLocked(dbFile); + + if (locked) { + console.error(`ESE DB at ${dbFile.path} is locked.`); + } + + return locked; + }, + + closeDB(db) { + db.decrementReferenceCounter(); + }, + + COLUMN_TYPES, +}; diff --git a/browser/components/migration/EdgeProfileMigrator.sys.mjs b/browser/components/migration/EdgeProfileMigrator.sys.mjs new file mode 100644 index 0000000000..3483292225 --- /dev/null +++ b/browser/components/migration/EdgeProfileMigrator.sys.mjs @@ -0,0 +1,589 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +const EDGE_COOKIE_PATH_OPTIONS = ["", "#!001\\", "#!002\\"]; +const EDGE_COOKIES_SUFFIX = "MicrosoftEdge\\Cookies"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + ESEDBReader: "resource:///modules/ESEDBReader.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const kEdgeRegistryRoot = + "SOFTWARE\\Classes\\Local Settings\\Software\\" + + "Microsoft\\Windows\\CurrentVersion\\AppContainer\\Storage\\" + + "microsoft.microsoftedge_8wekyb3d8bbwe\\MicrosoftEdge"; +const kEdgeDatabasePath = "AC\\MicrosoftEdge\\User\\Default\\DataStore\\Data\\"; + +XPCOMUtils.defineLazyGetter(lazy, "gEdgeDatabase", function () { + let edgeDir = MSMigrationUtils.getEdgeLocalDataFolder(); + if (!edgeDir) { + return null; + } + edgeDir.appendRelativePath(kEdgeDatabasePath); + if (!edgeDir.exists() || !edgeDir.isReadable() || !edgeDir.isDirectory()) { + return null; + } + let expectedLocation = edgeDir.clone(); + expectedLocation.appendRelativePath( + "nouser1\\120712-0049\\DBStore\\spartan.edb" + ); + if ( + expectedLocation.exists() && + expectedLocation.isReadable() && + expectedLocation.isFile() + ) { + expectedLocation.normalize(); + return expectedLocation; + } + // We used to recurse into arbitrary subdirectories here, but that code + // went unused, so it likely isn't necessary, even if we don't understand + // where the magic folders above come from, they seem to be the same for + // everyone. Just return null if they're not there: + return null; +}); + +/** + * Get rows from a table in the Edge DB as an array of JS objects. + * + * @param {string} tableName the name of the table to read. + * @param {string[]|Function} columns a list of column specifiers + * (see ESEDBReader.jsm) or a function that + * generates them based on the database + * reference once opened. + * @param {nsIFile} dbFile the database file to use. Defaults to + * the main Edge database. + * @param {Function} filterFn Optional. A function that is called for each row. + * Only rows for which it returns a truthy + * value are included in the result. + * @returns {Array} An array of row objects. + */ +function readTableFromEdgeDB( + tableName, + columns, + dbFile = lazy.gEdgeDatabase, + filterFn = null +) { + let database; + let rows = []; + try { + let logFile = dbFile.parent; + logFile.append("LogFiles"); + database = lazy.ESEDBReader.openDB(dbFile.parent, dbFile, logFile); + + if (typeof columns == "function") { + columns = columns(database); + } + + let tableReader = database.tableItems(tableName, columns); + for (let row of tableReader) { + if (!filterFn || filterFn(row)) { + rows.push(row); + } + } + } catch (ex) { + console.error( + "Failed to extract items from table ", + tableName, + " in Edge database at ", + dbFile.path, + " due to the following error: ", + ex + ); + // Deliberately make this fail so we expose failure in the UI: + throw ex; + } finally { + if (database) { + lazy.ESEDBReader.closeDB(database); + } + } + return rows; +} + +function EdgeTypedURLMigrator() {} + +EdgeTypedURLMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get _typedURLs() { + if (!this.__typedURLs) { + this.__typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } + return this.__typedURLs; + }, + + get exists() { + return this._typedURLs.size > 0; + }, + + migrate(aCallback) { + let typedURLs = this._typedURLs; + let pageInfos = []; + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let [urlString, time] of typedURLs) { + let visitDate = time ? lazy.PlacesUtils.toDate(time) : now; + if (time && visitDate < maxDate) { + continue; + } + + let url; + try { + url = new URL(urlString); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + } catch (ex) { + console.error(ex); + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: time ? lazy.PlacesUtils.toDate(time) : new Date(), + }, + ], + }); + } + + if (!pageInfos.length) { + aCallback(typedURLs.size == 0); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +function EdgeTypedURLDBMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeTypedURLDBMigrator.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateTypedURLsFromDB().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateTypedURLsFromDB() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columns = [ + { name: "URL", type: "string" }, + { name: "AccessDateTimeUTC", type: "date" }, + ]; + + let typedUrls = []; + try { + typedUrls = readTableFromEdgeDB("TypedUrls", columns, this.db); + } catch (ex) { + // Maybe the table doesn't exist (older versions of Win10). + // Just fall through and we'll return because there's no data. + // The `readTableFromEdgeDB` helper will report errors to the + // console anyway. + } + if (!typedUrls.length) { + return; + } + + let pageInfos = []; + + const kDateCutOff = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + for (let typedUrlInfo of typedUrls) { + try { + let date = typedUrlInfo.AccessDateTimeUTC; + if (!date) { + date = kDateCutOff; + } else if (date < kDateCutOff) { + continue; + } + + let url = new URL(typedUrlInfo.URL); + if (!["http:", "https:", "ftp:"].includes(url.protocol)) { + continue; + } + + pageInfos.push({ + url, + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date, + }, + ], + }); + } catch (ex) { + console.error(ex); + } + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + }, +}; + +function EdgeReadingListMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeReadingListMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get exists() { + return !!this.db; + }, + + migrate(callback) { + this._migrateReadingList(lazy.PlacesUtils.bookmarks.menuGuid).then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateReadingList(parentGuid) { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let columnFn = db => { + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "AddedDate", type: "date" }, + ]; + + // Later versions have an IsDeleted column: + let isDeletedColumn = db.checkForColumn("ReadingList", "IsDeleted"); + if ( + isDeletedColumn && + isDeletedColumn.dbType == lazy.ESEDBReader.COLUMN_TYPES.JET_coltypBit + ) { + columns.push({ name: "IsDeleted", type: "boolean" }); + } + return columns; + }; + + let filterFn = row => { + return !row.IsDeleted; + }; + + let readingListItems = readTableFromEdgeDB( + "ReadingList", + columnFn, + this.db, + filterFn + ); + if (!readingListItems.length) { + return; + } + + let destFolderGuid = await this._ensureReadingListFolder(parentGuid); + let bookmarks = []; + for (let item of readingListItems) { + let dateAdded = item.AddedDate || new Date(); + // Avoid including broken URLs: + try { + new URL(item.URL); + } catch (ex) { + continue; + } + bookmarks.push({ url: item.URL, title: item.Title, dateAdded }); + } + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, destFolderGuid); + }, + + async _ensureReadingListFolder(parentGuid) { + if (!this.__readingListFolderGuid) { + let folderTitle = await MigrationUtils.getLocalizedString( + "imported-edge-reading-list" + ); + let folderSpec = { + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + parentGuid, + title: folderTitle, + }; + this.__readingListFolderGuid = ( + await MigrationUtils.insertBookmarkWrapper(folderSpec) + ).guid; + } + return this.__readingListFolderGuid; + }, +}; + +function EdgeBookmarksMigrator(dbOverride) { + this.dbOverride = dbOverride; +} + +EdgeBookmarksMigrator.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get db() { + return this.dbOverride || lazy.gEdgeDatabase; + }, + + get TABLE_NAME() { + return "Favorites"; + }, + + get exists() { + if (!("_exists" in this)) { + this._exists = !!this.db; + } + return this._exists; + }, + + migrate(callback) { + this._migrateBookmarks().then( + () => callback(true), + ex => { + console.error(ex); + callback(false); + } + ); + }, + + async _migrateBookmarks() { + if (await lazy.ESEDBReader.dbLocked(this.db)) { + throw new Error("Edge seems to be running - its database is locked."); + } + let { toplevelBMs, toolbarBMs } = this._fetchBookmarksFromDB(); + if (toplevelBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await MigrationUtils.insertManyBookmarksWrapper(toplevelBMs, parentGuid); + } + if (toolbarBMs.length) { + let parentGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await MigrationUtils.insertManyBookmarksWrapper(toolbarBMs, parentGuid); + } + }, + + _fetchBookmarksFromDB() { + let folderMap = new Map(); + let columns = [ + { name: "URL", type: "string" }, + { name: "Title", type: "string" }, + { name: "DateUpdated", type: "date" }, + { name: "IsFolder", type: "boolean" }, + { name: "IsDeleted", type: "boolean" }, + { name: "ParentId", type: "guid" }, + { name: "ItemId", type: "guid" }, + ]; + let filterFn = row => { + if (row.IsDeleted) { + return false; + } + if (row.IsFolder) { + folderMap.set(row.ItemId, row); + } + return true; + }; + let bookmarks = readTableFromEdgeDB( + this.TABLE_NAME, + columns, + this.db, + filterFn + ); + let toplevelBMs = [], + toolbarBMs = []; + for (let bookmark of bookmarks) { + let bmToInsert; + // Ignore invalid URLs: + if (!bookmark.IsFolder) { + try { + new URL(bookmark.URL); + } catch (ex) { + console.error( + `Ignoring ${bookmark.URL} when importing from Edge because of exception: ${ex}` + ); + continue; + } + bmToInsert = { + dateAdded: bookmark.DateUpdated || new Date(), + title: bookmark.Title, + url: bookmark.URL, + }; + } /* bookmark.IsFolder */ else { + // Ignore the favorites bar bookmark itself. + if (bookmark.Title == "_Favorites_Bar_") { + continue; + } + if (!bookmark._childrenRef) { + bookmark._childrenRef = []; + } + bmToInsert = { + title: bookmark.Title, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + dateAdded: bookmark.DateUpdated || new Date(), + children: bookmark._childrenRef, + }; + } + + if (!folderMap.has(bookmark.ParentId)) { + toplevelBMs.push(bmToInsert); + } else { + let parent = folderMap.get(bookmark.ParentId); + if (parent.Title == "_Favorites_Bar_") { + toolbarBMs.push(bmToInsert); + continue; + } + if (!parent._childrenRef) { + parent._childrenRef = []; + } + parent._childrenRef.push(bmToInsert); + } + } + return { toplevelBMs, toolbarBMs }; + }, +}; + +function getCookiesPaths() { + let folders = []; + let edgeDir = MSMigrationUtils.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(fullPath); + } + } + } + return folders; +} + +/** + * Edge (EdgeHTML) profile migrator + */ +export class EdgeProfileMigrator extends MigratorBase { + static get key() { + return "edge"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-edge-legacy"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/edge.png"; + } + + getBookmarksMigratorForTesting(dbOverride) { + return new EdgeBookmarksMigrator(dbOverride); + } + + getReadingListMigratorForTesting(dbOverride) { + return new EdgeReadingListMigrator(dbOverride); + } + + getHistoryDBMigratorForTesting(dbOverride) { + return new EdgeTypedURLDBMigrator(dbOverride); + } + + getHistoryRegistryMigratorForTesting() { + return new EdgeTypedURLMigrator(); + } + + getResources() { + let resources = [ + new EdgeBookmarksMigrator(), + new EdgeTypedURLMigrator(), + new EdgeTypedURLDBMigrator(), + new EdgeReadingListMigrator(), + ]; + let windowsVaultFormPasswordsMigrator = + MSMigrationUtils.getWindowsVaultFormPasswordsMigrator(); + windowsVaultFormPasswordsMigrator.name = "EdgeVaultFormPasswords"; + resources.push(windowsVaultFormPasswordsMigrator); + return resources.filter(r => r.exists); + } + + async getLastUsedDate() { + // Don't do this if we don't have a single profile (see the comment for + // sourceProfiles) or if we can't find the database file: + let sourceProfiles = await this.getSourceProfiles(); + if (sourceProfiles !== null || !lazy.gEdgeDatabase) { + return Promise.resolve(new Date(0)); + } + let logFilePath = PathUtils.join( + lazy.gEdgeDatabase.parent.path, + "LogFiles", + "edb.log" + ); + let dbPath = lazy.gEdgeDatabase.path; + let datePromises = [logFilePath, dbPath, ...getCookiesPaths()].map(path => { + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + datePromises.push( + new Promise(resolve => { + let typedURLs = new Map(); + try { + typedURLs = MSMigrationUtils.getTypedURLs(kEdgeRegistryRoot); + } catch (ex) {} + let times = [0, ...typedURLs.values()]; + // dates is an array of PRTimes, which are in microseconds - convert to milliseconds + resolve(Math.max.apply(Math, times) / 1000); + }) + ); + return Promise.all(datePromises).then(dates => { + return new Date(Math.max.apply(Math, dates)); + }); + } + + /** + * @returns {Array|null} + * Somewhat counterintuitively, this returns: + * - |null| to indicate "There is only 1 (default) profile" (on win10+) + * - |[]| to indicate "There are no profiles" (on <=win8.1) which will avoid + * using this migrator. + * See MigrationUtils.sys.mjs for slightly more info on how sourceProfiles is used. + */ + getSourceProfiles() { + let isWin10OrHigher = AppConstants.isPlatformAndVersionAtLeast("win", "10"); + return isWin10OrHigher ? null : []; + } +} diff --git a/browser/components/migration/FileMigrators.sys.mjs b/browser/components/migration/FileMigrators.sys.mjs new file mode 100644 index 0000000000..27b8e9a618 --- /dev/null +++ b/browser/components/migration/FileMigrators.sys.mjs @@ -0,0 +1,329 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + LoginCSVImport: "resource://gre/modules/LoginCSVImport.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +/** + * Base class for a migration that involves reading a single file off of + * the disk that the user picks using a file picker. The file might be + * generated by another browser or some other application. + */ +export class FileMigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "password-csv". This key is what + * is used as an identifier when calling MigrationUtils.getFileMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("FileMigrator.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("FileMigrator.displayNameL10nID must be overridden."); + } + + /** + * This getter should get overridden to return an icon url to represent the + * file to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * Returns true if the migrator is configured to be enabled. + * + * @type {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + throw new Error("FileMigrator.enabled must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * underway. + * + * @type {string} + */ + get progressHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * This getter should be overridden to return a Fluent string ID for what + * the migration wizard header should be while the file migration is + * done. + * + * @type {string} + */ + get successHeaderL10nID() { + throw new Error("FileMigrator.progressHeaderL10nID must be overridden."); + } + + /** + * @typedef {object} FilePickerConfiguration + * @property {string} title + * The title that should be assigned to the native file picker window. + * @property {FilePickerConfigurationFilter[]} filters + * One or more extension filters that should be applied to the native + * file picker window to make selection easier. + */ + + /** + * @typedef {object} FilePickerConfigurationFilter + * @property {string} title + * The title for the filter. Example: "CSV Files" + * @property {string} extensionPattern + * A matching pattern for the filter. Example: "*.csv" + */ + + /** + * A subclass of FileMigratorBase will eventually open a native file picker + * for the user to select the file from their file system. + * + * Subclasses need to override this method in order to configure the + * native file picker. + * + * @returns {Promise} + */ + async getFilePickerConfig() { + throw new Error("FileMigrator.getFilePickerConfig must be overridden."); + } + + /** + * Returns a list of one or more resource types that should appear to be + * in progress of migrating while the file migration occurs. Notably, + * this does not need to match the resource types that are returned by + * `FileMigratorBase.migrate`. + * + * @type {string[]} + * An array of resource types from the + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES set. + */ + get displayedResourceTypes() { + throw new Error("FileMigrator.displayedResourceTypes must be overridden"); + } + + /** + * Called to perform the file migration once the user makes a selection + * from the native file picker. This will not be called if the user + * chooses to cancel the native file picker. + * + * @param {string} filePath + * The path that the user selected from the native file picker. + */ + // eslint-disable-next-line no-unused-vars + async migrate(filePath) { + throw new Error("FileMigrator.migrate must be overridden."); + } +} + +/** + * A file migrator for importing passwords from CSV or TSV files. CSV + * files are more common, so this is what we show as the file type for + * the display name, but this FileMigrator accepts both. + */ +export class PasswordFileMigrator extends FileMigratorBase { + static get key() { + return "file-password-csv"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-password-csv"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-passwords-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-passwords-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, csvFilterTitle, tsvFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-passwords-from-file-picker-title" }, + { id: "migration-passwords-from-file-csv-filter-title" }, + { id: "migration-passwords-from-file-tsv-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: csvFilterTitle, + extensionPattern: "*.csv", + }, + { + title: tsvFilterTitle, + extensionPattern: "*.tsv", + }, + ], + }; + } + + async migrate(filePath) { + let summary = await lazy.LoginCSVImport.importFromCSV(filePath); + let newEntries = 0; + let updatedEntries = 0; + for (let entry of summary) { + if (entry.result == "added") { + newEntries++; + } else if (entry.result == "modified") { + updatedEntries++; + } + } + let [newMessage, updatedMessage] = await lazy.gFluentStrings.formatValues([ + { + id: "migration-wizard-progress-success-new-passwords", + args: { newEntries }, + }, + { + id: "migration-wizard-progress-success-updated-passwords", + args: { updatedEntries }, + }, + ]); + + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_NEW]: newMessage, + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .PASSWORDS_UPDATED]: updatedMessage, + }; + } +} + +/** + * A file migrator for importing bookmarks from a HTML or JSON file. + * + * @class BookmarksFileMigrator + * @augments {FileMigratorBase} + */ +export class BookmarksFileMigrator extends FileMigratorBase { + static get key() { + return "file-bookmarks"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-file-bookmarks"; + } + + static get brandImage() { + return "chrome://branding/content/document.ico"; + } + + get enabled() { + return Services.prefs.getBoolPref( + "browser.migrate.bookmarks-file.enabled", + false + ); + } + + get displayedResourceTypes() { + return [ + lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE, + ]; + } + + get progressHeaderL10nID() { + return "migration-bookmarks-from-file-progress-header"; + } + + get successHeaderL10nID() { + return "migration-bookmarks-from-file-success-header"; + } + + async getFilePickerConfig() { + let [title, htmlFilterTitle, jsonFilterTitle] = + await lazy.gFluentStrings.formatValues([ + { id: "migration-bookmarks-from-file-picker-title" }, + { id: "migration-bookmarks-from-file-html-filter-title" }, + { id: "migration-bookmarks-from-file-json-filter-title" }, + ]); + + return { + title, + filters: [ + { + title: htmlFilterTitle, + extensionPattern: "*.html", + }, + { + title: jsonFilterTitle, + extensionPattern: "*.json", + }, + ], + }; + } + + async migrate(filePath) { + let pathCheck = filePath.toLowerCase(); + let importedCount; + + if (pathCheck.endsWith("html")) { + importedCount = await lazy.BookmarkHTMLUtils.importFromFile(filePath); + } else if (pathCheck.endsWith("json") || pathCheck.endsWith("jsonlz4")) { + importedCount = await lazy.BookmarkJSONUtils.importFromFile(filePath); + } + let importedMessage = await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-new-bookmarks", + { + newEntries: importedCount, + } + ); + return { + [lazy.MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: importedMessage, + }; + } +} diff --git a/browser/components/migration/FirefoxProfileMigrator.sys.mjs b/browser/components/migration/FirefoxProfileMigrator.sys.mjs new file mode 100644 index 0000000000..2ce57c2c7b --- /dev/null +++ b/browser/components/migration/FirefoxProfileMigrator.sys.mjs @@ -0,0 +1,397 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sw=2 ts=2 sts=2 et */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* + * Migrates from a Firefox profile in a lossy manner in order to clean up a + * user's profile. Data is only migrated where the benefits outweigh the + * potential problems caused by importing undesired/invalid configurations + * from the source profile. + */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PlacesBackups: "resource://gre/modules/PlacesBackups.sys.mjs", + ProfileAge: "resource://gre/modules/ProfileAge.sys.mjs", + SessionMigration: "resource:///modules/sessionstore/SessionMigration.sys.mjs", +}); + +/** + * Firefox profile migrator. Currently, this class only does "pave over" + * migrations, where various parts of an old profile overwrite a new + * profile. This is distinct from other migrators which attempt to import + * old profile data into the existing profile. + * + * This migrator is what powers the "Profile Refresh" mechanism. + */ +export class FirefoxProfileMigrator extends MigratorBase { + static get key() { + return "firefox"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-firefox"; + } + + _getAllProfiles() { + let allProfiles = new Map(); + let profileService = Cc[ + "@mozilla.org/toolkit/profile-service;1" + ].getService(Ci.nsIToolkitProfileService); + for (let profile of profileService.profiles) { + let rootDir = profile.rootDir; + + if ( + rootDir.exists() && + rootDir.isReadable() && + !rootDir.equals(MigrationUtils.profileStartup.directory) + ) { + allProfiles.set(profile.name, rootDir); + } + } + return allProfiles; + } + + getSourceProfiles() { + let sorter = (a, b) => { + return a.id.toLocaleLowerCase().localeCompare(b.id.toLocaleLowerCase()); + }; + + return [...this._getAllProfiles().keys()] + .map(x => ({ id: x, name: x })) + .sort(sorter); + } + + _getFileObject(dir, fileName) { + let file = dir.clone(); + file.append(fileName); + + // File resources are monolithic. We don't make partial copies since + // they are not expected to work alone. Return null to avoid trying to + // copy non-existing files. + return file.exists() ? file : null; + } + + getResources(aProfile) { + let sourceProfileDir = aProfile + ? this._getAllProfiles().get(aProfile.id) + : Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ).defaultProfile.rootDir; + if ( + !sourceProfileDir || + !sourceProfileDir.exists() || + !sourceProfileDir.isReadable() + ) { + return null; + } + + // Being a startup-only migrator, we can rely on + // MigrationUtils.profileStartup being set. + let currentProfileDir = MigrationUtils.profileStartup.directory; + + // Surely data cannot be imported from the current profile. + if (sourceProfileDir.equals(currentProfileDir)) { + return null; + } + + return this._getResourcesInternal(sourceProfileDir, currentProfileDir); + } + + getLastUsedDate() { + // We always pretend we're really old, so that we don't mess + // up the determination of which browser is the most 'recent' + // to import from. + return Promise.resolve(new Date(0)); + } + + _getResourcesInternal(sourceProfileDir, currentProfileDir) { + let getFileResource = (aMigrationType, aFileNames) => { + let files = []; + for (let fileName of aFileNames) { + let file = this._getFileObject(sourceProfileDir, fileName); + if (file) { + files.push(file); + } + } + if (!files.length) { + return null; + } + return { + type: aMigrationType, + migrate(aCallback) { + for (let file of files) { + file.copyTo(currentProfileDir, ""); + } + aCallback(true); + }, + }; + }; + + let _oldRawPrefsMemoized = null; + async function readOldPrefs() { + if (!_oldRawPrefsMemoized) { + let prefsPath = PathUtils.join(sourceProfileDir.path, "prefs.js"); + if (await IOUtils.exists(prefsPath)) { + _oldRawPrefsMemoized = await IOUtils.readUTF8(prefsPath, { + encoding: "utf-8", + }); + } + } + + return _oldRawPrefsMemoized; + } + + function savePrefs() { + // If we've used the pref service to write prefs for the new profile, it's too + // early in startup for the service to have a profile directory, so we have to + // manually tell it where to save the prefs file. + let newPrefsFile = currentProfileDir.clone(); + newPrefsFile.append("prefs.js"); + Services.prefs.savePrefFile(newPrefsFile); + } + + let types = MigrationUtils.resourceTypes; + let places = getFileResource(types.HISTORY, [ + "places.sqlite", + "places.sqlite-wal", + ]); + let favicons = getFileResource(types.HISTORY, [ + "favicons.sqlite", + "favicons.sqlite-wal", + ]); + let cookies = getFileResource(types.COOKIES, [ + "cookies.sqlite", + "cookies.sqlite-wal", + ]); + let passwords = getFileResource(types.PASSWORDS, [ + "signons.sqlite", + "logins.json", + "key3.db", + "key4.db", + ]); + let formData = getFileResource(types.FORMDATA, [ + "formhistory.sqlite", + "autofill-profiles.json", + ]); + let bookmarksBackups = getFileResource(types.OTHERDATA, [ + lazy.PlacesBackups.profileRelativeFolderPath, + ]); + let dictionary = getFileResource(types.OTHERDATA, ["persdict.dat"]); + + let session; + if (Services.env.get("MOZ_RESET_PROFILE_MIGRATE_SESSION")) { + // We only want to restore the previous firefox session if the profile refresh was + // triggered by user. The MOZ_RESET_PROFILE_MIGRATE_SESSION would be set when a user-triggered + // profile refresh happened in nsAppRunner.cpp. Hence, we detect the MOZ_RESET_PROFILE_MIGRATE_SESSION + // to see if session data migration is required. + Services.env.set("MOZ_RESET_PROFILE_MIGRATE_SESSION", ""); + let sessionCheckpoints = this._getFileObject( + sourceProfileDir, + "sessionCheckpoints.json" + ); + let sessionFile = this._getFileObject( + sourceProfileDir, + "sessionstore.jsonlz4" + ); + if (sessionFile) { + session = { + type: types.SESSION, + migrate(aCallback) { + sessionCheckpoints.copyTo( + currentProfileDir, + "sessionCheckpoints.json" + ); + let newSessionFile = currentProfileDir.clone(); + newSessionFile.append("sessionstore.jsonlz4"); + let migrationPromise = lazy.SessionMigration.migrate( + sessionFile.path, + newSessionFile.path + ); + migrationPromise.then( + function () { + let buildID = Services.appinfo.platformBuildID; + let mstone = Services.appinfo.platformVersion; + // Force the browser to one-off resume the session that we give it: + Services.prefs.setBoolPref( + "browser.sessionstore.resume_session_once", + true + ); + // Reset the homepage_override prefs so that the browser doesn't override our + // session with the "what's new" page: + Services.prefs.setCharPref( + "browser.startup.homepage_override.mstone", + mstone + ); + Services.prefs.setCharPref( + "browser.startup.homepage_override.buildID", + buildID + ); + savePrefs(); + aCallback(true); + }, + function () { + aCallback(false); + } + ); + }, + }; + } + } + + // Sync/FxA related data + let sync = { + name: "sync", // name is used only by tests. + type: types.OTHERDATA, + migrate: async aCallback => { + // Try and parse a signedInUser.json file from the source directory and + // if we can, copy it to the new profile and set sync's username pref + // (which acts as a de-facto flag to indicate if sync is configured) + try { + let oldPath = PathUtils.join( + sourceProfileDir.path, + "signedInUser.json" + ); + let exists = await IOUtils.exists(oldPath); + if (exists) { + let data = await IOUtils.readJSON(oldPath); + if (data && data.accountData && data.accountData.email) { + let username = data.accountData.email; + // copy the file itself. + await IOUtils.copy( + oldPath, + PathUtils.join(currentProfileDir.path, "signedInUser.json") + ); + // Now we need to know whether Sync is actually configured for this + // user. The only way we know is by looking at the prefs file from + // the old profile. We avoid trying to do a full parse of the prefs + // file and even avoid parsing the single string value we care + // about. + let oldRawPrefs = await readOldPrefs(); + if (/^user_pref\("services\.sync\.username"/m.test(oldRawPrefs)) { + // sync's configured in the source profile - ensure it is in the + // new profile too. + // Write it to prefs.js and flush the file. + Services.prefs.setStringPref( + "services.sync.username", + username + ); + savePrefs(); + } + } + } + } catch (ex) { + aCallback(false); + return; + } + aCallback(true); + }, + }; + + // Telemetry related migrations. + let times = { + name: "times", // name is used only by tests. + type: types.OTHERDATA, + migrate: aCallback => { + let file = this._getFileObject(sourceProfileDir, "times.json"); + if (file) { + file.copyTo(currentProfileDir, ""); + } + // And record the fact a migration (ie, a reset) happened. + let recordMigration = async () => { + try { + let profileTimes = await lazy.ProfileAge(currentProfileDir.path); + await profileTimes.recordProfileReset(); + aCallback(true); + } catch (e) { + aCallback(false); + } + }; + + recordMigration(); + }, + }; + let telemetry = { + name: "telemetry", // name is used only by tests... + type: types.OTHERDATA, + migrate: async aCallback => { + let createSubDir = name => { + let dir = currentProfileDir.clone(); + dir.append(name); + dir.create(Ci.nsIFile.DIRECTORY_TYPE, lazy.FileUtils.PERMS_DIRECTORY); + return dir; + }; + + // If the 'datareporting' directory exists we migrate files from it. + let dataReportingDir = this._getFileObject( + sourceProfileDir, + "datareporting" + ); + if (dataReportingDir && dataReportingDir.isDirectory()) { + // Copy only specific files. + let toCopy = ["state.json", "session-state.json"]; + + let dest = createSubDir("datareporting"); + let enumerator = dataReportingDir.directoryEntries; + while (enumerator.hasMoreElements()) { + let file = enumerator.nextFile; + if (file.isDirectory() || !toCopy.includes(file.leafName)) { + continue; + } + file.copyTo(dest, ""); + } + } + + try { + let oldRawPrefs = await readOldPrefs(); + let writePrefs = false; + const PREFS = ["bookmarks", "history", "passwords"]; + + for (let pref of PREFS) { + let fullPref = `browser\.migrate\.interactions\.${pref}`; + let regex = new RegExp('^user_pref\\("' + fullPref, "m"); + if (regex.test(oldRawPrefs)) { + Services.prefs.setBoolPref(fullPref, true); + writePrefs = true; + } + } + + if (writePrefs) { + savePrefs(); + } + } catch (e) { + aCallback(false); + return; + } + + aCallback(true); + }, + }; + + return [ + places, + cookies, + passwords, + formData, + dictionary, + bookmarksBackups, + session, + sync, + times, + telemetry, + favicons, + ].filter(r => r); + } + + get startupOnlyMigrator() { + return true; + } +} diff --git a/browser/components/migration/IEProfileMigrator.sys.mjs b/browser/components/migration/IEProfileMigrator.sys.mjs new file mode 100644 index 0000000000..1a8c231b55 --- /dev/null +++ b/browser/components/migration/IEProfileMigrator.sys.mjs @@ -0,0 +1,402 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const kLoginsKey = + "Software\\Microsoft\\Internet Explorer\\IntelliForms\\Storage2"; + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; +import { MSMigrationUtils } from "resource:///modules/MSMigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + OSCrypto: "resource://gre/modules/OSCrypto_win.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + ctypes: "resource://gre/modules/ctypes.sys.mjs", +}); + +// Resources + +function History() {} + +History.prototype = { + type: MigrationUtils.resourceTypes.HISTORY, + + get exists() { + return true; + }, + + migrate: function H_migrate(aCallback) { + let pageInfos = []; + let typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + let now = new Date(); + let maxDate = new Date( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let url = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, + // like HTMLHelp and others. Since we don't properly map handling for + // all of them we just avoid importing them. + if (!["http", "https", "ftp", "file"].includes(url.scheme)) { + continue; + } + + let title = entry.get("title"); + // Embed visits have no title and don't need to be imported. + if (!title.length) { + continue; + } + + // The typed urls are already fixed-up, so we can use them for comparison. + let transition = typedURLs.has(url.spec) + ? lazy.PlacesUtils.history.TRANSITIONS.LINK + : lazy.PlacesUtils.history.TRANSITIONS.TYPED; + + let time = entry.get("time"); + + let visitDate = time ? lazy.PlacesUtils.toDate(time) : null; + if (visitDate && visitDate < maxDate) { + continue; + } + + pageInfos.push({ + url, + title, + visits: [ + { + transition, + // use the current date if we have no visits for this entry. + date: visitDate ?? now, + }, + ], + }); + } + + // Check whether there is any history to import. + if (!pageInfos.length) { + aCallback(true); + return; + } + + MigrationUtils.insertVisitsWrapper(pageInfos).then( + () => aCallback(true), + () => aCallback(false) + ); + }, +}; + +// IE form password migrator supporting windows from XP until 7 and IE from 7 until 11 +function IE7FormPasswords() { + // used to distinguish between this migrator and other passwords migrators in tests. + this.name = "IE7FormPasswords"; +} + +IE7FormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // work only on windows until 7 + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + return false; + } + + try { + let nsIWindowsRegKey = Ci.nsIWindowsRegKey; + let key = + Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + key.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kLoginsKey, + nsIWindowsRegKey.ACCESS_READ + ); + let count = key.valueCount; + key.close(); + return count > 0; + } catch (e) { + return false; + } + }, + + async migrate(aCallback) { + let uris = []; // the uris of the websites that are going to be migrated + for (let entry of Cc[ + "@mozilla.org/profile/migrator/iehistoryenumerator;1" + ].createInstance(Ci.nsISimpleEnumerator)) { + let uri = entry.get("uri").QueryInterface(Ci.nsIURI); + // MSIE stores some types of URLs in its history that we don't handle, like HTMLHelp + // and others. Since we are not going to import the logins that are performed in these URLs + // we can just skip them. + if (!["http", "https", "ftp"].includes(uri.scheme)) { + continue; + } + + uris.push(uri); + } + await this._migrateURIs(uris); + aCallback(true); + }, + + /** + * Migrate the logins that were saved for the uris arguments. + * + * @param {nsIURI[]} uris - the uris that are going to be migrated. + */ + async _migrateURIs(uris) { + this.ctypesKernelHelpers = new MSMigrationUtils.CtypesKernelHelpers(); + this._crypto = new lazy.OSCrypto(); + let nsIWindowsRegKey = Ci.nsIWindowsRegKey; + let key = + Cc["@mozilla.org/windows-registry-key;1"].createInstance( + nsIWindowsRegKey + ); + key.open( + nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kLoginsKey, + nsIWindowsRegKey.ACCESS_READ + ); + + let urlsSet = new Set(); // set of the already processed urls. + // number of the successfully decrypted registry values + let successfullyDecryptedValues = 0; + /* The logins are stored in the registry, where the key is a hashed URL and its + * value contains the encrypted details for all logins for that URL. + * + * First iterate through IE history, hashing each URL and looking for a match. If + * found, decrypt the value, using the URL as a salt. Finally add any found logins + * to the Firefox password manager. + */ + + let logins = []; + for (let uri of uris) { + try { + // remove the query and the ref parts of the URL + let urlObject = new URL(uri.spec); + let url = urlObject.origin + urlObject.pathname; + // if the current url is already processed, it should be skipped + if (urlsSet.has(url)) { + continue; + } + urlsSet.add(url); + // hash value of the current uri + let hashStr = this._crypto.getIELoginHash(url); + if (!key.hasValue(hashStr)) { + continue; + } + let value = key.readBinaryValue(hashStr); + // if no value was found, the uri is skipped + if (value == null) { + continue; + } + let data; + try { + // the url is used as salt to decrypt the registry value + data = this._crypto.decryptData(value, url); + } catch (e) { + continue; + } + // extract the login details from the decrypted data + let ieLogins = this._extractDetails(data, uri); + // if at least a credential was found in the current data, successfullyDecryptedValues should + // be incremented by one + if (ieLogins.length) { + successfullyDecryptedValues++; + } + for (let ieLogin of ieLogins) { + logins.push({ + username: ieLogin.username, + password: ieLogin.password, + origin: ieLogin.url, + timeCreated: ieLogin.creation, + }); + } + } catch (e) { + console.error("Error while importing logins for ", uri.spec, ": ", e); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + + // if the number of the imported values is less than the number of values in the key, it means + // that not all the values were imported and an error should be reported + if (successfullyDecryptedValues < key.valueCount) { + console.error( + "We failed to decrypt and import some logins. " + + "This is likely because we didn't find the URLs where these " + + "passwords were submitted in the IE history and which are needed to be used " + + "as keys in the decryption." + ); + } + + key.close(); + this._crypto.finalize(); + this.ctypesKernelHelpers.finalize(); + }, + + _crypto: null, + + /** + * Extract the details of one or more logins from the raw decrypted data. + * + * @param {string} data - the decrypted data containing raw information. + * @param {nsURI} uri - the nsURI of page where the login has occur. + * @returns {object[]} array of objects where each of them contains the username, password, URL, + * and creation time representing all the logins found in the data arguments. + */ + _extractDetails(data, uri) { + // the structure of the header of the IE7 decrypted data for all the logins sharing the same URL + let loginData = new lazy.ctypes.StructType("loginData", [ + // Bytes 0-3 are not needed and not documented + { unknown1: lazy.ctypes.uint32_t }, + // Bytes 4-7 are the header size + { headerSize: lazy.ctypes.uint32_t }, + // Bytes 8-11 are the data size + { dataSize: lazy.ctypes.uint32_t }, + // Bytes 12-19 are not needed and not documented + { unknown2: lazy.ctypes.uint32_t }, + { unknown3: lazy.ctypes.uint32_t }, + // Bytes 20-23 are the data count: each username and password is considered as a data + { dataMax: lazy.ctypes.uint32_t }, + // Bytes 24-35 are not needed and not documented + { unknown4: lazy.ctypes.uint32_t }, + { unknown5: lazy.ctypes.uint32_t }, + { unknown6: lazy.ctypes.uint32_t }, + ]); + + // the structure of a IE7 decrypted login item + let loginItem = new lazy.ctypes.StructType("loginItem", [ + // Bytes 0-3 are the offset of the username + { usernameOffset: lazy.ctypes.uint32_t }, + // Bytes 4-11 are the date + { loDateTime: lazy.ctypes.uint32_t }, + { hiDateTime: lazy.ctypes.uint32_t }, + // Bytes 12-15 are not needed and not documented + { foo: lazy.ctypes.uint32_t }, + // Bytes 16-19 are the offset of the password + { passwordOffset: lazy.ctypes.uint32_t }, + // Bytes 20-31 are not needed and not documented + { unknown1: lazy.ctypes.uint32_t }, + { unknown2: lazy.ctypes.uint32_t }, + { unknown3: lazy.ctypes.uint32_t }, + ]); + + let url = uri.prePath; + let results = []; + let arr = this._crypto.stringToArray(data); + // convert data to ctypes.unsigned_char.array(arr.length) + let cdata = lazy.ctypes.unsigned_char.array(arr.length)(arr); + // Bytes 0-35 contain the loginData data structure for all the logins sharing the same URL + let currentLoginData = lazy.ctypes.cast(cdata, loginData); + let headerSize = currentLoginData.headerSize; + let currentInfoIndex = loginData.size; + // pointer to the current login item + let currentLoginItemPointer = lazy.ctypes.cast( + cdata.addressOfElement(currentInfoIndex), + loginItem.ptr + ); + // currentLoginData.dataMax is the data count: each username and password is considered as + // a data. So, the number of logins is the number of data dived by 2 + let numLogins = currentLoginData.dataMax / 2; + for (let n = 0; n < numLogins; n++) { + // Bytes 0-31 starting from currentInfoIndex contain the loginItem data structure for the + // current login + let currentLoginItem = currentLoginItemPointer.contents; + let creation = + this.ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + currentLoginItem.hiDateTime, + currentLoginItem.loDateTime + ) * 1000; + let currentResult = { + creation, + url, + }; + // The username is UTF-16 and null-terminated. + currentResult.username = lazy.ctypes + .cast( + cdata.addressOfElement( + headerSize + 12 + currentLoginItem.usernameOffset + ), + lazy.ctypes.char16_t.ptr + ) + .readString(); + // The password is UTF-16 and null-terminated. + currentResult.password = lazy.ctypes + .cast( + cdata.addressOfElement( + headerSize + 12 + currentLoginItem.passwordOffset + ), + lazy.ctypes.char16_t.ptr + ) + .readString(); + results.push(currentResult); + // move to the next login item + currentLoginItemPointer = currentLoginItemPointer.increment(); + } + return results; + }, +}; + +/** + * Internet Explorer profile migrator + */ +export class IEProfileMigrator extends MigratorBase { + static get key() { + return "ie"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-ie"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/ie.png"; + } + + getResources() { + let resources = [MSMigrationUtils.getBookmarksMigrator(), new History()]; + // 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); + } + + async getLastUsedDate() { + const datePromises = ["Favs", "CookD"].map(dirId => { + const { path } = Services.dirsvc.get(dirId, Ci.nsIFile); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }); + + const dates = await Promise.all(datePromises); + + try { + const typedURLs = MSMigrationUtils.getTypedURLs( + "Software\\Microsoft\\Internet Explorer" + ); + // typedURLs.values() returns an array of PRTimes, which are in + // microseconds - convert to milliseconds + dates.push(Math.max(0, ...typedURLs.values()) / 1000); + } catch (ex) {} + + return new Date(Math.max(...dates)); + } +} diff --git a/browser/components/migration/InternalTestingProfileMigrator.sys.mjs b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs new file mode 100644 index 0000000000..e14b72607f --- /dev/null +++ b/browser/components/migration/InternalTestingProfileMigrator.sys.mjs @@ -0,0 +1,63 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", +}); + +/** + * A stub of a migrator used for automated testing only. + */ +export class InternalTestingProfileMigrator extends MigratorBase { + static get key() { + return "internal-testing"; + } + + static get displayNameL10nID() { + return "Internal Testing Migrator"; + } + + getSourceProfiles() { + return Promise.resolve([InternalTestingProfileMigrator.testProfile]); + } + + // We will create a single MigratorResource for each resource type that + // just immediately reports a successful migration. + getResources(aProfile) { + if ( + !aProfile || + aProfile.id != InternalTestingProfileMigrator.testProfile.id + ) { + throw new Error( + "InternalTestingProfileMigrator.getResources expects test profile." + ); + } + return Object.values(lazy.MigrationUtils.resourceTypes).map(type => { + return { + type, + migrate: callback => { + callback(true /* success */); + }, + }; + }); + } + + /** + * Clears the MigratorResources that are normally cached by the + * MigratorBase parent class after a call to getResources. This + * allows our automated tests to try different resource availability + * scenarios between tests. + */ + flushResourceCache() { + this._resourcesByProfile = null; + } + + static get testProfile() { + return { id: "test-profile", name: "Some test profile" }; + } +} diff --git a/browser/components/migration/MSMigrationUtils.sys.mjs b/browser/components/migration/MSMigrationUtils.sys.mjs new file mode 100644 index 0000000000..dcb663fe57 --- /dev/null +++ b/browser/components/migration/MSMigrationUtils.sys.mjs @@ -0,0 +1,754 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { ctypes } from "resource://gre/modules/ctypes.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", +}); + +const EDGE_FAVORITES = "AC\\MicrosoftEdge\\User\\Default\\Favorites"; +const FREE_CLOSE_FAILED = 0; +const INTERNET_EXPLORER_EDGE_GUID = [ + 0x3ccd5499, 0x4b1087a8, 0x886015a2, 0x553bdd88, +]; +const RESULT_SUCCESS = 0; +const VAULT_ENUMERATE_ALL_ITEMS = 512; +const WEB_CREDENTIALS_VAULT_ID = [ + 0x4bf4c442, 0x41a09b8a, 0x4add80b3, 0x28db4d70, +]; + +const wintypes = { + BOOL: ctypes.int, + DWORD: ctypes.uint32_t, + DWORDLONG: ctypes.uint64_t, + CHAR: ctypes.char, + PCHAR: ctypes.char.ptr, + LPCWSTR: ctypes.char16_t.ptr, + PDWORD: ctypes.uint32_t.ptr, + VOIDP: ctypes.voidptr_t, + WORD: ctypes.uint16_t, +}; + +// TODO: Bug 1202978 - Refactor MSMigrationUtils ctypes helpers +function CtypesKernelHelpers() { + this._structs = {}; + this._functions = {}; + this._libs = {}; + + this._structs.SYSTEMTIME = new ctypes.StructType("SYSTEMTIME", [ + { wYear: wintypes.WORD }, + { wMonth: wintypes.WORD }, + { wDayOfWeek: wintypes.WORD }, + { wDay: wintypes.WORD }, + { wHour: wintypes.WORD }, + { wMinute: wintypes.WORD }, + { wSecond: wintypes.WORD }, + { wMilliseconds: wintypes.WORD }, + ]); + + this._structs.FILETIME = new ctypes.StructType("FILETIME", [ + { dwLowDateTime: wintypes.DWORD }, + { dwHighDateTime: wintypes.DWORD }, + ]); + + try { + this._libs.kernel32 = ctypes.open("Kernel32"); + + this._functions.FileTimeToSystemTime = this._libs.kernel32.declare( + "FileTimeToSystemTime", + ctypes.winapi_abi, + wintypes.BOOL, + this._structs.FILETIME.ptr, + this._structs.SYSTEMTIME.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesKernelHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + for (let key in this._libs) { + let lib = this._libs[key]; + try { + lib.close(); + } catch (ex) {} + } + this._libs = {}; + }, + + /** + * Converts a FILETIME struct (2 DWORDS), to a SYSTEMTIME struct, + * and then deduces the number of seconds since the epoch (which + * is the data we want for the cookie expiry date). + * + * @param {number} aTimeHi + * Least significant DWORD. + * @param {number} aTimeLo + * Most significant DWORD. + * @returns {number} the number of seconds since the epoch + */ + fileTimeToSecondsSinceEpoch(aTimeHi, aTimeLo) { + let fileTime = this._structs.FILETIME(); + fileTime.dwLowDateTime = aTimeLo; + fileTime.dwHighDateTime = aTimeHi; + let systemTime = this._structs.SYSTEMTIME(); + let result = this._functions.FileTimeToSystemTime( + fileTime.address(), + systemTime.address() + ); + if (result == 0) { + throw new Error(ctypes.winLastError); + } + + // System time is in UTC, so we use Date.UTC to get milliseconds from epoch, + // then divide by 1000 to get seconds, and round down: + return Math.floor( + Date.UTC( + systemTime.wYear, + systemTime.wMonth - 1, + systemTime.wDay, + systemTime.wHour, + systemTime.wMinute, + systemTime.wSecond, + systemTime.wMilliseconds + ) / 1000 + ); + }, +}; + +function CtypesVaultHelpers() { + this._structs = {}; + this._functions = {}; + + this._structs.GUID = new ctypes.StructType("GUID", [ + { id: wintypes.DWORD.array(4) }, + ]); + + this._structs.VAULT_ITEM_ELEMENT = new ctypes.StructType( + "VAULT_ITEM_ELEMENT", + [ + // not documented + { schemaElementId: wintypes.DWORD }, + // not documented + { unknown1: wintypes.DWORD }, + // vault type + { type: wintypes.DWORD }, + // not documented + { unknown2: wintypes.DWORD }, + // value of the item + { itemValue: wintypes.LPCWSTR }, + // not documented + { unknown3: wintypes.CHAR.array(12) }, + ] + ); + + this._structs.VAULT_ELEMENT = new ctypes.StructType("VAULT_ELEMENT", [ + // vault item schemaId + { schemaId: this._structs.GUID }, + // a pointer to the name of the browser VAULT_ITEM_ELEMENT + { pszCredentialFriendlyName: wintypes.LPCWSTR }, + // a pointer to the url VAULT_ITEM_ELEMENT + { pResourceElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // a pointer to the username VAULT_ITEM_ELEMENT + { pIdentityElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pAuthenticatorElement: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // not documented + { pPackageSid: this._structs.VAULT_ITEM_ELEMENT.ptr }, + // time stamp in local format + { lowLastModified: wintypes.DWORD }, + { highLastModified: wintypes.DWORD }, + // not documented + { flags: wintypes.DWORD }, + // not documented + { dwPropertiesCount: wintypes.DWORD }, + // not documented + { pPropertyElements: this._structs.VAULT_ITEM_ELEMENT.ptr }, + ]); + + try { + this._vaultcliLib = ctypes.open("vaultcli.dll"); + + this._functions.VaultOpenVault = this._vaultcliLib.declare( + "VaultOpenVault", + ctypes.winapi_abi, + wintypes.DWORD, + // GUID + this._structs.GUID.ptr, + // Flags + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP.ptr + ); + this._functions.VaultEnumerateItems = this._vaultcliLib.declare( + "VaultEnumerateItems", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Flags + wintypes.DWORD, + // Items Count + wintypes.PDWORD, + // Items + ctypes.voidptr_t + ); + this._functions.VaultCloseVault = this._vaultcliLib.declare( + "VaultCloseVault", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP + ); + this._functions.VaultGetItem = this._vaultcliLib.declare( + "VaultGetItem", + ctypes.winapi_abi, + wintypes.DWORD, + // Vault Handle + wintypes.VOIDP, + // Schema Id + this._structs.GUID.ptr, + // Resource + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Identity + this._structs.VAULT_ITEM_ELEMENT.ptr, + // Package Sid + this._structs.VAULT_ITEM_ELEMENT.ptr, + // HWND Owner + wintypes.DWORD, + // Flags + wintypes.DWORD, + // Items + this._structs.VAULT_ELEMENT.ptr.ptr + ); + this._functions.VaultFree = this._vaultcliLib.declare( + "VaultFree", + ctypes.winapi_abi, + wintypes.DWORD, + // Memory + this._structs.VAULT_ELEMENT.ptr + ); + } catch (ex) { + this.finalize(); + } +} + +CtypesVaultHelpers.prototype = { + /** + * Must be invoked once after last use of any of the provided helpers. + */ + finalize() { + this._structs = {}; + this._functions = {}; + try { + this._vaultcliLib.close(); + } catch (ex) {} + this._vaultcliLib = null; + }, +}; + +var gEdgeDir; +function getEdgeLocalDataFolder() { + if (gEdgeDir) { + return gEdgeDir.clone(); + } + let packages = Services.dirsvc.get("LocalAppData", Ci.nsIFile); + packages.append("Packages"); + let edgeDir = packages.clone(); + edgeDir.append("Microsoft.MicrosoftEdge_8wekyb3d8bbwe"); + try { + if (edgeDir.exists() && edgeDir.isReadable() && edgeDir.isDirectory()) { + gEdgeDir = edgeDir; + return edgeDir.clone(); + } + + // Let's try the long way: + let dirEntries = packages.directoryEntries; + while (dirEntries.hasMoreElements()) { + let subDir = dirEntries.nextFile; + if ( + subDir.leafName.startsWith("Microsoft.MicrosoftEdge") && + subDir.isReadable() && + subDir.isDirectory() + ) { + gEdgeDir = subDir; + return subDir.clone(); + } + } + } catch (ex) { + console.error( + "Exception trying to find the Edge favorites directory: ", + ex + ); + } + return null; +} + +function Bookmarks(migrationType) { + this._migrationType = migrationType; +} + +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + get exists() { + return !!this._favoritesFolder; + }, + + get importedAppLabel() { + return this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE + ? "IE" + : "Edge"; + }, + + __favoritesFolder: null, + get _favoritesFolder() { + if (!this.__favoritesFolder) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + let favoritesFolder = Services.dirsvc.get("Favs", Ci.nsIFile); + if (favoritesFolder.exists() && favoritesFolder.isReadable()) { + this.__favoritesFolder = favoritesFolder; + } + } else if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_EDGE) { + let edgeDir = getEdgeLocalDataFolder(); + if (edgeDir) { + edgeDir.appendRelativePath(EDGE_FAVORITES); + if ( + edgeDir.exists() && + edgeDir.isReadable() && + edgeDir.isDirectory() + ) { + this.__favoritesFolder = edgeDir; + } + } + } + } + return this.__favoritesFolder; + }, + + __toolbarFolderName: null, + get _toolbarFolderName() { + if (!this.__toolbarFolderName) { + if (this._migrationType == MSMigrationUtils.MIGRATION_TYPE_IE) { + // Retrieve the name of IE's favorites subfolder that holds the bookmarks + // in the toolbar. This was previously stored in the registry and changed + // in IE7 to always be called "Links". + let folderName = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + "Software\\Microsoft\\Internet Explorer\\Toolbar", + "LinksFolderName" + ); + this.__toolbarFolderName = folderName || "Links"; + } else { + this.__toolbarFolderName = "Links"; + } + } + return this.__toolbarFolderName; + }, + + migrate: function B_migrate(aCallback) { + return (async () => { + // Import to the bookmarks menu. + let folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + await this._migrateFolder(this._favoritesFolder, folderGuid); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + async _migrateFolder(aSourceFolder, aDestFolderGuid) { + let { bookmarks, favicons } = await this._getBookmarksInFolder( + aSourceFolder + ); + if (!bookmarks.length) { + return; + } + + await MigrationUtils.insertManyBookmarksWrapper(bookmarks, aDestFolderGuid); + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Iterates through a bookmark folder to obtain whatever information from each bookmark is needed elsewhere. This function also recurses into child folders. + * + * @param {nsIFile} aSourceFolder the folder to search for bookmarks and subfolders. + * @returns {Promise} An object with the following properties: + * {Object[]} bookmarks: + * An array of Objects with these properties: + * {number} type: A type mapping to one of the types in nsINavBookmarksService + * {string} title: The title of the bookmark + * {Object[]} children: An array of objects with the same structure as this one. + * + * {Object[]} favicons + * An array of Objects with these properties: + * {Uint8Array} faviconData: The binary data of a favicon + * {nsIURI} uri: The URI of the associated bookmark + */ + async _getBookmarksInFolder(aSourceFolder) { + // TODO (bug 741993): the favorites order is stored in the Registry, at + // HCU\Software\Microsoft\Windows\CurrentVersion\Explorer\MenuOrder\Favorites + // for IE, and in a similar location for Edge. + // Until we support it, bookmarks are imported in alphabetical order. + let entries = aSourceFolder.directoryEntries; + let rv = []; + let favicons = []; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + try { + // Make sure that entry.path == entry.target to not follow .lnk folder + // shortcuts which could lead to infinite cycles. + // Don't use isSymlink(), since it would throw for invalid + // lnk files pointing to URLs or to unresolvable paths. + if (entry.path == entry.target && entry.isDirectory()) { + let isBookmarksFolder = + entry.leafName == this._toolbarFolderName && + entry.parent.equals(this._favoritesFolder); + if (isBookmarksFolder && entry.isReadable()) { + // Import to the bookmarks toolbar. + let folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + await this._migrateFolder(entry, folderGuid); + } else if (entry.isReadable()) { + let { bookmarks: childBookmarks, favicons: childFavicons } = + await this._getBookmarksInFolder(entry); + favicons = favicons.concat(childFavicons); + rv.push({ + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: entry.leafName, + children: childBookmarks, + }); + } + } else { + // Strip the .url extension, to both check this is a valid link file, + // and get the associated title. + let matches = entry.leafName.match(/(.+)\.url$/i); + if (matches) { + let fileHandler = Cc[ + "@mozilla.org/network/protocol;1?name=file" + ].getService(Ci.nsIFileProtocolHandler); + let uri = fileHandler.readURLFile(entry); + // Silently failing in the event that the alternative data stream for the favicon doesn't exist + try { + let faviconData = await IOUtils.read(entry.path + ":favicon"); + favicons.push({ faviconData, uri }); + } catch {} + + rv.push({ url: uri, title: matches[1] }); + } + } + } catch (ex) { + console.error( + "Unable to import ", + this.importedAppLabel, + " favorite (", + entry.leafName, + "): ", + ex + ); + } + } + return { bookmarks: rv, favicons }; + }, +}; + +function getTypedURLs(registryKeyPath) { + // The list of typed URLs is a sort of annotation stored in the registry. + // The number of entries stored is not UI-configurable, but has changed + // between different Windows versions. We just keep reading up to the first + // non-existing entry to support different limits / states of the registry. + let typedURLs = new Map(); + let typedURLKey = Cc["@mozilla.org/windows-registry-key;1"].createInstance( + Ci.nsIWindowsRegKey + ); + let typedURLTimeKey = Cc[ + "@mozilla.org/windows-registry-key;1" + ].createInstance(Ci.nsIWindowsRegKey); + let cTypes = new CtypesKernelHelpers(); + try { + try { + typedURLKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLs", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + // Ignore errors opening this registry key - if it doesn't work, there's + // no way we can get useful info here. + return typedURLs; + } + try { + typedURLTimeKey.open( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + registryKeyPath + "\\TypedURLsTime", + Ci.nsIWindowsRegKey.ACCESS_READ + ); + } catch (ex) { + typedURLTimeKey = null; + } + let entryName; + for ( + let entry = 1; + typedURLKey.hasValue((entryName = "url" + entry)); + entry++ + ) { + let url = typedURLKey.readStringValue(entryName); + // If we can't get a date for whatever reason, default to 6 months ago + let timeTyped = Date.now() - 31536000 / 2; + if (typedURLTimeKey && typedURLTimeKey.hasValue(entryName)) { + let urlTime = ""; + try { + urlTime = typedURLTimeKey.readBinaryValue(entryName); + } catch (ex) { + console.error("Couldn't read url time for ", entryName); + } + if (urlTime.length == 8) { + let urlTimeHex = []; + for (let i = 0; i < 8; i++) { + let c = urlTime.charCodeAt(i).toString(16); + if (c.length == 1) { + c = "0" + c; + } + urlTimeHex.unshift(c); + } + try { + let hi = parseInt(urlTimeHex.slice(0, 4).join(""), 16); + let lo = parseInt(urlTimeHex.slice(4, 8).join(""), 16); + // Convert to seconds since epoch: + let secondsSinceEpoch = cTypes.fileTimeToSecondsSinceEpoch(hi, lo); + + // If the date is very far in the past, just use the default + if (secondsSinceEpoch > Date.now() / 1000000) { + // Callers expect PRTime, which is microseconds since epoch: + timeTyped = secondsSinceEpoch * 1000; + } + } catch (ex) { + // Ignore conversion exceptions. Callers will have to deal + // with the fallback value. + } + } + } + typedURLs.set(url, timeTyped * 1000); + } + } catch (ex) { + console.error("Error reading typed URL history: ", ex); + } finally { + if (typedURLKey) { + typedURLKey.close(); + } + if (typedURLTimeKey) { + typedURLTimeKey.close(); + } + cTypes.finalize(); + } + return typedURLs; +} + +// Migrator for form passwords on Windows 8 and higher. +function WindowsVaultFormPasswords() {} + +WindowsVaultFormPasswords.prototype = { + type: MigrationUtils.resourceTypes.PASSWORDS, + + get exists() { + // work only on windows 8+ + if (AppConstants.isPlatformAndVersionAtLeast("win", "6.2")) { + // check if there are passwords available for migration. + return this.migrate(() => {}, true); + } + return false; + }, + + /** + * If aOnlyCheckExists is false, import the form passwords on Windows 8 and higher from the vault + * and then call the aCallback. + * Otherwise, check if there are passwords in the vault. + * + * @param {Function} aCallback - a callback called when the migration is done. + * @param {boolean} [aOnlyCheckExists=false] - if aOnlyCheckExists is true, just check if there are some + * passwords to migrate. Import the passwords from the vault and call aCallback otherwise. + * @returns {boolean} true if there are passwords in the vault and aOnlyCheckExists is set to true, + * false if there is no password in the vault and aOnlyCheckExists is set to true, undefined if + * aOnlyCheckExists is set to false. + */ + async migrate(aCallback, aOnlyCheckExists = false) { + // check if the vault item is an IE/Edge one + function _isIEOrEdgePassword(id) { + return ( + id[0] == INTERNET_EXPLORER_EDGE_GUID[0] && + id[1] == INTERNET_EXPLORER_EDGE_GUID[1] && + id[2] == INTERNET_EXPLORER_EDGE_GUID[2] && + id[3] == INTERNET_EXPLORER_EDGE_GUID[3] + ); + } + + let ctypesVaultHelpers = new CtypesVaultHelpers(); + let ctypesKernelHelpers = new CtypesKernelHelpers(); + let migrationSucceeded = true; + let successfulVaultOpen = false; + let error, vault; + try { + // web credentials vault id + let vaultGuid = new ctypesVaultHelpers._structs.GUID( + WEB_CREDENTIALS_VAULT_ID + ); + error = new wintypes.DWORD(); + // web credentials vault + vault = new wintypes.VOIDP(); + // open the current vault using the vaultGuid + error = ctypesVaultHelpers._functions.VaultOpenVault( + vaultGuid.address(), + 0, + vault.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to open Vault: " + error); + } + successfulVaultOpen = true; + + let item = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + let itemCount = new wintypes.DWORD(); + // enumerate all the available items. This api is going to return a table of all the + // available items and item is going to point to the first element of this table. + error = ctypesVaultHelpers._functions.VaultEnumerateItems( + vault, + VAULT_ENUMERATE_ALL_ITEMS, + itemCount.address(), + item.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to enumerate Vault items: " + error); + } + + let logins = []; + for (let j = 0; j < itemCount.value; j++) { + try { + // if it's not an ie/edge password, skip it + if (!_isIEOrEdgePassword(item.contents.schemaId.id)) { + continue; + } + let url = + item.contents.pResourceElement.contents.itemValue.readString(); + let realURL; + try { + realURL = Services.io.newURI(url); + } catch (ex) { + /* leave realURL as null */ + } + if (!realURL || !["http", "https", "ftp"].includes(realURL.scheme)) { + // Ignore items for non-URLs or URLs that aren't HTTP(S)/FTP + continue; + } + + // if aOnlyCheckExists is set to true, the purpose of the call is to return true if there is at + // least a password which is true in this case because a password was by now already found + if (aOnlyCheckExists) { + return true; + } + let username = + item.contents.pIdentityElement.contents.itemValue.readString(); + // the current login credential object + let credential = new ctypesVaultHelpers._structs.VAULT_ELEMENT.ptr(); + error = ctypesVaultHelpers._functions.VaultGetItem( + vault, + item.contents.schemaId.address(), + item.contents.pResourceElement, + item.contents.pIdentityElement, + null, + 0, + 0, + credential.address() + ); + if (error != RESULT_SUCCESS) { + throw new Error("Unable to get item: " + error); + } + + let password = + credential.contents.pAuthenticatorElement.contents.itemValue.readString(); + let creation = Date.now(); + try { + // login manager wants time in milliseconds since epoch, so convert + // to seconds since epoch and multiply to get milliseconds: + creation = + ctypesKernelHelpers.fileTimeToSecondsSinceEpoch( + item.contents.highLastModified, + item.contents.lowLastModified + ) * 1000; + } catch (ex) { + // Ignore exceptions in the dates and just create the login for right now. + } + // create a new login + logins.push({ + username, + password, + origin: realURL.prePath, + timeCreated: creation, + }); + + // close current item + error = ctypesVaultHelpers._functions.VaultFree(credential); + if (error == FREE_CLOSE_FAILED) { + throw new Error("Unable to free item: " + error); + } + } catch (e) { + migrationSucceeded = false; + console.error(e); + } finally { + // move to next item in the table returned by VaultEnumerateItems + item = item.increment(); + } + } + + if (logins.length) { + await MigrationUtils.insertLoginsWrapper(logins); + } + } catch (e) { + console.error(e); + migrationSucceeded = false; + } finally { + if (successfulVaultOpen) { + // close current vault + error = ctypesVaultHelpers._functions.VaultCloseVault(vault); + if (error == FREE_CLOSE_FAILED) { + console.error("Unable to close vault: ", error); + } + } + ctypesKernelHelpers.finalize(); + ctypesVaultHelpers.finalize(); + aCallback(migrationSucceeded); + } + if (aOnlyCheckExists) { + return false; + } + return undefined; + }, +}; + +export var MSMigrationUtils = { + MIGRATION_TYPE_IE: 1, + MIGRATION_TYPE_EDGE: 2, + CtypesKernelHelpers, + getBookmarksMigrator(migrationType = this.MIGRATION_TYPE_IE) { + return new Bookmarks(migrationType); + }, + getWindowsVaultFormPasswordsMigrator() { + return new WindowsVaultFormPasswords(); + }, + getTypedURLs, + getEdgeLocalDataFolder, +}; diff --git a/browser/components/migration/MigrationUtils.sys.mjs b/browser/components/migration/MigrationUtils.sys.mjs new file mode 100644 index 0000000000..57a957d527 --- /dev/null +++ b/browser/components/migration/MigrationUtils.sys.mjs @@ -0,0 +1,1171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", + WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +var gMigrators = null; +var gFileMigrators = null; +var gProfileStartup = null; +var gL10n = null; +var gPreviousDefaultBrowserKey = ""; + +let gForceExitSpinResolve = false; +let gKeepUndoData = false; +let gUndoData = null; + +function getL10n() { + if (!gL10n) { + gL10n = new Localization(["browser/migration.ftl"]); + } + return gL10n; +} + +const MIGRATOR_MODULES = Object.freeze({ + EdgeProfileMigrator: { + moduleURI: "resource:///modules/EdgeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + FirefoxProfileMigrator: { + moduleURI: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + IEProfileMigrator: { + moduleURI: "resource:///modules/IEProfileMigrator.sys.mjs", + platforms: ["win"], + }, + SafariProfileMigrator: { + moduleURI: "resource:///modules/SafariProfileMigrator.sys.mjs", + platforms: ["macosx"], + }, + + // The following migrators are all variants of the ChromeProfileMigrator + + BraveProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + CanaryProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromeProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + ChromeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "win"], + }, + ChromeDevMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux"], + }, + ChromiumProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + Chromium360seMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["win"], + }, + ChromiumEdgeMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + ChromiumEdgeBetaMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + OperaProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + VivaldiProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, + OperaGXProfileMigrator: { + moduleURI: "resource:///modules/ChromeProfileMigrator.sys.mjs", + platforms: ["macosx", "win"], + }, + + InternalTestingProfileMigrator: { + moduleURI: "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + platforms: ["linux", "macosx", "win"], + }, +}); + +const FILE_MIGRATOR_MODULES = Object.freeze({ + PasswordFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, + BookmarksFileMigrator: { + moduleURI: "resource:///modules/FileMigrators.sys.mjs", + }, +}); + +/** + * The singleton MigrationUtils service. This service is the primary mechanism + * by which migrations from other browsers to this browser occur. The singleton + * instance of this class is exported from this module as `MigrationUtils`. + */ +class MigrationUtils { + constructor() { + XPCOMUtils.defineLazyPreferenceGetter( + this, + "HISTORY_MAX_AGE_IN_DAYS", + "browser.migrate.history.maxAgeInDays", + 180 + ); + } + + resourceTypes = Object.freeze({ + ALL: 0x0000, + /* 0x01 used to be used for settings, but was removed. */ + COOKIES: 0x0002, + HISTORY: 0x0004, + FORMDATA: 0x0008, + PASSWORDS: 0x0010, + BOOKMARKS: 0x0020, + OTHERDATA: 0x0040, + SESSION: 0x0080, + PAYMENT_METHODS: 0x0100, + }); + + /** + * Helper for implementing simple asynchronous cases of migration resources' + * |migrate(aCallback)| (see MigratorBase). If your |migrate| method + * just waits for some file to be read, for example, and then migrates + * everything right away, you can wrap the async-function with this helper + * and not worry about notifying the callback. + * + * @example + * // For example, instead of writing: + * setTimeout(function() { + * try { + * .... + * aCallback(true); + * } + * catch() { + * aCallback(false); + * } + * }, 0); + * + * // You may write: + * setTimeout(MigrationUtils.wrapMigrateFunction(function() { + * if (importingFromMosaic) + * throw Cr.NS_ERROR_UNEXPECTED; + * }, aCallback), 0); + * + * // ... and aCallback will be called with aSuccess=false when importing + * // from Mosaic, or with aSuccess=true otherwise. + * + * @param {Function} aFunction + * the function that will be called sometime later. If aFunction + * throws when it's called, aCallback(false) is called, otherwise + * aCallback(true) is called. + * @param {Function} aCallback + * the callback function passed to |migrate|. + * @returns {Function} + * the wrapped function. + */ + wrapMigrateFunction(aFunction, aCallback) { + return function () { + let success = false; + try { + aFunction.apply(null, arguments); + success = true; + } catch (ex) { + console.error(ex); + } + // Do not change this to call aCallback directly in try try & catch + // blocks, because if aCallback throws, we may end up calling aCallback + // twice. + aCallback(success); + }; + } + + /** + * Gets localized string corresponding to l10n-id + * + * @param {string} aKey + * The key of the id of the localization to retrieve. + * @param {object} [aArgs=undefined] + * An optional map of arguments to the id. + * @returns {Promise} + * A promise that resolves to the retrieved localization. + */ + getLocalizedString(aKey, aArgs) { + let l10n = getL10n(); + return l10n.formatValue(aKey, aArgs); + } + + /** + * Get all the rows corresponding to a select query from a database, without + * requiring a lock on the database. If fetching data fails (because someone + * else tried to write to the DB at the same time, for example), we will + * retry the fetch after a 100ms timeout, up to 10 times. + * + * @param {string} path + * The file path to the database we want to open. + * @param {string} description + * A developer-readable string identifying what kind of database we're + * trying to open. + * @param {string} selectQuery + * The SELECT query to use to fetch the rows. + * @param {Promise} [testDelayPromise] + * An optional promise to await for after the first loop, used in tests. + * + * @returns {Promise} + * 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, + testDelayPromise = null + ) { + let dbOptions = { + readOnly: true, + ignoreLockingMode: true, + path, + }; + + const RETRYLIMIT = 10; + const RETRYINTERVAL = 100; + return (async function innerGetRows() { + let rows = null; + for (let retryCount = RETRYLIMIT; retryCount; 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 previousExceptionMessage = null; + try { + db = await lazy.Sqlite.openConnection(dbOptions); + didOpen = true; + rows = await db.execute(selectQuery); + break; + } catch (ex) { + if (previousExceptionMessage != ex.message) { + console.error(ex); + } + previousExceptionMessage = ex.message; + if (ex.name == "NS_ERROR_FILE_CORRUPTED") { + break; + } + } finally { + try { + if (didOpen) { + await db.close(); + } + } catch (ex) {} + } + await Promise.all([ + new Promise(resolve => lazy.setTimeout(resolve, RETRYINTERVAL)), + testDelayPromise, + ]); + } + if (!rows) { + throw new Error( + "Couldn't get rows from the " + description + " database." + ); + } + return rows; + })(); + } + + get #migrators() { + if (!gMigrators) { + gMigrators = new Map(); + for (let [symbol, { moduleURI, platforms }] of Object.entries( + MIGRATOR_MODULES + )) { + if (platforms.includes(AppConstants.platform)) { + let { [symbol]: migratorClass } = + ChromeUtils.importESModule(moduleURI); + if (gMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gMigrators.set(migratorClass.key, new migratorClass()); + } + } + } + return gMigrators; + } + + get #fileMigrators() { + if (!gFileMigrators) { + gFileMigrators = new Map(); + for (let [symbol, { moduleURI }] of Object.entries( + FILE_MIGRATOR_MODULES + )) { + let { [symbol]: migratorClass } = ChromeUtils.importESModule(moduleURI); + if (gFileMigrators.has(migratorClass.key)) { + console.error( + "A pre-existing file migrator exists with key " + + `${migratorClass.key}. Not registering.` + ); + continue; + } + gFileMigrators.set(migratorClass.key, new migratorClass()); + } + } + return gFileMigrators; + } + + forceExitSpinResolve() { + gForceExitSpinResolve = true; + } + + spinResolve(promise) { + if (!(promise instanceof Promise)) { + return promise; + } + let done = false; + let result = null; + let error = null; + gForceExitSpinResolve = false; + promise + .catch(e => { + error = e; + }) + .then(r => { + result = r; + done = true; + }); + + Services.tm.spinEventLoopUntil( + "MigrationUtils.jsm:MU_spinResolve", + () => done || gForceExitSpinResolve + ); + if (!done) { + throw new Error("Forcefully exited event loop."); + } else if (error) { + throw error; + } else { + return result; + } + } + + /** + * Returns the migrator for the given source, if any data is available + * for this source, or null otherwise. + * + * If null is returned, either no data can be imported for the given migrator, + * or aMigratorKey is invalid (e.g. ie on mac, or mosaic everywhere). This + * method should be used rather than direct getService for future compatibility + * (see bug 718280). + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * + * @returns {MigratorBase} + * A profile migrator implementing nsIBrowserProfileMigrator, if it can + * import any data, null otherwise. + */ + async getMigrator(aKey) { + let migrator = this.#migrators.get(aKey); + if (!migrator) { + console.error(`Could not find a migrator class for key ${aKey}`); + return null; + } + + try { + return migrator && (await migrator.isSourceAvailable()) ? migrator : null; + } catch (ex) { + console.error(ex); + return null; + } + } + + getFileMigrator(aKey) { + let migrator = this.#fileMigrators.get(aKey); + if (!migrator) { + console.error(`Could not find a file migrator class for key ${aKey}`); + return null; + } + return migrator; + } + + /** + * Returns true if a migrator is registered with key aKey. No check is made + * to determine if a profile exists that the migrator can migrate from. + * + * @param {string} aKey + * Internal name of the migration source. See `availableMigratorKeys` + * for supported values by OS. + * @returns {boolean} + */ + migratorExists(aKey) { + return this.#migrators.has(aKey); + } + + /** + * Figure out what is the default browser, and if there is a migrator + * for it, return that migrator's internal name. + * + * For the time being, the "internal name" of a migrator is its contract-id + * trailer (e.g. ie for @mozilla.org/profile/migrator;1?app=browser&type=ie), + * but it will soon be exposed properly. + * + * @returns {string} + */ + getMigratorKeyForDefaultBrowser() { + // Canary uses the same description as Chrome so we can't distinguish them. + // Edge Beta on macOS uses "Microsoft Edge" with no "beta" indication. + const APP_DESC_TO_KEY = { + "Internet Explorer": "ie", + "Microsoft Edge": "edge", + Safari: "safari", + Firefox: "firefox", + Nightly: "firefox", + Opera: "opera", + Vivaldi: "vivaldi", + "Opera GX": "opera-gx", + "Brave Web Browser": "brave", // Windows, Linux + Brave: "brave", // OS X + "Google Chrome": "chrome", // Windows, Linux + Chrome: "chrome", // OS X + Chromium: "chromium", // Windows, OS X + "Chromium Web Browser": "chromium", // Linux + "360\u5b89\u5168\u6d4f\u89c8\u5668": "chromium-360se", + }; + + let key = ""; + try { + let browserDesc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService) + .getApplicationDescription("http"); + key = APP_DESC_TO_KEY[browserDesc] || ""; + // Handle devedition, as well as "FirefoxNightly" on OS X. + if (!key && browserDesc.startsWith("Firefox")) { + key = "firefox"; + } + } catch (ex) { + console.error("Could not detect default browser: ", ex); + } + + // "firefox" is the least useful entry here, and might just be because we've set + // ourselves as the default (on Windows 7 and below). In that case, check if we + // have a registry key that tells us where to go: + if ( + key == "firefox" && + AppConstants.isPlatformAndVersionAtMost("win", "6.2") + ) { + // Because we remove the registry key, reading the registry key only works once. + // We save the value for subsequent calls to avoid hard-to-trace bugs when multiple + // consumers ask for this key. + if (gPreviousDefaultBrowserKey) { + key = gPreviousDefaultBrowserKey; + } else { + // We didn't have a saved value, so check the registry. + const kRegPath = "Software\\Mozilla\\Firefox"; + let oldDefault = lazy.WindowsRegistry.readRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kRegPath, + "OldDefaultBrowserCommand" + ); + if (oldDefault) { + // Remove the key: + lazy.WindowsRegistry.removeRegKey( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + kRegPath, + "OldDefaultBrowserCommand" + ); + try { + let file = Cc["@mozilla.org/file/local;1"].createInstance( + Ci.nsILocalFileWin + ); + file.initWithCommandLine(oldDefault); + key = + APP_DESC_TO_KEY[file.getVersionInfoField("FileDescription")] || + key; + // Save the value for future callers. + gPreviousDefaultBrowserKey = key; + } catch (ex) { + console.error( + "Could not convert old default browser value to description." + ); + } + } + } + } + return key; + } + + /** + * True if we're in the process of a startup migration. + * + * @type {boolean} + */ + get isStartupMigration() { + return gProfileStartup != null; + } + + /** + * In the case of startup migration, this is set to the nsIProfileStartup + * instance passed to ProfileMigrator's migrate. + * + * @see showMigrationWizard + * @type {nsIProfileStartup|null} + */ + get profileStartup() { + return gProfileStartup; + } + + /** + * Show the migration wizard. On mac, this may just focus the wizard if it's + * already running, in which case aOpener and aOptions are ignored. + * + * NB: If you add new consumers, please add a migration entry point constant to + * MIGRATION_ENTRYPOINTS and supply that entrypoint with the entrypoint property + * in the aOptions argument. + * + * @param {Window} [aOpener=null] + * optional; the window that asks to open the wizard. + * @param {object} [aOptions=null] + * optional named arguments for the migration wizard. + * @param {string} [aOptions.entrypoint=undefined] + * migration entry point constant. See MIGRATION_ENTRYPOINTS. + * @param {string} [aOptions.migratorKey=undefined] + * The key for which migrator to use automatically. This is the key that is exposed + * as a static getter on the migrator class. + * @param {MigratorBase} [aOptions.migrator=undefined] + * A migrator instance to use automatically. + * @param {boolean} [aOptions.isStartupMigration=undefined] + * True if this is a startup migration. + * @param {boolean} [aOptions.skipSourceSelection=undefined] + * True if the source selection page of the wizard should be skipped. + * @param {string} [aOptions.profileId] + * An identifier for the profile to use when migrating. + * @returns {Promise} + * If the new content-modal migration dialog is enabled and an + * about:preferences tab can be opened, this will resolve when + * that tab has been switched to. Otherwise, this will resolve + * just after opening the dialog window. + */ + showMigrationWizard(aOpener, aOptions) { + // When migration is kicked off from about:welcome, there are + // a few different behaviors that we want to test, controlled + // by a preference that is instrumented for Nimbus. The pref + // has the following possible states: + // + // "autoclose": + // The user will be directed to the migration wizard in + // about:preferences, but once the wizard is dismissed, + // the tab will close. + // + // "standalone": + // The migration wizard will open in a new top-level content + // window. + // + // "legacy": + // The legacy migration wizard will open, even if the new migration + // wizard is enabled by default. + // + // "default" / other + // The user will be directed to the migration wizard in + // about:preferences. The tab will not close once the + // user closes the wizard. + let aboutWelcomeBehavior = Services.prefs.getCharPref( + "browser.migrate.content-modal.about-welcome-behavior", + "default" + ); + + let aboutWelcomeLegacyBehavior = + aboutWelcomeBehavior == "legacy" && + aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB; + + if ( + Services.prefs.getBoolPref( + "browser.migrate.content-modal.enabled", + false + ) && + !aOptions?.isStartupMigration && + !aboutWelcomeLegacyBehavior + ) { + let entrypoint = + aOptions.entrypoint || this.MIGRATION_ENTRYPOINTS.UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL") + .add(entrypoint); + + let openStandaloneWindow = () => { + const FEATURES = "dialog,centerscreen,resizable=no"; + const win = Services.ww.openWindow( + aOpener, + "chrome://browser/content/migration/migration-dialog-window.html", + "_blank", + FEATURES, + { + onResize: () => { + win.sizeToContent(); + }, + options: aOptions, + } + ); + return Promise.resolve(); + }; + + if (aOptions.isStartupMigration) { + openStandaloneWindow(); + return Promise.resolve(); + } + + if (aOpener?.openPreferences) { + if (aOptions.entrypoint == this.MIGRATION_ENTRYPOINTS.NEWTAB) { + if (aboutWelcomeBehavior == "autoclose") { + return aOpener.openPreferences("general-migrate-autoclose"); + } else if (aboutWelcomeBehavior == "standalone") { + openStandaloneWindow(); + return Promise.resolve(); + } + } + return aOpener.openPreferences("general-migrate"); + } + + // If somehow we failed to open about:preferences, fall back to opening + // the top-level window. + openStandaloneWindow(); + return Promise.resolve(); + } + // Legacy migration dialog + const DIALOG_URL = "chrome://browser/content/migration/migration.xhtml"; + let features = "chrome,dialog,modal,centerscreen,titlebar,resizable=no"; + if (AppConstants.platform == "macosx" && !this.isStartupMigration) { + let win = Services.wm.getMostRecentWindow("Browser:MigrationWizard"); + if (win) { + win.focus(); + return Promise.resolve(); + } + // On mac, the migration wiazrd should only be modal in the case of + // startup-migration. + features = "centerscreen,chrome,resizable=no"; + } + Services.ww.openWindow(aOpener, DIALOG_URL, "_blank", features, aOptions); + return Promise.resolve(); + } + + /** + * Show the migration wizard for startup-migration. This should only be + * called by ProfileMigrator (see ProfileMigrator.js), which implements + * nsIProfileMigrator. This runs asynchronously if we are running an + * automigration. + * + * @param {nsIProfileStartup} aProfileStartup + * the nsIProfileStartup instance provided to ProfileMigrator.migrate. + * @param {string|null} [aMigratorKey=null] + * If set, the migration wizard will import from the corresponding + * migrator, bypassing the source-selection page. Otherwise, the + * source-selection page will be displayed, either with the default + * browser selected, if it could be detected and if there is a + * migrator for it, or with the first option selected as a fallback + * (The first option is hardcoded to be the most common browser for + * the OS we run on. See migration.xhtml). + * @param {string|null} [aProfileToMigrate=null] + * If set, the migration wizard will import from the profile indicated. + * @throws + * if aMigratorKey is invalid or if it points to a non-existent + * source. + */ + startupMigration(aProfileStartup, aMigratorKey, aProfileToMigrate) { + this.spinResolve( + this.asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) + ); + } + + async asyncStartupMigration( + aProfileStartup, + aMigratorKey, + aProfileToMigrate + ) { + if (!aProfileStartup) { + throw new Error( + "an profile-startup instance is required for startup-migration" + ); + } + gProfileStartup = aProfileStartup; + + let skipSourceSelection = false, + migrator = null, + migratorKey = ""; + if (aMigratorKey) { + migrator = await this.getMigrator(aMigratorKey); + if (!migrator) { + // aMigratorKey must point to a valid source, so, if it doesn't + // cleanup and throw. + this.finishMigration(); + throw new Error( + "startMigration was asked to open auto-migrate from " + + "a non-existent source: " + + aMigratorKey + ); + } + migratorKey = aMigratorKey; + skipSourceSelection = true; + } else { + let defaultBrowserKey = this.getMigratorKeyForDefaultBrowser(); + if (defaultBrowserKey) { + migrator = await this.getMigrator(defaultBrowserKey); + if (migrator) { + migratorKey = defaultBrowserKey; + } + } + } + + if (!migrator) { + let migrators = await Promise.all( + this.availableMigratorKeys.map(key => this.getMigrator(key)) + ); + // If there's no migrator set so far, ensure that there is at least one + // migrator available before opening the wizard. + // Note that we don't need to check the default browser first, because + // if that one existed we would have used it in the block above this one. + if (!migrators.some(m => m)) { + // None of the keys produced a usable migrator, so finish up here: + this.finishMigration(); + return; + } + } + + let isRefresh = + migrator && + skipSourceSelection && + migratorKey == AppConstants.MOZ_APP_NAME; + + let entrypoint = this.MIGRATION_ENTRYPOINTS.FIRSTRUN; + if (isRefresh) { + entrypoint = this.MIGRATION_ENTRYPOINTS.FXREFRESH; + } + + this.showMigrationWizard(null, { + entrypoint, + migratorKey, + migrator, + isStartupMigration: !!aProfileStartup, + skipSourceSelection, + profileId: aProfileToMigrate, + }); + } + + /** + * This is only pseudo-private because some tests and helper functions + * still expect to be able to directly access it. + */ + _importQuantities = { + bookmarks: 0, + logins: 0, + history: 0, + cards: 0, + }; + + getImportedCount(type) { + if (!this._importQuantities.hasOwnProperty(type)) { + throw new Error( + `Unknown import data type "${type}" passed to getImportedCount` + ); + } + return this._importQuantities[type]; + } + + insertBookmarkWrapper(bookmark) { + this._importQuantities.bookmarks++; + let insertionPromise = lazy.PlacesUtils.bookmarks.insert(bookmark); + if (!gKeepUndoData) { + return insertionPromise; + } + // If we keep undo data, add a promise handler that stores the undo data once + // the bookmark has been inserted in the DB, and then returns the bookmark. + let { parentGuid } = bookmark; + return insertionPromise.then(bm => { + let { guid, lastModified, type } = bm; + gUndoData.get("bookmarks").push({ + parentGuid, + guid, + lastModified, + type, + }); + return bm; + }); + } + + insertManyBookmarksWrapper(bookmarks, parent) { + let insertionPromise = lazy.PlacesUtils.bookmarks.insertTree({ + guid: parent, + children: bookmarks, + }); + return insertionPromise.then( + insertedItems => { + this._importQuantities.bookmarks += insertedItems.length; + if (gKeepUndoData) { + let bmData = gUndoData.get("bookmarks"); + for (let bm of insertedItems) { + let { parentGuid, guid, lastModified, type } = bm; + bmData.push({ parentGuid, guid, lastModified, type }); + } + } + if (parent == lazy.PlacesUtils.bookmarks.toolbarGuid) { + lazy.PlacesUIUtils.maybeToggleBookmarkToolbarVisibility( + true /* aForceVisible */ + ).catch(console.error); + } + }, + ex => console.error(ex) + ); + } + + insertVisitsWrapper(pageInfos) { + let now = new Date(); + // Ensure that none of the dates are in the future. If they are, rewrite + // them to be now. This means we don't loose history entries, but they will + // be valid for the history store. + for (let pageInfo of pageInfos) { + for (let visit of pageInfo.visits) { + if (visit.date && visit.date > now) { + visit.date = now; + } + } + } + this._importQuantities.history += pageInfos.length; + if (gKeepUndoData) { + this.#updateHistoryUndo(pageInfos); + } + return lazy.PlacesUtils.history.insertMany(pageInfos); + } + + async insertLoginsWrapper(logins) { + this._importQuantities.logins += logins.length; + let inserted = await lazy.LoginHelper.maybeImportLogins(logins); + // Note that this means that if we import a login that has a newer password + // than we know about, we will update the login, and an undo of the import + // will not revert this. This seems preferable over removing the login + // outright or storing the old password in the undo file. + if (gKeepUndoData) { + for (let { guid, timePasswordChanged } of inserted) { + gUndoData.get("logins").push({ guid, timePasswordChanged }); + } + } + } + + /** + * Iterates through the favicons, sniffs for a mime type, + * and uses the mime type to properly import the favicon. + * + * @param {object[]} favicons + * An array of Objects with these properties: + * {Uint8Array} faviconData: The binary data of a favicon + * {nsIURI} uri: The URI of the associated page + */ + insertManyFavicons(favicons) { + let sniffer = Cc["@mozilla.org/image/loader;1"].createInstance( + Ci.nsIContentSniffer + ); + for (let faviconDataItem of favicons) { + let mimeType = sniffer.getMIMETypeFromContent( + null, + faviconDataItem.faviconData, + faviconDataItem.faviconData.length + ); + let fakeFaviconURI = Services.io.newURI( + "fake-favicon-uri:" + faviconDataItem.uri.spec + ); + lazy.PlacesUtils.favicons.replaceFaviconData( + fakeFaviconURI, + faviconDataItem.faviconData, + mimeType + ); + lazy.PlacesUtils.favicons.setAndFetchFaviconForPage( + faviconDataItem.uri, + fakeFaviconURI, + true, + lazy.PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE, + null, + Services.scriptSecurityManager.getSystemPrincipal() + ); + } + } + + async insertCreditCardsWrapper(cards) { + this._importQuantities.cards += cards.length; + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + + await formAutofillStorage.initialize(); + for (let card of cards) { + try { + await formAutofillStorage.creditCards.add(card); + } catch (e) { + console.error("Failed to insert credit card due to error: ", e, card); + } + } + } + + initializeUndoData() { + gKeepUndoData = true; + gUndoData = new Map([ + ["bookmarks", []], + ["visits", []], + ["logins", []], + ]); + } + + async #postProcessUndoData(state) { + if (!state) { + return state; + } + let bookmarkFolders = state + .get("bookmarks") + .filter(b => b.type == lazy.PlacesUtils.bookmarks.TYPE_FOLDER); + + let bookmarkFolderData = []; + let bmPromises = bookmarkFolders.map(({ guid }) => { + // Ignore bookmarks where the promise doesn't resolve (ie that are missing) + // Also check that the bookmark fetch returns isn't null before adding it. + return lazy.PlacesUtils.bookmarks.fetch(guid).then( + bm => bm && bookmarkFolderData.push(bm), + () => {} + ); + }); + + await Promise.all(bmPromises); + let folderLMMap = new Map( + bookmarkFolderData.map(b => [b.guid, b.lastModified]) + ); + for (let bookmark of bookmarkFolders) { + let lastModified = folderLMMap.get(bookmark.guid); + // If the bookmark was deleted, the map will be returning null, so check: + if (lastModified) { + bookmark.lastModified = lastModified; + } + } + return state; + } + + stopAndRetrieveUndoData() { + let undoData = gUndoData; + gUndoData = null; + gKeepUndoData = false; + return this.#postProcessUndoData(undoData); + } + + #updateHistoryUndo(pageInfos) { + let visits = gUndoData.get("visits"); + let visitMap = new Map(visits.map(v => [v.url, v])); + for (let pageInfo of pageInfos) { + let visitCount = pageInfo.visits.length; + let first, last; + if (visitCount > 1) { + let dates = pageInfo.visits.map(v => v.date); + first = Math.min.apply(Math, dates); + last = Math.max.apply(Math, dates); + } else { + first = last = pageInfo.visits[0].date; + } + let url = pageInfo.url; + if (url instanceof Ci.nsIURI) { + url = pageInfo.url.spec; + } else if (typeof url != "string") { + pageInfo.url.href; + } + + try { + new URL(url); + } catch (ex) { + // This won't save and we won't need to 'undo' it, so ignore this URL. + continue; + } + if (!visitMap.has(url)) { + visitMap.set(url, { url, visitCount, first, last }); + } else { + let currentData = visitMap.get(url); + currentData.visitCount += visitCount; + currentData.first = Math.min(currentData.first, first); + currentData.last = Math.max(currentData.last, last); + } + } + gUndoData.set("visits", Array.from(visitMap.values())); + } + + /** + * Cleans up references to migrators and nsIProfileInstance instances. + */ + finishMigration() { + gMigrators = null; + gProfileStartup = null; + gL10n = null; + } + + get availableMigratorKeys() { + return [...this.#migrators.keys()]; + } + + get availableFileMigrators() { + return [...this.#fileMigrators.values()]; + } + + /** + * Enum for the entrypoint that is being used to start migration. + * Callers can use the MIGRATION_ENTRYPOINTS getter to use these. + * + * These values are what's written into the FX_MIGRATION_ENTRY_POINT + * histogram after a migration. + * + * @see MIGRATION_ENTRYPOINTS + * @readonly + * @enum {number} + */ + #MIGRATION_ENTRYPOINTS_ENUM = Object.freeze({ + /** The entrypoint was not supplied */ + UNKNOWN: "unknown", + + /** Migration is occurring at startup */ + FIRSTRUN: "firstrun", + + /** Migration is occurring at after a profile refresh */ + FXREFRESH: "fxrefresh", + + /** Migration is being started from the Library window */ + PLACES: "places", + + /** Migration is being started from our password management UI */ + PASSWORDS: "passwords", + + /** Migration is being started from the default about:home/about:newtab */ + NEWTAB: "newtab", + + /** Migration is being started from the File menu */ + FILE_MENU: "file_menu", + + /** Migration is being started from the Help menu */ + HELP_MENU: "help_menu", + + /** Migration is being started from the Bookmarks Toolbar */ + BOOKMARKS_TOOLBAR: "bookmarks_toolbar", + + /** Migration is being started from about:preferences */ + PREFERENCES: "preferences", + }); + + /** + * Returns an enum that should be used to record the entrypoint for + * starting a migration. + * + * @returns {number} + */ + get MIGRATION_ENTRYPOINTS() { + return this.#MIGRATION_ENTRYPOINTS_ENUM; + } + + /** + * Translates an entrypoint string into the proper numeric value for the legacy + * FX_MIGRATION_ENTRY_POINT histogram. + * + * @param {string} entrypoint + * The entrypoint to translate from MIGRATION_ENTRYPOINTS. + * @returns {number} + * The numeric value for the legacy FX_MIGRATION_ENTRY_POINT histogram. + */ + getLegacyMigrationEntrypoint(entrypoint) { + switch (entrypoint) { + case this.MIGRATION_ENTRYPOINTS.FIRSTRUN: { + return 1; + } + case this.MIGRATION_ENTRYPOINTS.FXREFRESH: { + return 2; + } + case this.MIGRATION_ENTRYPOINTS.PLACES: { + return 3; + } + case this.MIGRATION_ENTRYPOINTS.PASSWORDS: { + return 4; + } + case this.MIGRATION_ENTRYPOINTS.NEWTAB: { + return 5; + } + case this.MIGRATION_ENTRYPOINTS.FILE_MENU: { + return 6; + } + case this.MIGRATION_ENTRYPOINTS.HELP_MENU: { + return 7; + } + case this.MIGRATION_ENTRYPOINTS.BOOKMARKS_TOOLBAR: { + return 8; + } + case this.MIGRATION_ENTRYPOINTS.PREFERENCES: { + return 9; + } + case this.MIGRATION_ENTRYPOINTS.UNKNOWN: + // Intentional fall-through + default: { + return 0; // Unknown + } + } + } + + /** + * Enum for the numeric value written to the FX_MIGRATION_SOURCE_BROWSER, + * and FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER histograms. + * + * @see getSourceIdForTelemetry + * @readonly + * @enum {number} + */ + #SOURCE_NAME_TO_ID_MAPPING_ENUM = Object.freeze({ + nothing: 1, + firefox: 2, + edge: 3, + ie: 4, + chrome: 5, + "chrome-beta": 5, + "chrome-dev": 5, + chromium: 6, + canary: 7, + safari: 8, + "chromium-360se": 9, + "chromium-edge": 10, + "chromium-edge-beta": 10, + brave: 11, + opera: 12, + "opera-gx": 14, + vivaldi: 13, + }); + + getSourceIdForTelemetry(sourceName) { + return this.#SOURCE_NAME_TO_ID_MAPPING_ENUM[sourceName] || 0; + } + + get HISTORY_MAX_AGE_IN_MILLISECONDS() { + return this.HISTORY_MAX_AGE_IN_DAYS * 24 * 60 * 60 * 1000; + } +} + +const MigrationUtilsSingleton = new MigrationUtils(); + +export { MigrationUtilsSingleton as MigrationUtils }; diff --git a/browser/components/migration/MigrationWizardChild.sys.mjs b/browser/components/migration/MigrationWizardChild.sys.mjs new file mode 100644 index 0000000000..a3894f7e92 --- /dev/null +++ b/browser/components/migration/MigrationWizardChild.sys.mjs @@ -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/. */ + +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "SHOW_IMPORT_ALL_PREF", + "browser.migrate.content-modal.import-all.enabled", + false +); + +/** + * This class is responsible for updating the state of a + * component, and for listening for events from that component to perform + * various migration functions. + */ +export class MigrationWizardChild extends JSWindowActorChild { + #wizardEl = null; + + /** + * General event handler function for events dispatched from the + * component. + * + * @param {Event} event + * The DOM event being handled. + * @returns {Promise} + */ + async handleEvent(event) { + switch (event.type) { + case "MigrationWizard:RequestState": { + this.#sendTelemetryEvent("opened"); + + this.#wizardEl = event.target; + this.setComponentState({ + page: MigrationWizardConstants.PAGES.LOADING, + }); + + let migrators = await this.sendQuery("GetAvailableMigrators"); + let hasBrowserMigrators = migrators.some(migrator => { + return ( + migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ); + }); + let hasFileMigrators = migrators.some(migrator => { + return migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE; + }); + if (!hasBrowserMigrators && !event.detail?.allowOnlyFileMigrators) { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND, + hasFileMigrators, + }); + this.#sendTelemetryEvent("no_browsers_found"); + } else { + this.setComponentState({ + migrators, + page: MigrationWizardConstants.PAGES.SELECTION, + showImportAll: lazy.SHOW_IMPORT_ALL_PREF, + }); + } + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:Ready", { + bubbles: true, + }) + ); + break; + } + + case "MigrationWizard:BeginMigration": { + let extraArgs = this.#recordBeginMigrationEvent(event.detail); + + let hasPermissions = await this.sendQuery("CheckPermissions", { + key: event.detail.key, + type: event.detail.type, + }); + + if (!hasPermissions) { + if (event.detail.key == "safari") { + this.#sendTelemetryEvent("safari_perms"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PERMISSION, + }); + } else { + console.error( + `A migrator with key ${event.detail.key} needs permissions, ` + + "and no UI exists for that right now." + ); + } + return; + } + + await this.beginMigration(event.detail, extraArgs); + break; + } + + case "MigrationWizard:RequestSafariPermissions": { + let success = await this.sendQuery("RequestSafariPermissions"); + if (success) { + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + + case "MigrationWizard:SelectSafariPasswordFile": { + let path = await this.sendQuery("SelectSafariPasswordFile"); + if (path) { + event.detail.safariPasswordFilePath = path; + + let passwordResourceIndex = event.detail.resourceTypes.indexOf( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ); + event.detail.resourceTypes.splice(passwordResourceIndex, 1); + + let extraArgs = this.#constructExtraArgs(event.detail); + await this.beginMigration(event.detail, extraArgs); + } + break; + } + } + } + + /** + * Sends a message to the parent actor to record Event Telemetry. + * + * @param {string} type + * The type of event being recorded. + * @param {object} [args=null] + * Optional extra_args to supply for the event. + */ + #sendTelemetryEvent(type, args) { + this.sendAsyncMessage("RecordEvent", { type, args }); + } + + /** + * Constructs extra arguments to pass to some Event Telemetry based + * on the MigrationDetails passed up from the MigrationWizard. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #constructExtraArgs(migrationDetails) { + let extraArgs = { + migrator_key: migrationDetails.key, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + other: 0, + }; + + for (let type of migrationDetails.resourceTypes) { + switch (type) { + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + extraArgs.history = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + extraArgs.formdata = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + extraArgs.passwords = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + extraArgs.bookmarks = "1"; + break; + } + + case MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + extraArgs.payment_methods = "1"; + break; + } + + default: { + extraArgs.other++; + } + } + } + + // Event Telemetry extra arguments expect strings for every value, so + // now we coerce our "other" count into a string. + extraArgs.other = String(extraArgs.other); + return extraArgs; + } + + /** + * This migration wizard combines a lot of steps (selecting the browser, profile, + * resources, and starting the migration) into a single page. This helper method + * records Event Telemetry for each of those actions at the same time when a + * migration begins. + * + * This method returns the extra_args object that was constructed for the + * resources_selected and migration_started event so that a + * "migration_finished" event can use the same extra_args without + * regenerating it. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @returns {object} + */ + #recordBeginMigrationEvent(migrationDetails) { + this.#sendTelemetryEvent("browser_selected", { + migrator_key: migrationDetails.key, + }); + + if (migrationDetails.profile) { + this.#sendTelemetryEvent("profile_selected", { + migrator_key: migrationDetails.key, + }); + } + + let extraArgs = this.#constructExtraArgs(migrationDetails); + + extraArgs.configured = String(Number(migrationDetails.expandedDetails)); + this.#sendTelemetryEvent("resources_selected", extraArgs); + delete extraArgs.configured; + + this.#sendTelemetryEvent("migration_started", extraArgs); + return extraArgs; + } + + /** + * Sends a message to the parent actor to attempt a migration. + * + * See migration-wizard.mjs for a definition of MigrationDetails. + * + * @param {object} migrationDetails + * A MigrationDetails object. + * @param {object} extraArgs + * Extra argument object to pass to the Event Telemetry for finishing + * the migration. + * @returns {Promise} + * Returns a Promise that resolves after the parent responds to the migration + * message. + */ + async beginMigration(migrationDetails, extraArgs) { + if ( + migrationDetails.key == "safari" && + migrationDetails.resourceTypes.includes( + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ) && + !migrationDetails.safariPasswordFilePath + ) { + this.#sendTelemetryEvent("safari_password_file"); + this.setComponentState({ + page: MigrationWizardConstants.PAGES.SAFARI_PASSWORD_PERMISSION, + }); + return; + } + + await this.sendQuery("Migrate", migrationDetails); + this.#sendTelemetryEvent("migration_finished", extraArgs); + + this.#wizardEl.dispatchEvent( + new this.contentWindow.CustomEvent("MigrationWizard:DoneMigration", { + bubbles: true, + }) + ); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardParent JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardParent. + */ + receiveMessage(message) { + switch (message.name) { + case "UpdateProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.PROGRESS, + progress: message.data.progress, + key: message.data.key, + }); + break; + } + case "UpdateFileImportProgress": { + this.setComponentState({ + page: MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS, + progress: message.data.progress, + title: message.data.title, + }); + break; + } + } + } + + /** + * Calls the `setState` method on the component. The + * state is cloned into the execution scope of this.#wizardEl. + * + * @param {object} state The state object that a + * component expects. See the documentation for the element's setState + * method for more details. + */ + setComponentState(state) { + if (!this.#wizardEl) { + return; + } + // We waive XrayWrappers in the event that the element is embedded in + // a document without system privileges, like about:welcome. + Cu.waiveXrays(this.#wizardEl).setState( + Cu.cloneInto( + state, + // ownerGlobal doesn't exist in content windows. + // eslint-disable-next-line mozilla/use-ownerGlobal + this.#wizardEl.ownerDocument.defaultView + ) + ); + } +} diff --git a/browser/components/migration/MigrationWizardParent.sys.mjs b/browser/components/migration/MigrationWizardParent.sys.mjs new file mode 100644 index 0000000000..488f29b47a --- /dev/null +++ b/browser/components/migration/MigrationWizardParent.sys.mjs @@ -0,0 +1,651 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { E10SUtils } from "resource://gre/modules/E10SUtils.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "gFluentStrings", function () { + return new Localization([ + "branding/brand.ftl", + "browser/migrationWizard.ftl", + ]); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + InternalTestingProfileMigrator: + "resource:///modules/InternalTestingProfileMigrator.sys.mjs", + MigrationWizardConstants: + "chrome://browser/content/migration/migration-wizard-constants.mjs", + PasswordFileMigrator: "resource:///modules/FileMigrators.sys.mjs", +}); + +if (AppConstants.platform == "macosx") { + ChromeUtils.defineESModuleGetters(lazy, { + SafariProfileMigrator: "resource:///modules/SafariProfileMigrator.sys.mjs", + }); +} + +XPCOMUtils.defineLazyModuleGetters(lazy, { + LoginCSVImport: "resource://gre/modules/LoginCSVImport.jsm", +}); + +/** + * This class is responsible for communicating with MigrationUtils to do the + * actual heavy-lifting of any kinds of migration work, based on messages from + * the associated MigrationWizardChild. + */ +export class MigrationWizardParent extends JSWindowActorParent { + constructor() { + super(); + Services.telemetry.setEventRecordingEnabled("browser.migration", true); + } + + didDestroy() { + Services.obs.notifyObservers(this, "MigrationWizard:Destroyed"); + } + + /** + * General message handler function for messages received from the + * associated MigrationWizardChild JSWindowActor. + * + * @param {ReceiveMessageArgument} message + * The message received from the MigrationWizardChild. + * @returns {Promise} + */ + async receiveMessage(message) { + // Some belt-and-suspenders here, mainly because the migration-wizard + // component can be embedded in less privileged content pages, so let's + // make sure that any messages from content are coming from the privileged + // about content process type. + if ( + !this.browsingContext.currentWindowGlobal.isInProcess && + this.browsingContext.currentRemoteType != + E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE + ) { + throw new Error( + "MigrationWizardParent: received message from the wrong content process type." + ); + } + + switch (message.name) { + case "GetAvailableMigrators": { + let availableMigrators = []; + for (const key of MigrationUtils.availableMigratorKeys) { + availableMigrators.push(this.#getMigratorAndProfiles(key)); + } + + // Wait for all getMigrator calls to resolve in parallel + let results = await Promise.all(availableMigrators); + + for (const migrator of MigrationUtils.availableFileMigrators.values()) { + results.push(await this.#serializeFileMigrator(migrator)); + } + + // Each migrator might give us a single MigratorProfileInstance, + // or an Array of them, so we flatten them out and filter out + // any that ended up going wrong and returning null from the + // #getMigratorAndProfiles call. + let filteredResults = results + .flat() + .filter(result => result) + .sort((a, b) => { + return b.lastModifiedDate - a.lastModifiedDate; + }); + + for (let result of filteredResults) { + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + result.key, + 1 + ); + } + return filteredResults; + } + + case "Migrate": { + if ( + message.data.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + await this.#doBrowserMigration( + message.data.key, + message.data.resourceTypes, + message.data.profile, + message.data.safariPasswordFilePath + ); + } else if ( + message.data.type == lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + let window = this.browsingContext.topChromeWindow; + await this.#doFileMigration(window, message.data.key); + } + break; + } + + case "CheckPermissions": { + if ( + message.data.type == + lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER + ) { + let migrator = await MigrationUtils.getMigrator(message.data.key); + return migrator.hasPermissions(); + } + return true; + } + + case "RequestSafariPermissions": { + let safariMigrator = await MigrationUtils.getMigrator("safari"); + return safariMigrator.getPermissions( + this.browsingContext.topChromeWindow + ); + } + + case "SelectSafariPasswordFile": { + return this.#selectSafariPasswordFile( + this.browsingContext.topChromeWindow + ); + } + + case "RecordEvent": { + this.#recordEvent(message.data.type, message.data.args); + break; + } + } + + return null; + } + + /** + * Used for recording telemetry in the migration wizard. + * + * @param {string} type + * The type of event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + #recordEvent(type, args = null) { + Services.telemetry.recordEvent( + "browser.migration", + type, + "wizard", + null, + args + ); + } + + /** + * Gets the FileMigrator associated with the passed in key, and then opens + * a native file picker configured for that migrator. Once the user selects + * a file from the native file picker, this is then passed to the + * FileMigrator.migrate method. + * + * As the migration occurs, this will send UpdateProgress messages to the + * MigrationWizardChild to show the beginning and then the ending state of + * the migration. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @param {string} key + * The unique identification key for a file migrator. + * @returns {Promise} + * Resolves once the file migrator's migrate method has resolved. + */ + async #doFileMigration(window, key) { + let fileMigrator = MigrationUtils.getFileMigrator(key); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return; + } + + let progress = {}; + for (let resourceType of fileMigrator.displayedResourceTypes) { + progress[resourceType] = { + inProgress: true, + message: "", + }; + } + + let [progressHeaderString, successHeaderString] = + await lazy.gFluentStrings.formatValues([ + fileMigrator.progressHeaderL10nID, + fileMigrator.successHeaderL10nID, + ]); + + this.sendAsyncMessage("UpdateFileImportProgress", { + title: progressHeaderString, + progress, + }); + let migrationResult = await fileMigrator.migrate(path); + let successProgress = {}; + for (let resourceType in migrationResult) { + successProgress[resourceType] = { + inProgress: false, + message: migrationResult[resourceType], + }; + } + this.sendAsyncMessage("UpdateFileImportProgress", { + title: successHeaderString, + progress: successProgress, + }); + } + + /** + * Handles a request to open a native file picker to get the path to a + * CSV file that contains passwords exported from Safari. The returned + * path is in the form of a string, or `null` if the user cancelled the + * native picker. + * + * @param {DOMWindow} window + * The window that the native file picker should be associated with. This + * cannot be null. See nsIFilePicker.init for more details. + * @returns {Promise} + */ + async #selectSafariPasswordFile(window) { + let fileMigrator = MigrationUtils.getFileMigrator( + lazy.PasswordFileMigrator.key + ); + let filePickerConfig = await fileMigrator.getFilePickerConfig(); + + let { result, path } = await new Promise(resolve => { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + fp.init(window, filePickerConfig.title, Ci.nsIFilePicker.modeOpen); + + for (let filter of filePickerConfig.filters) { + fp.appendFilter(filter.title, filter.extensionPattern); + } + fp.appendFilters(Ci.nsIFilePicker.filterAll); + fp.open(async fileOpenResult => { + resolve({ result: fileOpenResult, path: fp.file.path }); + }); + }); + + if (result == Ci.nsIFilePicker.returnCancel) { + // If the user cancels out of the file picker, the migration wizard should + // still be in the state that lets the user re-open the file picker if + // they closed it by accident, so we don't have to do anything else here. + return null; + } + + return path; + } + + /** + * Calls into MigrationUtils to perform a migration given the parameters + * sent via the wizard. + * + * @param {string} migratorKey + * The unique identification key for a migrator. + * @param {string[]} resourceTypeNames + * An array of strings, where each string represents a resource type + * that can be imported for this migrator and profile. The strings + * should be one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @param {object|null} profileObj + * A description of the user profile that the migrator can import. + * @param {string} profileObj.id + * A unique ID for the user profile. + * @param {string} profileObj.name + * The display name for the user profile. + * @param {string} [safariPasswordFilePath=null] + * An optional string argument that points to the path of a passwords + * export file from Safari. This file will have password imported from if + * supplied. This argument is ignored if the migratorKey is not for the + * Safari browser. + * @returns {Promise} + * Resolves once the Migration:Ended observer notification has fired. + */ + async #doBrowserMigration( + migratorKey, + resourceTypeNames, + profileObj, + safariPasswordFilePath = null + ) { + let migrator = await MigrationUtils.getMigrator(migratorKey); + let availableResourceTypes = await migrator.getMigrateData(profileObj); + let resourceTypesToMigrate = 0; + let progress = {}; + + for (let resourceTypeName of resourceTypeNames) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeName]; + if (availableResourceTypes & resourceType) { + resourceTypesToMigrate |= resourceType; + progress[resourceTypeName] = { + inProgress: true, + message: "", + }; + } + } + + if ( + migratorKey == lazy.SafariProfileMigrator?.key && + safariPasswordFilePath + ) { + // The caller supplied a password export file for Safari. We're going to + // pretend that there was a PASSWORDS resource for Safari to represent + // the state of importing from that file. + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + inProgress: true, + message: "", + }; + + this.sendAsyncMessage("UpdateProgress", { key: migratorKey, progress }); + + let summary = await lazy.LoginCSVImport.importFromCSV( + safariPasswordFilePath + ); + let quantity = summary.filter(entry => entry.result == "added").length; + + progress[ + lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS + ] = { + inProgress: false, + message: await lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ), + }; + } + + this.sendAsyncMessage("UpdateProgress", { key: migratorKey, progress }); + + // It's possible that only a Safari password file path was sent up, and + // there's nothing left to migrate, in which case we're done here. + if (safariPasswordFilePath && !resourceTypeNames.length) { + return; + } + + try { + await migrator.migrate( + resourceTypesToMigrate, + false, + profileObj, + async resourceTypeNum => { + // Unfortunately, MigratorBase hands us the the numeric value of the + // MigrationUtils.resourceType for this callback. For now, we'll just + // do a look-up to map it to the right constant. + let foundResourceTypeName; + for (let resourceTypeName in MigrationUtils.resourceTypes) { + if ( + MigrationUtils.resourceTypes[resourceTypeName] == resourceTypeNum + ) { + foundResourceTypeName = resourceTypeName; + break; + } + } + + if (!foundResourceTypeName) { + console.error( + "Could not find a resource type for value: ", + resourceTypeNum + ); + } else { + // For now, we ignore errors in migration, and simply display + // the success state. + progress[foundResourceTypeName] = { + inProgress: false, + message: await this.#getStringForImportQuantity( + migratorKey, + foundResourceTypeName + ), + }; + this.sendAsyncMessage("UpdateProgress", { + key: migratorKey, + progress, + }); + } + } + ); + } catch (e) { + console.error(e); + } + } + + /** + * @typedef {object} MigratorProfileInstance + * An object that describes a single user profile (or the default + * user profile) for a particular migrator. + * @property {string} key + * The unique identification key for a migrator. + * @property {string} displayName + * The display name for the migrator that will be shown to the user + * in the wizard. + * @property {string[]} resourceTypes + * An array of strings, where each string represents a resource type + * that can be imported for this migrator and profile. The strings + * should be one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * + * Example: ["HISTORY", "FORMDATA", "PASSWORDS", "BOOKMARKS"] + * @property {object|null} profile + * A description of the user profile that the migrator can import. + * @property {string} profile.id + * A unique ID for the user profile. + * @property {string} profile.name + * The display name for the user profile. + */ + + /** + * Asynchronously fetches a migrator for a particular key, and then + * also gets any user profiles that exist on for that migrator. Resolves + * to null if something goes wrong getting information about the migrator + * or any of the user profiles. + * + * @param {string} key + * The unique identification key for a migrator. + * @returns {Promise} + */ + async #getMigratorAndProfiles(key) { + try { + let migrator = await MigrationUtils.getMigrator(key); + if (!migrator?.enabled) { + return null; + } + + let sourceProfiles = await migrator.getSourceProfiles(); + if (Array.isArray(sourceProfiles)) { + if (!sourceProfiles.length) { + return null; + } + + let result = []; + for (let profile of sourceProfiles) { + result.push( + await this.#serializeMigratorAndProfile(migrator, profile) + ); + } + return result; + } + return this.#serializeMigratorAndProfile(migrator, sourceProfiles); + } catch (e) { + console.error(`Could not get migrator with key ${key}`, e); + } + return null; + } + + /** + * Asynchronously fetches information about what resource types can be + * migrated for a particular migrator and user profile, and then packages + * the migrator, user profile data, and resource type data into an object + * that can be sent down to the MigrationWizardChild. + * + * @param {MigratorBase} migrator + * A migrator subclass of MigratorBase. + * @param {object|null} profileObj + * The user profile object representing the profile to get information + * about. This object is usually gotten by calling getSourceProfiles on + * the migrator. + * @returns {Promise} + */ + async #serializeMigratorAndProfile(migrator, profileObj) { + let [profileMigrationData, lastModifiedDate] = await Promise.all([ + migrator.getMigrateData(profileObj), + migrator.getLastUsedDate(), + ]); + + let availableResourceTypes = []; + + for (let resourceType in MigrationUtils.resourceTypes) { + // Normally, we check each possible resourceType to see if we have one or + // more corresponding resourceTypes in profileMigrationData. The exception + // is for Safari, where the migrator does not expose a PASSWORDS resource + // type, but we allow the user to express that they'd like to import + // passwords from it anyways. This is because the Safari migration flow is + // special, and allows the user to import passwords from a file exported + // from Safari. + if ( + profileMigrationData & MigrationUtils.resourceTypes[resourceType] || + (migrator.constructor.key == lazy.SafariProfileMigrator?.key && + MigrationUtils.resourceTypes[resourceType] == + MigrationUtils.resourceTypes.PASSWORDS && + Services.prefs.getBoolPref( + "signon.management.page.fileImport.enabled", + false + )) + ) { + availableResourceTypes.push(resourceType); + } + } + + let displayName; + + if (migrator.constructor.key == lazy.InternalTestingProfileMigrator.key) { + // In the case of the InternalTestingProfileMigrator, which is never seen + // by users outside of testing, we don't make our localization community + // localize it's display name, and just display the ID instead. + displayName = migrator.constructor.displayNameL10nID; + } else { + displayName = await lazy.gFluentStrings.formatValue( + migrator.constructor.displayNameL10nID + ); + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.BROWSER, + key: migrator.constructor.key, + displayName, + brandImage: migrator.constructor.brandImage, + resourceTypes: availableResourceTypes, + profile: profileObj, + lastModifiedDate, + }; + } + + /** + * Returns the "success" string for a particular resource type after + * migration has completed. + * + * @param {string} migratorKey + * The key for the migrator being used. + * @param {string} resourceTypeStr + * A string mapping to one of the key values of + * MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @returns {Promise} + * The success string for the resource type after migration has completed. + */ + #getStringForImportQuantity(migratorKey, resourceTypeStr) { + switch (resourceTypeStr) { + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS: { + let quantity = MigrationUtils.getImportedCount("bookmarks"); + let stringID = "migration-wizard-progress-success-bookmarks"; + + if ( + lazy.MigrationWizardConstants.USES_FAVORITES.includes(migratorKey) + ) { + stringID = "migration-wizard-progress-success-favorites"; + } + + return lazy.gFluentStrings.formatValue(stringID, { + quantity, + }); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-history", + { + maxAgeInDays: MigrationUtils.HISTORY_MAX_AGE_IN_DAYS, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS: { + let quantity = MigrationUtils.getImportedCount("logins"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-passwords", + { + quantity, + } + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA: { + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-formdata" + ); + } + case lazy.MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES + .PAYMENT_METHODS: { + let quantity = MigrationUtils.getImportedCount("cards"); + return lazy.gFluentStrings.formatValue( + "migration-wizard-progress-success-payment-methods", + { + quantity, + } + ); + } + default: { + return ""; + } + } + } + + /** + * Returns a Promise that resolves to a serializable representation of a + * FileMigrator for sending down to the MigrationWizard. + * + * @param {FileMigrator} fileMigrator + * The FileMigrator to serialize. + * @returns {Promise} + * The serializable representation of the FileMigrator, or null if the + * migrator is disabled. + */ + async #serializeFileMigrator(fileMigrator) { + if (!fileMigrator.enabled) { + return null; + } + + return { + type: lazy.MigrationWizardConstants.MIGRATOR_TYPES.FILE, + key: fileMigrator.constructor.key, + displayName: await lazy.gFluentStrings.formatValue( + fileMigrator.constructor.displayNameL10nID + ), + brandImage: fileMigrator.constructor.brandImage, + resourceTypes: [], + }; + } +} diff --git a/browser/components/migration/MigratorBase.sys.mjs b/browser/components/migration/MigratorBase.sys.mjs new file mode 100644 index 0000000000..b7ae6de78d --- /dev/null +++ b/browser/components/migration/MigratorBase.sys.mjs @@ -0,0 +1,587 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const TOPIC_WILL_IMPORT_BOOKMARKS = + "initial-migration-will-import-default-bookmarks"; +const TOPIC_DID_IMPORT_BOOKMARKS = + "initial-migration-did-import-default-bookmarks"; +const TOPIC_PLACES_DEFAULTS_FINISHED = "places-browser-init-complete"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkHTMLUtils: "resource://gre/modules/BookmarkHTMLUtils.sys.mjs", + FirefoxProfileMigrator: "resource:///modules/FirefoxProfileMigrator.sys.mjs", + MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs", + ResponsivenessMonitor: "resource://gre/modules/ResponsivenessMonitor.sys.mjs", +}); + +/** + * @typedef {object} MigratorResource + * A resource returned by a subclass of MigratorBase that can migrate + * data to this browser. + * @property {number} type + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what this resource represents. A resource can represent one or more types + * of data, for example HISTORY and FORMDATA. + * @property {Function} migrate + * A function that will actually perform the migration of this resource's + * data into this browser. + */ + +/** + * Shared prototype for migrators. + * + * To implement a migrator: + * 1. Import this module. + * 2. Create a subclass of MigratorBase for your new migrator. + * 3. Override the `key` static getter with a unique identifier for the browser + * that this migrator migrates from. + * 4. If the migrator supports multiple profiles, override the sourceProfiles + * Here we default for single-profile migrator. + * 5. Implement getResources(aProfile) (see below). + * 6. For startup-only migrators, override |startupOnlyMigrator|. + * 7. Add the migrator to the MIGRATOR_MODULES structure in MigrationUtils.sys.mjs. + */ +export class MigratorBase { + /** + * This must be overridden to return a simple string identifier for the + * migrator, for example "firefox", "chrome", "opera-gx". This key is what + * is used as an identifier when calling MigrationUtils.getMigrator. + * + * @type {string} + */ + static get key() { + throw new Error("MigratorBase.key must be overridden."); + } + + /** + * This must be overridden to return a Fluent string ID mapping to the display + * name for this migrator. These strings should be defined in migrationWizard.ftl. + * + * @type {string} + */ + static get displayNameL10nID() { + throw new Error("MigratorBase.displayNameL10nID must be overridden."); + } + + /** + * This method should get overridden to return an icon url of the browser + * to be imported from. By default, this will just use the default Favicon + * image. + * + * @type {string} + */ + static get brandImage() { + return "chrome://global/skin/icons/defaultFavicon.svg"; + } + + /** + * OVERRIDE IF AND ONLY IF the source supports multiple profiles. + * + * Returns array of profile objects from which data may be imported. The object + * should have the following keys: + * id - a unique string identifier for the profile + * name - a pretty name to display to the user in the UI + * + * Only profiles from which data can be imported should be listed. Otherwise + * the behavior of the migration wizard isn't well-defined. + * + * For a single-profile source (e.g. safari, ie), this returns null, + * and not an empty array. That is the default implementation. + * + * @abstract + * @returns {object[]|null} + */ + getSourceProfiles() { + return null; + } + + /** + * MUST BE OVERRIDDEN. + * + * Returns an array of "migration resources" objects for the given profile, + * or for the "default" profile, if the migrator does not support multiple + * profiles. + * + * Each migration resource should provide: + * - a |type| getter, returning any of the migration resource types (see + * MigrationUtils.resourceTypes). + * + * - a |migrate| method, taking a single argument, aCallback(bool success), + * for migrating the data for this resource. It may do its job + * synchronously or asynchronously. Either way, it must call + * aCallback(bool aSuccess) when it's done. In the case of an exception + * thrown from |migrate|, it's taken as if aCallback(false) is called. + * + * Note: In the case of a simple asynchronous implementation, you may find + * MigrationUtils.wrapMigrateFunction handy for handling aCallback easily. + * + * For each migration type listed in MigrationUtils.resourceTypes, multiple + * migration resources may be provided. This practice is useful when the + * data for a certain migration type is independently stored in few + * locations. For example, the mac version of Safari stores its "reading list" + * bookmarks in a separate property list. + * + * Note that the importation of a particular migration type is reported as + * successful if _any_ of its resources succeeded to import (that is, called, + * |aCallback(true)|). However, completion-status for a particular migration + * type is reported to the UI only once all of its migrators have called + * aCallback. + * + * NOTE: The returned array should only include resources from which data + * can be imported. So, for example, before adding a resource for the + * BOOKMARKS migration type, you should check if you should check that the + * bookmarks file exists. + * + * @abstract + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * In the case of multiple-profiles migrator, it is guaranteed that + * aProfile is a value returned by the sourceProfiles getter (see + * above). + * @returns {Promise|MigratorResource[]} + */ + // eslint-disable-next-line no-unused-vars + getResources(aProfile) { + throw new Error("getResources must be overridden"); + } + + /** + * OVERRIDE in order to provide an estimate of when the last time was + * that somebody used the browser. It is OK that this is somewhat fuzzy - + * history may not be available (or be wiped or not present due to e.g. + * incognito mode). + * + * If not overridden, the promise will resolve to the Unix epoch. + * + * @returns {Promise} + * A Promise that resolves to the last used date. + */ + getLastUsedDate() { + return Promise.resolve(new Date(0)); + } + + /** + * OVERRIDE IF AND ONLY IF the migrator is a startup-only migrator (For now, + * that is just the Firefox migrator, see bug 737381). Default: false. + * + * Startup-only migrators are different in two ways: + * - they may only be used during startup. + * - the user-profile is half baked during migration. The folder exists, + * but it's only accessible through MigrationUtils.profileStartup. + * The migrator can call MigrationUtils.profileStartup.doStartup + * at any point in order to initialize the profile. + * + * @returns {boolean} + * true if the migrator is start-up only. + */ + get startupOnlyMigrator() { + return false; + } + + /** + * Returns true if the migrator is configured to be enabled. This is + * controlled by the `browser.migrate..enabled` boolean + * preference. + * + * @returns {boolean} + * true if the migrator should be shown in the migration wizard. + */ + get enabled() { + let key = this.constructor.key; + return Services.prefs.getBoolPref(`browser.migrate.${key}.enabled`, false); + } + + /** + * Subclasses should implement this if special checks need to be made to determine + * if certain permissions need to be requested before data can be imported. + * The returned Promise resolves to true if the required permissions have + * been granted and a migration could proceed. + * + * @returns {Promise} + */ + async hasPermissions() { + return Promise.resolve(true); + } + + /** + * Subclasses should implement this if special permissions need to be + * requested from the user or the operating system in order to perform + * a migration with this MigratorBase. This will be called only if + * hasPermissions resolves to false. + * + * The returned Promise will resolve to true if permissions were successfully + * obtained, and false otherwise. Implementors should ensure that if a call + * to getPermissions resolves to true, that the MigratorBase will be able to + * get read access to all of the resources it needs to do a migration. + * + * @param {DOMWindow} win + * The top-level DOM window hosting the UI that is requesting the permission. + * This can be used to, for example, anchor a file picker window to the + * same window that is hosting the migration UI. + * @returns {Promise} + */ + // eslint-disable-next-line no-unused-vars + async getPermissions(win) { + return Promise.resolve(true); + } + + /** + * This method returns a number that is the bitwise OR of all resource + * types that are available in aProfile. See MigrationUtils.resourceTypes + * for each resource type. + * + * @param {object|string} aProfile + * The profile from which data may be imported, or an empty string + * in the case of a single-profile migrator. + * @returns {number} + */ + async getMigrateData(aProfile) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources) { + return 0; + } + let types = resources.map(r => r.type); + return types.reduce((a, b) => { + a |= b; + return a; + }, 0); + } + + /** + * @see MigrationUtils + * + * @param {number} aItems + * A bitfield with bits from MigrationUtils.resourceTypes flipped to indicate + * what types of resources should be migrated. + * @param {boolean} aStartup + * True if this migration is occurring during startup. + * @param {object|string} aProfile + * The other browser profile that is being migrated from. + * @param {Function|null} aProgressCallback + * An optional callback that will be fired once a resourceType has finished + * migrating. The callback will be passed the numeric representation of the + * resource type. + */ + async migrate(aItems, aStartup, aProfile, aProgressCallback = () => {}) { + let resources = await this.#getMaybeCachedResources(aProfile); + if (!resources.length) { + throw new Error("migrate called for a non-existent source"); + } + + if (aItems != lazy.MigrationUtils.resourceTypes.ALL) { + resources = resources.filter(r => aItems & r.type); + } + + // Used to periodically give back control to the main-thread loop. + let unblockMainThread = function () { + return new Promise(resolve => { + Services.tm.dispatchToMainThread(resolve); + }); + }; + + let getHistogramIdForResourceType = (resourceType, template) => { + if (resourceType == lazy.MigrationUtils.resourceTypes.HISTORY) { + return template.replace("*", "HISTORY"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.BOOKMARKS) { + return template.replace("*", "BOOKMARKS"); + } + if (resourceType == lazy.MigrationUtils.resourceTypes.PASSWORDS) { + return template.replace("*", "LOGINS"); + } + return null; + }; + + let browserKey = this.constructor.key; + + let maybeStartTelemetryStopwatch = resourceType => { + let histogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_IMPORT_MS" + ); + if (histogramId) { + TelemetryStopwatch.startKeyed(histogramId, browserKey); + } + return histogramId; + }; + + let maybeStartResponsivenessMonitor = resourceType => { + let responsivenessMonitor; + let responsivenessHistogramId = getHistogramIdForResourceType( + resourceType, + "FX_MIGRATION_*_JANK_MS" + ); + if (responsivenessHistogramId) { + responsivenessMonitor = new lazy.ResponsivenessMonitor(); + } + return { responsivenessMonitor, responsivenessHistogramId }; + }; + + let maybeFinishResponsivenessMonitor = ( + responsivenessMonitor, + histogramId + ) => { + if (responsivenessMonitor) { + let accumulatedDelay = responsivenessMonitor.finish(); + if (histogramId) { + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add(browserKey, accumulatedDelay); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + } + }; + + let collectQuantityTelemetry = () => { + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + let histogramId = + "FX_MIGRATION_" + resourceType.toUpperCase() + "_QUANTITY"; + try { + Services.telemetry + .getKeyedHistogramById(histogramId) + .add( + browserKey, + lazy.MigrationUtils._importQuantities[resourceType] + ); + } catch (ex) { + console.error(histogramId, ": ", ex); + } + } + }; + + let collectMigrationTelemetry = resourceType => { + // We don't want to collect this if the migration is occurring due to a + // profile refresh. + if (this.constructor.key == lazy.FirefoxProfileMigrator.key) { + return; + } + + let prefKey = null; + switch (resourceType) { + case lazy.MigrationUtils.resourceTypes.BOOKMARKS: { + prefKey = "browser.migrate.interactions.bookmarks"; + break; + } + case lazy.MigrationUtils.resourceTypes.HISTORY: { + prefKey = "browser.migrate.interactions.history"; + break; + } + case lazy.MigrationUtils.resourceTypes.PASSWORDS: { + prefKey = "browser.migrate.interactions.passwords"; + break; + } + default: { + return; + } + } + + if (prefKey) { + Services.prefs.setBoolPref(prefKey, true); + } + }; + + // Called either directly or through the bookmarks import callback. + let doMigrate = async function () { + let resourcesGroupedByItems = new Map(); + resources.forEach(function (resource) { + if (!resourcesGroupedByItems.has(resource.type)) { + resourcesGroupedByItems.set(resource.type, new Set()); + } + resourcesGroupedByItems.get(resource.type).add(resource); + }); + + if (resourcesGroupedByItems.size == 0) { + throw new Error("No items to import"); + } + + let notify = function (aMsg, aItemType) { + Services.obs.notifyObservers(null, aMsg, aItemType); + }; + + for (let resourceType of Object.keys( + lazy.MigrationUtils._importQuantities + )) { + lazy.MigrationUtils._importQuantities[resourceType] = 0; + } + notify("Migration:Started"); + for (let [migrationType, itemResources] of resourcesGroupedByItems) { + notify("Migration:ItemBeforeMigrate", migrationType); + + let stopwatchHistogramId = maybeStartTelemetryStopwatch(migrationType); + + let { responsivenessMonitor, responsivenessHistogramId } = + maybeStartResponsivenessMonitor(migrationType); + + let itemSuccess = false; + for (let res of itemResources) { + let completeDeferred = lazy.PromiseUtils.defer(); + let resourceDone = function (aSuccess) { + itemResources.delete(res); + itemSuccess |= aSuccess; + if (itemResources.size == 0) { + notify( + itemSuccess + ? "Migration:ItemAfterMigrate" + : "Migration:ItemError", + migrationType + ); + collectMigrationTelemetry(migrationType); + + aProgressCallback(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) { + console.error(ex); + resourceDone(false); + } + + await completeDeferred.promise; + await unblockMainThread(); + } + } + }; + + if ( + lazy.MigrationUtils.isStartupMigration && + !this.startupOnlyMigrator && + Services.policies.isAllowed("defaultBookmarks") + ) { + lazy.MigrationUtils.profileStartup.doStartup(); + // First import the default bookmarks. + // Note: We do not need to do so for the Firefox migrator + // (=startupOnlyMigrator), as it just copies over the places database + // from another profile. + await (async function () { + // Tell nsBrowserGlue we're importing default bookmarks. + let browserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver + ); + browserGlue.observe(null, TOPIC_WILL_IMPORT_BOOKMARKS, ""); + + // Import the default bookmarks. We ignore whether or not we succeed. + await lazy.BookmarkHTMLUtils.importFromURL( + "chrome://browser/content/default-bookmarks.html", + { + replace: true, + source: lazy.PlacesUtils.bookmarks.SOURCES.RESTORE_ON_STARTUP, + } + ).catch(console.error); + + // We'll tell nsBrowserGlue we've imported bookmarks, but before that + // we need to make sure we're going to know when it's finished + // initializing places: + let placesInitedPromise = new Promise(resolve => { + let onPlacesInited = function () { + Services.obs.removeObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + resolve(); + }; + Services.obs.addObserver( + onPlacesInited, + TOPIC_PLACES_DEFAULTS_FINISHED + ); + }); + browserGlue.observe(null, TOPIC_DID_IMPORT_BOOKMARKS, ""); + await placesInitedPromise; + await doMigrate(); + })(); + return; + } + await doMigrate(); + } + + /** + * Checks to see if one or more profiles exist for the browser that this + * migrator migrates from. + * + * @returns {Promise} + * True if one or more profiles exists that this migrator can migrate + * resources from. + */ + async isSourceAvailable() { + if (this.startupOnlyMigrator && !lazy.MigrationUtils.isStartupMigration) { + return false; + } + + // For a single-profile source, check if any data is available. + // For multiple-profiles source, make sure that at least one + // profile is available. + let exists = false; + try { + let profiles = await this.getSourceProfiles(); + if (!profiles) { + let resources = await this.#getMaybeCachedResources(""); + if (resources && resources.length) { + exists = true; + } + } else { + exists = !!profiles.length; + } + } catch (ex) { + console.error(ex); + } + return exists; + } + + /*** PRIVATE STUFF - DO NOT OVERRIDE ***/ + + /** + * Returns resources for a particular profile and then caches them for later + * lookups. + * + * @param {object|string} aProfile + * The profile that resources are being imported from. + * @returns {Promise} + */ + async #getMaybeCachedResources(aProfile) { + let profileKey = aProfile ? aProfile.id : ""; + if (this._resourcesByProfile) { + if (profileKey in this._resourcesByProfile) { + return this._resourcesByProfile[profileKey]; + } + } else { + this._resourcesByProfile = {}; + } + this._resourcesByProfile[profileKey] = await this.getResources(aProfile); + return this._resourcesByProfile[profileKey]; + } +} diff --git a/browser/components/migration/ProfileMigrator.sys.mjs b/browser/components/migration/ProfileMigrator.sys.mjs new file mode 100644 index 0000000000..5d3b8baba7 --- /dev/null +++ b/browser/components/migration/ProfileMigrator.sys.mjs @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; + +export function ProfileMigrator() {} + +ProfileMigrator.prototype = { + migrate: MigrationUtils.startupMigration.bind(MigrationUtils), + QueryInterface: ChromeUtils.generateQI(["nsIProfileMigrator"]), + classDescription: "Profile Migrator", + contractID: "@mozilla.org/toolkit/profile-migrator;1", + classID: Components.ID("6F8BB968-C14F-4D6F-9733-6C6737B35DCE"), +}; diff --git a/browser/components/migration/SafariProfileMigrator.sys.mjs b/browser/components/migration/SafariProfileMigrator.sys.mjs new file mode 100644 index 0000000000..4a68178e4b --- /dev/null +++ b/browser/components/migration/SafariProfileMigrator.sys.mjs @@ -0,0 +1,674 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs"; + +import { MigrationUtils } from "resource:///modules/MigrationUtils.sys.mjs"; +import { MigratorBase } from "resource:///modules/MigratorBase.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + PropertyListUtils: "resource://gre/modules/PropertyListUtils.sys.mjs", +}); + +// NSDate epoch is Jan 1, 2001 UTC +const NS_DATE_EPOCH_MS = new Date("2001-01-01T00:00:00-00:00").getTime(); + +// Convert NSDate timestamp to UNIX timestamp. +function parseNSDate(cocoaDateStr) { + let asDouble = parseFloat(cocoaDateStr); + if (!isNaN(asDouble)) { + return new Date(NS_DATE_EPOCH_MS + asDouble * 1000); + } + return new Date(); +} + +// Convert UNIX timestamp to NSDate timestamp. +function msToNSDate(ms) { + return parseFloat(ms - NS_DATE_EPOCH_MS) / 1000; +} + +function Bookmarks(aBookmarksFile) { + this._file = aBookmarksFile; +} +Bookmarks.prototype = { + type: MigrationUtils.resourceTypes.BOOKMARKS, + + migrate: function B_migrate(aCallback) { + return (async () => { + let dict = await new Promise(resolve => + lazy.PropertyListUtils.read(this._file, resolve) + ); + if (!dict) { + throw new Error("Could not read Bookmarks.plist"); + } + let children = dict.get("Children"); + if (!children) { + throw new Error("Invalid Bookmarks.plist format"); + } + + let collection = + dict.get("Title") == "com.apple.ReadingList" + ? this.READING_LIST_COLLECTION + : this.ROOT_COLLECTION; + await this._migrateRootCollection(children, collection); + })().then( + () => aCallback(true), + e => { + console.error(e); + aCallback(false); + } + ); + }, + + // Bookmarks collections in Safari. Constants for migrateCollection. + ROOT_COLLECTION: 0, + MENU_COLLECTION: 1, + TOOLBAR_COLLECTION: 2, + READING_LIST_COLLECTION: 3, + + /** + * Start the migration of a Safari collection of bookmarks by retrieving favicon data. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above. + */ + async _migrateRootCollection(aEntries, aCollection) { + // First, try to get the favicon data of a user's bookmarks. + // In Safari, Favicons are stored as files with a unique name: + // the MD5 hash of the UUID of an SQLite entry in favicons.db. + // Thus, we must create a map from bookmark URLs -> their favicon entry's UUID. + let bookmarkURLToUUIDMap = new Map(); + + const faviconFolder = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache"], + false + ).path; + let dbPath = PathUtils.join(faviconFolder, "favicons.db"); + + try { + // If there is an error getting favicon data, we catch the error and move on. + // In this case, the bookmarkURLToUUIDMap will be left empty. + let rows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + + if (rows) { + // Convert the rows from our SQLite database into a map from bookmark url to uuid + for (let row of rows) { + let uniqueURL = Services.io.newURI(row.getResultByName("url")).spec; + + // Normalize the URL by removing any trailing slashes. We'll make sure to do + // the same when doing look-ups during a migration. + if (uniqueURL.endsWith("/")) { + uniqueURL = uniqueURL.replace(/\/+$/, ""); + } + bookmarkURLToUUIDMap.set(uniqueURL, row.getResultByName("uuid")); + } + } + } catch (ex) { + console.error(ex); + } + + await this._migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap); + }, + + /** + * Recursively migrate a Safari collection of bookmarks. + * + * @param {object[]} aEntries + * The collection's children + * @param {number} aCollection + * One of the _COLLECTION values above + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {Promise} + * Resolves after the bookmarks and favicons have been inserted into the + * appropriate databases. + */ + async _migrateCollection(aEntries, aCollection, bookmarkURLToUUIDMap) { + // A collection of bookmarks in Safari resembles places roots. In the + // property list files (Bookmarks.plist, ReadingList.plist) they are + // stored as regular bookmarks folders, and thus can only be distinguished + // from by their names and places in the hierarchy. + + let entriesFiltered = []; + if (aCollection == this.ROOT_COLLECTION) { + for (let entry of aEntries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let title = entry.get("Title"); + let children = entry.get("Children"); + if (title == "BookmarksBar") { + await this._migrateCollection( + children, + this.TOOLBAR_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "BookmarksMenu") { + await this._migrateCollection( + children, + this.MENU_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (title == "com.apple.ReadingList") { + await this._migrateCollection( + children, + this.READING_LIST_COLLECTION, + bookmarkURLToUUIDMap + ); + } else if (entry.get("ShouldOmitFromUI") !== true) { + entriesFiltered.push(entry); + } + } else if (type == "WebBookmarkTypeLeaf") { + entriesFiltered.push(entry); + } + } + } else { + entriesFiltered = aEntries; + } + + if (!entriesFiltered.length) { + return; + } + + let folderGuid = -1; + switch (aCollection) { + case this.ROOT_COLLECTION: { + // In Safari, it is possible (though quite cumbersome) to move + // bookmarks to the bookmarks root, which is the parent folder of + // all bookmarks "collections". That is somewhat in parallel with + // both the places root and the unfiled-bookmarks root. + // Because the former is only an implementation detail in our UI, + // the unfiled root seems to be the best choice. + folderGuid = lazy.PlacesUtils.bookmarks.unfiledGuid; + break; + } + case this.MENU_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.menuGuid; + break; + } + case this.TOOLBAR_COLLECTION: { + folderGuid = lazy.PlacesUtils.bookmarks.toolbarGuid; + break; + } + case this.READING_LIST_COLLECTION: { + // Reading list items are imported as regular bookmarks. + // They are imported under their own folder, created either under the + // bookmarks menu (in the case of startup migration). + let readingListTitle = await MigrationUtils.getLocalizedString( + "imported-safari-reading-list" + ); + folderGuid = ( + await MigrationUtils.insertBookmarkWrapper({ + parentGuid: lazy.PlacesUtils.bookmarks.menuGuid, + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + title: readingListTitle, + }) + ).guid; + break; + } + default: + throw new Error("Unexpected value for aCollection!"); + } + if (folderGuid == -1) { + throw new Error("Invalid folder GUID"); + } + + await this._migrateEntries( + entriesFiltered, + folderGuid, + bookmarkURLToUUIDMap + ); + }, + + /** + * Migrates bookmarks and favicons from Safari to Firefox. + * + * @param {object[]} entries + * The Safari collection's children + * @param {number} parentGuid + * GUID of the collection folder + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + */ + async _migrateEntries(entries, parentGuid, bookmarkURLToUUIDMap) { + let { convertedEntries, favicons } = await this._convertEntries( + entries, + bookmarkURLToUUIDMap + ); + + await MigrationUtils.insertManyBookmarksWrapper( + convertedEntries, + parentGuid + ); + + MigrationUtils.insertManyFavicons(favicons); + }, + + /** + * Converts Safari collection entries into a suitable format for + * inserting bookmarks and favicons. + * + * @param {object[]} entries + * The collection's children + * @param {Map} bookmarkURLToUUIDMap + * A map from a bookmark's URL to the UUID of its entry in the favicons.db database + * @returns {object[]} + * Returns an object with an array of converted bookmark entries and favicons + */ + async _convertEntries(entries, bookmarkURLToUUIDMap) { + let favicons = []; + let convertedEntries = []; + + const faviconFolder = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache"], + false + ).path; + + for (const entry of entries) { + let type = entry.get("WebBookmarkType"); + if (type == "WebBookmarkTypeList" && entry.has("Children")) { + let convertedChildren = await this._convertEntries( + entry.get("Children"), + bookmarkURLToUUIDMap + ); + favicons.push(...convertedChildren.favicons); + convertedEntries.push({ + title: entry.get("Title"), + type: lazy.PlacesUtils.bookmarks.TYPE_FOLDER, + children: convertedChildren.convertedEntries, + }); + } else if (type == "WebBookmarkTypeLeaf" && entry.has("URLString")) { + // Check we understand this URL before adding it: + let url = entry.get("URLString"); + try { + new URL(url); + } catch (ex) { + console.error( + `Ignoring ${url} when importing from Safari because of exception: ${ex}` + ); + continue; + } + let title; + if (entry.has("URIDictionary")) { + title = entry.get("URIDictionary").get("title"); + } + convertedEntries.push({ url, title }); + + try { + // Try to get the favicon data for each bookmark we have. + // We use uri.spec as our unique identifier since bookmark links + // don't completely match up in the Safari data. + let uri = Services.io.newURI(url); + let uriSpec = uri.spec; + + // Safari's favicon database doesn't include forward slashes for + // the page URLs, despite adding them in the Bookmarks.plist file. + // We'll strip any off here for our favicon lookup. + if (uriSpec.endsWith("/")) { + uriSpec = uriSpec.replace(/\/+$/, ""); + } + + let uuid = bookmarkURLToUUIDMap.get(uriSpec); + if (uuid) { + // Hash the UUID with md5 to give us the favicon file name. + let hashedUUID = lazy.PlacesUtils.md5(uuid, { + format: "hex", + }).toUpperCase(); + let faviconFile = PathUtils.join( + faviconFolder, + "favicons", + hashedUUID + ); + let faviconData = await IOUtils.read(faviconFile); + favicons.push({ faviconData, uri }); + } + } catch (error) { + // Even if we fail, still continue the import process + // since favicons aren't as essential as the bookmarks themselves. + console.error(error); + } + } + } + + return { convertedEntries, favicons }; + }, +}; + +async function GetHistoryResource() { + let dbPath = FileUtils.getDir( + "ULibDir", + ["Safari", "History.db"], + false + ).path; + let maxAge = msToNSDate( + Date.now() - MigrationUtils.HISTORY_MAX_AGE_IN_MILLISECONDS + ); + + // If we have read access to the Safari profile directory, check to + // see if there's any history to import. If we can't access the profile + // directory, let's assume that there's history to import and give the + // user the option to migrate it. + let canReadHistory = false; + try { + // 'stat' is always allowed, but reading is somehow not, if the user hasn't + // allowed it: + await IOUtils.read(dbPath, { maxBytes: 1 }); + canReadHistory = true; + } catch (ex) { + console.error( + "Cannot yet read from Safari profile directory. Will presume history exists for import." + ); + } + + if (canReadHistory) { + let countQuery = ` + SELECT COUNT(*) + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge} + LIMIT 1;`; + + let countResult = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + countQuery + ); + + if (!countResult[0].getResultByName("COUNT(*)")) { + return null; + } + } + + let selectQuery = ` + SELECT + history_items.url as history_url, + history_visits.title as history_title, + history_visits.visit_time as history_time + FROM history_items LEFT JOIN history_visits + ON history_items.id = history_visits.history_item + WHERE history_visits.visit_time > ${maxAge};`; + + return { + type: MigrationUtils.resourceTypes.HISTORY, + + async migrate(callback) { + callback(await this._migrate()); + }, + + async _migrate() { + let historyRows; + + try { + historyRows = await MigrationUtils.getRowsFromDBWithoutLocks( + dbPath, + "Safari history", + selectQuery + ); + + if (!historyRows.length) { + console.log("No history found"); + return false; + } + } catch (ex) { + console.error(ex); + return false; + } + + let pageInfos = []; + for (let row of historyRows) { + pageInfos.push({ + title: row.getResultByName("history_title"), + url: new URL(row.getResultByName("history_url")), + visits: [ + { + transition: lazy.PlacesUtils.history.TRANSITIONS.TYPED, + date: parseNSDate(row.getResultByName("history_time")), + }, + ], + }); + } + await MigrationUtils.insertVisitsWrapper(pageInfos); + + return true; + }, + }; +} + +/** + * Safari's preferences property list is independently used for three purposes: + * (a) importation of preferences + * (b) importation of search strings + * (c) retrieving the home page. + * + * So, rather than reading it three times, it's cached and managed here. + * + * @param {nsIFile} aPreferencesFile + * The .plist file to be read. + */ +function MainPreferencesPropertyList(aPreferencesFile) { + this._file = aPreferencesFile; + this._callbacks = []; +} +MainPreferencesPropertyList.prototype = { + /** + * @see PropertyListUtils.read + * @param {Function} aCallback + * A callback called with an Object representing the key-value pairs + * read out of the .plist file. + */ + read: function MPPL_read(aCallback) { + if ("_dict" in this) { + aCallback(this._dict); + return; + } + + let alreadyReading = !!this._callbacks.length; + this._callbacks.push(aCallback); + if (!alreadyReading) { + lazy.PropertyListUtils.read(this._file, aDict => { + this._dict = aDict; + for (let callback of this._callbacks) { + try { + callback(aDict); + } catch (ex) { + console.error(ex); + } + } + this._callbacks.splice(0); + }); + } + }, +}; + +function SearchStrings(aMainPreferencesPropertyListInstance) { + this._mainPreferencesPropertyList = aMainPreferencesPropertyListInstance; +} +SearchStrings.prototype = { + type: MigrationUtils.resourceTypes.OTHERDATA, + + migrate: function SS_migrate(aCallback) { + this._mainPreferencesPropertyList.read( + MigrationUtils.wrapMigrateFunction(function migrateSearchStrings(aDict) { + if (!aDict) { + throw new Error("Could not get preferences dictionary"); + } + + if (aDict.has("RecentSearchStrings")) { + let recentSearchStrings = aDict.get("RecentSearchStrings"); + if (recentSearchStrings && recentSearchStrings.length) { + let changes = recentSearchStrings.map(searchString => ({ + op: "add", + fieldname: "searchbar-history", + value: searchString, + })); + lazy.FormHistory.update(changes); + } + } + }, aCallback) + ); + }, +}; + +/** + * Safari migrator + */ +export class SafariProfileMigrator extends MigratorBase { + static get key() { + return "safari"; + } + + static get displayNameL10nID() { + return "migration-wizard-migrator-display-name-safari"; + } + + static get brandImage() { + return "chrome://browser/content/migration/brands/safari.png"; + } + + async 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("Bookmarks.plist", Bookmarks); + + // The Reading List feature was introduced at the same time in Windows and + // Mac versions of Safari. Not surprisingly, they are stored in the same + // format in both versions. Surpsingly, only on Windows there is a + // separate property list for it. This code is used on mac too, because + // Apple may fix this at some point. + pushProfileFileResource("ReadingList.plist", Bookmarks); + + let prefs = this.mainPreferencesPropertyList; + if (prefs) { + resources.push(new SearchStrings(prefs)); + } + + resources.push(GetHistoryResource()); + + resources = await Promise.all(resources); + + return resources.filter(r => r != null); + } + + async getLastUsedDate() { + const profileDir = FileUtils.getDir("ULibDir", ["Safari"], false); + const dates = await Promise.all( + ["Bookmarks.plist", "History.db"].map(file => { + const path = PathUtils.join(profileDir.path, file); + return IOUtils.stat(path) + .then(info => info.lastModified) + .catch(() => 0); + }) + ); + + return new Date(Math.max(...dates)); + } + + async hasPermissions() { + if (this._hasPermissions) { + return true; + } + // Check if we have access to some key files, but only if they exist. + let historyTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "History.db"], + false + ); + let bookmarkTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "Bookmarks.plist"], + false + ); + let faviconTarget = FileUtils.getDir( + "ULibDir", + ["Safari", "Favicon Cache", "favicons.db"], + false + ); + try { + let historyExists = await IOUtils.exists(historyTarget.path); + let bookmarksExists = await IOUtils.exists(bookmarkTarget.path); + let faviconsExists = await IOUtils.exists(faviconTarget.path); + // We now know which files exist, which is always allowed. + // To determine if we have read permissions, try to read a single byte + // from each file that exists, which will throw if we need permissions. + if (historyExists) { + await IOUtils.read(historyTarget.path, { maxBytes: 1 }); + } + if (bookmarksExists) { + await IOUtils.read(bookmarkTarget.path, { maxBytes: 1 }); + } + if (faviconsExists) { + await IOUtils.read(faviconTarget.path, { maxBytes: 1 }); + } + this._hasPermissions = true; + return true; + } catch (ex) { + return false; + } + } + + async getPermissions(win) { + // Keep prompting the user until they pick something that grants us access + // to Safari's bookmarks and favicons or they cancel out of the file open panel. + while (!(await this.hasPermissions())) { + let fp = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + // The title (second arg) is not displayed on macOS, so leave it blank. + fp.init(win, "", Ci.nsIFilePicker.modeGetFolder); + fp.filterIndex = 1; + fp.displayDirectory = FileUtils.getDir("ULibDir", [""], false); + // Now wait for the filepicker to open and close. If the user picks + // the Safari folder, macOS will grant us read access to everything + // inside, so we don't need to check or do anything else with what's + // returned by the filepicker. + let result = await new Promise(resolve => fp.open(resolve)); + // Bail if the user cancels the dialog: + if (result == Ci.nsIFilePicker.returnCancel) { + return false; + } + } + return true; + } + + get mainPreferencesPropertyList() { + if (this._mainPreferencesPropertyList === undefined) { + let file = FileUtils.getDir("UsrPrfs", [], false); + if (file.exists()) { + file.append("com.apple.Safari.plist"); + if (file.exists()) { + this._mainPreferencesPropertyList = new MainPreferencesPropertyList( + file + ); + return this._mainPreferencesPropertyList; + } + } + this._mainPreferencesPropertyList = null; + return this._mainPreferencesPropertyList; + } + return this._mainPreferencesPropertyList; + } +} diff --git a/browser/components/migration/components.conf b/browser/components/migration/components.conf new file mode 100644 index 0000000000..06b2d4b446 --- /dev/null +++ b/browser/components/migration/components.conf @@ -0,0 +1,37 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +XP_WIN = buildconfig.substs['OS_ARCH'] == 'WINNT' +XP_MACOSX = buildconfig.substs['MOZ_WIDGET_TOOLKIT'] == 'cocoa' + +Classes = [ + { + 'cid': '{6F8BB968-C14F-4D6F-9733-6C6737B35DCE}', + 'contract_ids': ['@mozilla.org/toolkit/profile-migrator;1'], + 'esModule': 'resource:///modules/ProfileMigrator.sys.mjs', + 'constructor': 'ProfileMigrator', + }, +] + +if XP_WIN: + Classes += [ + { + 'cid': '{c214cadc-2033-445e-8800-3fe25ee8d368}', + 'contract_ids': ['@mozilla.org/profile/migrator/edgemigrationutils;1'], + 'type': 'mozilla::nsEdgeMigrationUtils', + 'headers': ['nsEdgeMigrationUtils.h'], + }, + ] + +if XP_MACOSX: + Classes += [ + { + 'cid': '{647bf80c-cd35-4ce6-b904-fd586b97ae48}', + 'contract_ids': ['@mozilla.org/profile/migrator/keychainmigrationutils;1'], + 'type': 'nsKeychainMigrationUtils', + 'headers': ['nsKeychainMigrationUtils.h'], + }, + ] diff --git a/browser/components/migration/content/aboutWelcomeBack.xhtml b/browser/components/migration/content/aboutWelcomeBack.xhtml new file mode 100644 index 0000000000..0777cc56e9 --- /dev/null +++ b/browser/components/migration/content/aboutWelcomeBack.xhtml @@ -0,0 +1,126 @@ + + + +%htmlDTD; ]> + + + + + + + + + + + + + + + + + + + + diff --git a/browser/components/migration/content/migration-dialog-window.js b/browser/components/migration/content/migration-dialog-window.js new file mode 100644 index 0000000000..feaa70f565 --- /dev/null +++ b/browser/components/migration/content/migration-dialog-window.js @@ -0,0 +1,82 @@ +/* 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 file manages a MigrationWizard embedded in a dialog that runs + * in a top-level dialog window. It's main responsibility is to listen + * for dialog-specific events from the embedded MigrationWizard and to + * respond appropriately to them. + * + * A single object argument is expected to be passed when opening + * this dialog. + * + * @param {object} window.arguments.0 + * @param {Function} window.arguments.0.onResize + * A callback to resize the container of this document when the + * MigrationWizard resizes. + * @param {object} window.arguments.0.options + * A series of options for configuring the dialog. See + * MigrationUtils.showMigrationWizard for a description of this + * object. + */ + +const MigrationDialog = { + _wiz: null, + + init() { + addEventListener("load", this); + }, + + onLoad() { + this._wiz = document.getElementById("wizard"); + this._wiz.addEventListener("MigrationWizard:Close", this); + document.addEventListener("keypress", this); + + let args = window.arguments[0]; + // When opened via nsIWindowWatcher.openWindow, the arguments are + // passed through C++, and they arrive to us wrapped as an XPCOM + // object. We use wrappedJSObject to get at the underlying JS + // object. + if (args instanceof Ci.nsISupports) { + args = args.wrappedJSObject; + } + + // We have to inform the container of this document that the + // MigrationWizard has changed size in order for it to update + // its dimensions too. + let observer = new ResizeObserver(() => { + args.onResize(); + }); + observer.observe(this._wiz); + + let panelList = this._wiz.querySelector("panel-list"); + let panel = document.createXULElement("panel"); + panel.appendChild(panelList); + this._wiz.appendChild(panel); + this._wiz.requestState(); + }, + + handleEvent(event) { + switch (event.type) { + case "load": { + this.onLoad(); + break; + } + case "keypress": { + if (event.keyCode == KeyEvent.DOM_VK_ESCAPE) { + window.close(); + } + break; + } + case "MigrationWizard:Close": { + window.close(); + break; + } + } + }, +}; + +MigrationDialog.init(); diff --git a/browser/components/migration/content/migration-wizard-constants.mjs b/browser/components/migration/content/migration-wizard-constants.mjs new file mode 100644 index 0000000000..30e1af3d79 --- /dev/null +++ b/browser/components/migration/content/migration-wizard-constants.mjs @@ -0,0 +1,76 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export const MigrationWizardConstants = Object.freeze({ + MIGRATOR_TYPES: Object.freeze({ + BROWSER: "browser", + FILE: "file", + }), + + /** + * A mapping of a page identification string to the IDs used by the + * various wizard pages. These are used by MigrationWizard.setState + * to set the current page. + * + * @type {Object} + */ + PAGES: Object.freeze({ + LOADING: "loading", + SELECTION: "selection", + PROGRESS: "progress", + FILE_IMPORT_PROGRESS: "file-import-progress", + SAFARI_PERMISSION: "safari-permission", + SAFARI_PASSWORD_PERMISSION: "safari-password-permission", + NO_BROWSERS_FOUND: "no-browsers-found", + }), + + /** + * Returns a mapping of a resource type to a string used to identify + * the associated resource group in the wizard via a data-resource-type + * attribute. The keys are used to set which items should be shown and + * in what state in #onShowingProgress. + * + * @type {Object} + */ + DISPLAYED_RESOURCE_TYPES: Object.freeze({ + // The DISPLAYED_RESOURCE_TYPES should have their keys match those + // in MigrationUtils.resourceTypes. + + // This is a little silly, but JavaScript doesn't have a notion of + // enums. The advantage of this set-up is that these constants values + // can be used to access the MigrationUtils.resourceTypes constants, + // are reasonably readable as DOM attributes, and easily serialize / + // deserialize. + HISTORY: "HISTORY", + FORMDATA: "FORMDATA", + PASSWORDS: "PASSWORDS", + BOOKMARKS: "BOOKMARKS", + PAYMENT_METHODS: "PAYMENT_METHODS", + + // We don't yet show OTHERDATA or SESSION resources. + }), + + DISPLAYED_FILE_RESOURCE_TYPES: Object.freeze({ + // When migrating passwords from a file, we first show the progress + // for a single PASSWORDS_FROM_FILE resource type, and then upon + // completion, show two different resource types - one for new + // passwords imported from the file, and one for existing passwords + // that were updated from the file. + PASSWORDS_FROM_FILE: "PASSWORDS_FROM_FILE", + PASSWORDS_NEW: "PASSWORDS_NEW", + PASSWORDS_UPDATED: "PASSWORDS_UPDATED", + BOOKMARKS_FROM_FILE: "BOOKMARKS_FROM_FILE", + }), + + /** + * The set of keys that maps to migrators that use the term "favorites" + * in the place of "bookmarks". This tends to be browsers from Microsoft. + */ + USES_FAVORITES: Object.freeze([ + "chromium-edge", + "chromium-edge-beta", + "edge", + "ie", + ]), +}); diff --git a/browser/components/migration/content/migration-wizard.mjs b/browser/components/migration/content/migration-wizard.mjs new file mode 100644 index 0000000000..e1462ab9d1 --- /dev/null +++ b/browser/components/migration/content/migration-wizard.mjs @@ -0,0 +1,1088 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// eslint-disable-next-line import/no-unassigned-import +import "chrome://global/content/elements/moz-button-group.mjs"; +import { MigrationWizardConstants } from "chrome://browser/content/migration/migration-wizard-constants.mjs"; + +/** + * This component contains the UI that steps users through migrating their + * data from other browsers to this one. This component only contains very + * basic logic and structure for the UI, and most of the state management + * occurs in the MigrationWizardChild JSWindowActor. + */ +export class MigrationWizard extends HTMLElement { + static #template = null; + + #deck = null; + #browserProfileSelector = null; + #browserProfileSelectorList = null; + #resourceTypeList = null; + #shadowRoot = null; + #importButton = null; + #importFromFileButton = null; + #chooseImportFromFile = null; + #safariPermissionButton = null; + #safariPasswordImportSkipButton = null; + #safariPasswordImportSelectButton = null; + #selectAllCheckbox = null; + #resourceSummary = null; + #expandedDetails = false; + + static get markup() { + return ` + + `; + } + + static get fragment() { + if (!MigrationWizard.#template) { + let parser = new DOMParser(); + let doc = parser.parseFromString(MigrationWizard.markup, "text/html"); + MigrationWizard.#template = document.importNode( + doc.querySelector("template"), + true + ); + } + let fragment = MigrationWizard.#template.content.cloneNode(true); + if (window.IS_STORYBOOK) { + // If we're using Storybook, load the CSS from the static local file + // system rather than chrome:// to take advantage of auto-reloading. + fragment.querySelector("link[rel=stylesheet]").href = + "./migration/migration-wizard.css"; + } + return fragment; + } + + constructor() { + super(); + const shadow = this.attachShadow({ mode: "closed" }); + + if (window.MozXULElement) { + window.MozXULElement.insertFTLIfNeeded("branding/brand.ftl"); + window.MozXULElement.insertFTLIfNeeded("browser/migrationWizard.ftl"); + } + document.l10n.connectRoot(shadow); + + shadow.appendChild(MigrationWizard.fragment); + + this.#deck = shadow.querySelector("#wizard-deck"); + this.#browserProfileSelector = shadow.querySelector( + "#browser-profile-selector" + ); + this.#resourceSummary = shadow.querySelector("#resource-selection-summary"); + this.#resourceSummary.addEventListener("click", this); + + let cancelCloseButtons = shadow.querySelectorAll(".cancel-close"); + for (let button of cancelCloseButtons) { + button.addEventListener("click", this); + } + + let finishButtons = shadow.querySelectorAll(".finish-button"); + for (let button of finishButtons) { + button.addEventListener("click", this); + } + + this.#importButton = shadow.querySelector("#import"); + this.#importButton.addEventListener("click", this); + this.#importFromFileButton = shadow.querySelector("#import-from-file"); + this.#importFromFileButton.addEventListener("click", this); + this.#chooseImportFromFile = shadow.querySelector( + "#choose-import-from-file" + ); + this.#chooseImportFromFile.addEventListener("click", this); + + this.#browserProfileSelector.addEventListener("click", this); + this.#resourceTypeList = shadow.querySelector("#resource-type-list"); + this.#resourceTypeList.addEventListener("change", this); + + this.#safariPermissionButton = shadow.querySelector( + "#safari-request-permissions" + ); + this.#safariPermissionButton.addEventListener("click", this); + + this.#selectAllCheckbox = shadow.querySelector("#select-all").control; + + this.#safariPasswordImportSkipButton = shadow.querySelector( + "#safari-password-import-skip" + ); + this.#safariPasswordImportSkipButton.addEventListener("click", this); + + this.#safariPasswordImportSelectButton = shadow.querySelector( + "#safari-password-import-select" + ); + this.#safariPasswordImportSelectButton.addEventListener("click", this); + + this.#shadowRoot = shadow; + } + + connectedCallback() { + if (this.hasAttribute("auto-request-state")) { + this.requestState(); + } + } + + requestState() { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { bubbles: true }) + ); + } + + /** + * This setter can be used in the event that the MigrationWizard is being + * inserted via Lit, and the caller wants to set state declaratively using + * a property expression. + * + * @param {object} state + * The state object to pass to setState. + * @see MigrationWizard.setState. + */ + set state(state) { + this.setState(state); + } + + /** + * This is the main entrypoint for updating the state and appearance of + * the wizard. + * + * @param {object} state The state to be represented by the component. + * @param {string} state.page The page of the wizard to display. This should + * be one of the MigrationWizardConstants.PAGES constants. + */ + setState(state) { + switch (state.page) { + case MigrationWizardConstants.PAGES.SELECTION: { + this.#onShowingSelection(state); + break; + } + case MigrationWizardConstants.PAGES.PROGRESS: { + this.#onShowingProgress(state); + break; + } + case MigrationWizardConstants.PAGES.FILE_IMPORT_PROGRESS: { + this.#onShowingFileImportProgress(state); + break; + } + case MigrationWizardConstants.PAGES.NO_BROWSERS_FOUND: { + this.#onShowingNoBrowsersFound(state); + break; + } + } + + this.#deck.toggleAttribute( + "aria-busy", + state.page == MigrationWizardConstants.PAGES.LOADING + ); + this.#deck.setAttribute("selected-view", `page-${state.page}`); + + if (window.IS_STORYBOOK) { + this.#updateForStorybook(); + } + } + + get #dialogMode() { + return this.hasAttribute("dialog-mode"); + } + + #ensureSelectionDropdown() { + if (this.#browserProfileSelectorList) { + return; + } + this.#browserProfileSelectorList = this.querySelector("panel-list"); + if (!this.#browserProfileSelectorList) { + throw new Error( + "Could not find a under the MigrationWizard during initialization." + ); + } + this.#browserProfileSelectorList.toggleAttribute( + "min-width-from-anchor", + true + ); + this.#browserProfileSelectorList.addEventListener("click", this); + // Until bug 1823489 is fixed, this is the easiest way for the + // migration wizard to style the selector dropdown so that it more + // closely lines up with the edges of the selector button. + this.#browserProfileSelectorList.style.boxSizing = "border-box"; + this.#browserProfileSelectorList.style.overflowY = "auto"; + } + + /** + * Reacts to changes to the browser / profile selector dropdown. This + * should update the list of resource types to match what's supported + * by the selected migrator and profile. + * + * @param {Element} panelItem the selected + */ + #onBrowserProfileSelectionChanged(panelItem) { + this.#browserProfileSelector.selectedPanelItem = panelItem; + this.#browserProfileSelector.querySelector("#migrator-name").textContent = + panelItem.displayName; + this.#browserProfileSelector.querySelector("#profile-name").textContent = + panelItem.profile?.name || ""; + + if (panelItem.brandImage) { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = `url(${panelItem.brandImage})`; + } else { + this.#browserProfileSelector.querySelector( + ".migrator-icon" + ).style.content = "url(chrome://global/skin/icons/defaultFavicon.svg)"; + } + + let key = panelItem.getAttribute("key"); + let resourceTypes = panelItem.resourceTypes; + + for (let child of this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + )) { + child.hidden = true; + child.control.checked = false; + } + + for (let resourceType of resourceTypes) { + let resourceLabel = this.#resourceTypeList.querySelector( + `label[data-resource-type="${resourceType}"]` + ); + if (resourceLabel) { + resourceLabel.hidden = false; + resourceLabel.control.checked = true; + + let labelSpan = resourceLabel.querySelector( + "span[default-data-l10n-id]" + ); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + } + } + let selectAll = this.#shadowRoot.querySelector("#select-all").control; + selectAll.checked = true; + + this.#displaySelectedResources(); + this.#browserProfileSelector.selectedPanelItem = panelItem; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.setAttribute("migrator-type", panelItem.getAttribute("type")); + selectionPage.toggleAttribute( + "no-resources", + panelItem.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.BROWSER && !resourceTypes.length + ); + } + + /** + * Called when showing the browser/profile selection page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string[]} state.migrators An array of source browser names that + * can be migrated from. + */ + #onShowingSelection(state) { + this.#ensureSelectionDropdown(); + this.#browserProfileSelectorList.textContent = ""; + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + + let details = this.#shadowRoot.querySelector("details"); + selectionPage.toggleAttribute("show-import-all", state.showImportAll); + details.open = !state.showImportAll; + + this.#expandedDetails = false; + + for (let migrator of state.migrators) { + let opt = document.createElement("panel-item"); + opt.setAttribute("key", migrator.key); + opt.setAttribute("type", migrator.type); + opt.profile = migrator.profile; + opt.displayName = migrator.displayName; + opt.resourceTypes = migrator.resourceTypes; + opt.hasPermissions = migrator.hasPermissions; + opt.brandImage = migrator.brandImage; + + // Bug 1823489 - since the panel-list and panel-items are slotted, we + // cannot style them directly from migration-wizard.css. We use inline + // styles for now to achieve the desired appearance, but bug 1823489 + // will investigate having MigrationWizard own the , + // and 's so that styling can be done in the + // stylesheet instead. + let button = opt.shadowRoot.querySelector("button"); + button.style.minHeight = "40px"; + if (migrator.brandImage) { + button.style.backgroundImage = `url(${migrator.brandImage})`; + } else { + button.style.backgroundImage = `url("chrome://global/skin/icons/defaultFavicon.svg")`; + } + + // Bug 1823489 - since the panel-list and panel-items are slotted, we + // cannot style them or their children in migration-wizard.css. We use + // inline styles for now to achieve the desired appearance, but bug 1823489 + // will investigate having MigrationWizard own the , + // and 's so that styling can be done in the + // stylesheet instead. + if (migrator.type == MigrationWizardConstants.MIGRATOR_TYPES.FILE) { + button.style.backgroundSize = "20px"; + button.style.backgroundPosition = "6px center"; + if (this.#browserProfileSelectorList.isDocumentRTL()) { + button.style.backgroundPositionX = "right 6px"; + } + } + + if (migrator.profile) { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-with-profile", + { + sourceBrowser: migrator.displayName, + profileName: migrator.profile.name, + } + ); + } else { + document.l10n.setAttributes( + opt, + "migration-wizard-selection-option-without-profile", + { + sourceBrowser: migrator.displayName, + } + ); + } + + this.#browserProfileSelectorList.appendChild(opt); + } + + if (state.migrators.length) { + this.#onBrowserProfileSelectionChanged( + this.#browserProfileSelectorList.firstElementChild + ); + } + + // Since this is called before the named-deck actually switches to + // show the selection page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + this.#browserProfileSelector.focus({ focusVisible: false }); + }); + } + + /** + * @typedef {object} ProgressState + * The migration progress state for a resource. + * @property {boolean} inProgress + * True if progress is still underway. + * @property {string} [message=undefined] + * An optional message to display underneath the resource in + * the progress dialog. This message is only shown when inProgress + * is `false`. + */ + + /** + * Called when showing the progress / success page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.key + * The key of the migrator being used. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let successText = group.querySelector(".success-text"); + + let labelSpan = group.querySelector("span[default-data-l10n-id]"); + if (labelSpan) { + if (MigrationWizardConstants.USES_FAVORITES.includes(state.key)) { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("ie-edge-data-l10n-id") + ); + } else { + document.l10n.setAttributes( + labelSpan, + labelSpan.getAttribute("default-data-l10n-id") + ); + } + } + + if (state.progress[resourceType].inProgress) { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.classList.remove("completed"); + successText.textContent = ""; + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + successText.appendChild(document.createTextNode("\u00A0")); + } else { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.classList.add("completed"); + successText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + } + } + + let migrationDone = remainingProgressGroups == 0; + let headerL10nID = migrationDone + ? "migration-wizard-progress-done-header" + : "migration-wizard-progress-header"; + let header = this.#shadowRoot.getElementById("progress-header"); + document.l10n.setAttributes(header, headerL10nID); + + let finishButtons = progressPage.querySelectorAll(".finish-button"); + let cancelButton = progressPage.querySelector(".cancel-close"); + + for (let finishButton of finishButtons) { + finishButton.hidden = !migrationDone; + } + + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + let button = this.#dialogMode + ? progressPage.querySelector(".done-button") + : progressPage.querySelector(".continue-button"); + button.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the progress / success page of the wizard for + * files. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.title + * The string to display in the header. + * @param {Object} state.progress + * An object whose keys match one of DISPLAYED_FILE_RESOURCE_TYPES. + * + * Any resource type not included in state.progress will be hidden. + */ + #onShowingFileImportProgress(state) { + // Any resource progress group not included in state.progress is hidden. + let progressPage = this.#shadowRoot.querySelector( + "div[name='page-file-import-progress']" + ); + let resourceGroups = progressPage.querySelectorAll( + ".resource-progress-group" + ); + let totalProgressGroups = Object.keys(state.progress).length; + let remainingProgressGroups = totalProgressGroups; + + for (let group of resourceGroups) { + let resourceType = group.dataset.resourceType; + if (!state.progress.hasOwnProperty(resourceType)) { + group.hidden = true; + continue; + } + group.hidden = false; + + let progressIcon = group.querySelector(".progress-icon"); + let successText = group.querySelector(".success-text"); + + if (state.progress[resourceType].inProgress) { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-in-progress" + ); + progressIcon.classList.remove("completed"); + successText.textContent = ""; + // With no status text, we re-insert the   so that the status + // text area does not fully collapse. + successText.appendChild(document.createTextNode("\u00A0")); + } else { + document.l10n.setAttributes( + progressIcon, + "migration-wizard-progress-icon-completed" + ); + progressIcon.classList.add("completed"); + successText.textContent = state.progress[resourceType].message; + remainingProgressGroups--; + } + } + + let migrationDone = remainingProgressGroups == 0; + let header = this.#shadowRoot.getElementById("file-import-progress-header"); + header.textContent = state.title; + + let doneButton = progressPage.querySelector(".primary"); + let cancelButton = progressPage.querySelector(".cancel-close"); + doneButton.hidden = !migrationDone; + cancelButton.hidden = migrationDone; + + if (migrationDone) { + // Since this might be called before the named-deck actually switches to + // show the progress page, we cannot focus this button immediately. + // Instead, we use a rAF to queue this up for focusing before the + // next paint. + requestAnimationFrame(() => { + doneButton.focus({ focusVisible: false }); + }); + } + } + + /** + * Called when showing the "no browsers found" page of the wizard. + * + * @param {object} state + * The state object passed into setState. The following properties are + * used: + * @param {string} state.hasFileMigrators + * True if at least one FileMigrator is available for use. + */ + #onShowingNoBrowsersFound(state) { + this.#chooseImportFromFile.hidden = !state.hasFileMigrators; + } + + /** + * Certain parts of the MigrationWizard need to be modified slightly + * in order to work properly with Storybook. This method should be called + * to apply those changes after changing state. + */ + #updateForStorybook() { + // The CSS mask used for the progress spinner cannot be loaded via + // chrome:// URIs in Storybook. We work around this by exposing the + // progress elements as custom parts that the MigrationWizard story + // can style on its own. + this.#shadowRoot.querySelectorAll(".progress-icon").forEach(progressEl => { + if (progressEl.classList.contains("completed")) { + progressEl.removeAttribute("part"); + } else { + progressEl.setAttribute("part", "progress-spinner"); + } + }); + } + + /** + * Takes the current state of the selections page and bundles them + * up into a MigrationWizard:BeginMigration event that can be handled + * externally to perform the actual migration. + */ + #doImport() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:BeginMigration", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * @typedef {object} MigrationDetails + * @property {string} key + * The key for a MigratorBase subclass. + * @property {object|null} profile + * A representation of a browser profile. This is serialized and originally + * sent down from the parent via the GetAvailableMigrators message. + * @property {string[]} resourceTypes + * An array of resource types that the user is attempted to import. These + * strings should be from MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES. + * @property {boolean} hasPermissions + * True if this MigrationWizardChild told us that the associated + * MigratorBase subclass for the key has enough permission to read + * the requested resources. + * @property {boolean} expandedDetails + * True if the user clicked on the element to expand the resource + * type list. + */ + + /** + * Pulls information from the DOM state of the MigrationWizard and constructs + * and returns an object that can be used to begin migration via and event + * sent to the MigrationWizardChild. + * + * @returns {MigrationDetails} details + */ + #gatherMigrationEventDetails() { + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + let type = panelItem.getAttribute("type"); + let profile = panelItem.profile; + let hasPermissions = panelItem.hasPermissions; + + let resourceTypeFields = this.#resourceTypeList.querySelectorAll( + "label[data-resource-type]" + ); + let resourceTypes = []; + for (let resourceTypeField of resourceTypeFields) { + if (resourceTypeField.control.checked) { + resourceTypes.push(resourceTypeField.dataset.resourceType); + } + } + + return { + key, + type, + profile, + resourceTypes, + hasPermissions, + expandedDetails: this.#expandedDetails, + }; + } + + /** + * Sends a request to gain read access to the Safari profile folder on + * macOS, and upon gaining access, performs a migration using the current + * settings as gathered by #gatherMigrationEventDetails + */ + #requestSafariPermissions() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestSafariPermissions", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Sends a request to get a string path for a passwords file exported + * from Safari. + */ + #selectSafariPasswordFile() { + let migrationEventDetail = this.#gatherMigrationEventDetails(); + this.dispatchEvent( + new CustomEvent("MigrationWizard:SelectSafariPasswordFile", { + bubbles: true, + detail: migrationEventDetail, + }) + ); + } + + /** + * Changes selected-data-header text and selected-data text based on + * how many resources are checked + */ + async #displaySelectedResources() { + let resourceTypeLabels = this.#resourceTypeList.querySelectorAll( + "label:not([hidden])[data-resource-type]" + ); + let panelItem = this.#browserProfileSelector.selectedPanelItem; + let key = panelItem.getAttribute("key"); + + let totalResources = resourceTypeLabels.length; + let checkedResources = 0; + + let selectedData = this.#shadowRoot.querySelector(".selected-data"); + let selectedDataArray = []; + let resourceTypeToLabelIDs = { + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS]: + "migration-list-bookmark-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS]: + "migration-list-password-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.HISTORY]: + "migration-list-history-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.FORMDATA]: + "migration-list-autofill-label", + [MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PAYMENT_METHODS]: + "migration-list-payment-methods-label", + }; + + if (MigrationWizardConstants.USES_FAVORITES.includes(key)) { + resourceTypeToLabelIDs[ + MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.BOOKMARKS + ] = "migration-list-favorites-label"; + } + + let resourceTypes = Object.keys(resourceTypeToLabelIDs); + let labelIds = Object.values(resourceTypeToLabelIDs).map(id => { + return { id }; + }); + let labels = await document.l10n.formatValues(labelIds); + let resourceTypeLabelMapping = new Map(); + for (let i = 0; i < resourceTypes.length; ++i) { + let resourceType = resourceTypes[i]; + resourceTypeLabelMapping.set(resourceType, labels[i]); + } + let formatter = new Intl.ListFormat(undefined, { + style: "long", + type: "conjunction", + }); + for (let resourceTypeLabel of resourceTypeLabels) { + if (resourceTypeLabel.control.checked) { + selectedDataArray.push( + resourceTypeLabelMapping.get(resourceTypeLabel.dataset.resourceType) + ); + checkedResources++; + } + } + if (selectedDataArray.length) { + selectedDataArray[0] = + selectedDataArray[0].charAt(0).toLocaleUpperCase() + + selectedDataArray[0].slice(1); + selectedData.textContent = formatter.format(selectedDataArray); + } else { + selectedData.textContent = "\u00A0"; + } + + let selectedDataHeader = this.#shadowRoot.querySelector( + ".selected-data-header" + ); + + let importButton = this.#shadowRoot.querySelector("#import"); + importButton.disabled = checkedResources == 0; + + if (checkedResources == 0) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-no-selected-data-label" + ); + } else if (checkedResources < totalResources) { + document.l10n.setAttributes( + selectedDataHeader, + "migration-selected-data-label" + ); + } else { + document.l10n.setAttributes( + selectedDataHeader, + "migration-all-available-data-label" + ); + } + + let selectionPage = this.#shadowRoot.querySelector( + "div[name='page-selection']" + ); + selectionPage.toggleAttribute("single-item", totalResources == 1); + + this.dispatchEvent( + new CustomEvent("MigrationWizard:ResourcesUpdated", { bubbles: true }) + ); + } + + handleEvent(event) { + switch (event.type) { + case "click": { + if ( + event.target == this.#importButton || + event.target == this.#importFromFileButton + ) { + this.#doImport(); + } else if ( + event.target.classList.contains("cancel-close") || + event.target.classList.contains("finish-button") + ) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:Close", { bubbles: true }) + ); + } else if (event.target == this.#browserProfileSelector) { + this.#browserProfileSelectorList.show(event); + } else if ( + event.currentTarget == this.#browserProfileSelectorList && + event.target != this.#browserProfileSelectorList + ) { + this.#onBrowserProfileSelectionChanged(event.target); + // If the user selected a file migration type from the selector, we'll + // help the user out by immediately starting the file migration flow, + // rather than waiting for them to click the "Select File". + if ( + event.target.getAttribute("type") == + MigrationWizardConstants.MIGRATOR_TYPES.FILE + ) { + this.#doImport(); + } + } else if (event.target == this.#safariPermissionButton) { + this.#requestSafariPermissions(); + } else if (event.currentTarget == this.#resourceSummary) { + this.#expandedDetails = true; + } else if (event.target == this.#chooseImportFromFile) { + this.dispatchEvent( + new CustomEvent("MigrationWizard:RequestState", { + bubbles: true, + detail: { + allowOnlyFileMigrators: true, + }, + }) + ); + } else if (event.target == this.#safariPasswordImportSkipButton) { + // If the user chose to skip importing passwords from Safari, we + // programmatically uncheck the PASSWORDS resource type and re-request + // import. + let checkbox = this.#shadowRoot.querySelector( + `label[data-resource-type="${MigrationWizardConstants.DISPLAYED_RESOURCE_TYPES.PASSWORDS}"]` + ).control; + checkbox.checked = false; + + // If there are no other checked checkboxes, go back to the selection + // screen. + let checked = this.#shadowRoot.querySelectorAll( + `label[data-resource-type] > input:checked` + ).length; + + if (!checked) { + this.requestState(); + } else { + this.#doImport(); + } + } else if (event.target == this.#safariPasswordImportSelectButton) { + this.#selectSafariPasswordFile(); + } + break; + } + case "change": { + if (event.target == this.#browserProfileSelector) { + this.#onBrowserProfileSelectionChanged(); + } else if (event.target == this.#selectAllCheckbox) { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + for (let checkbox of checkboxes) { + checkbox.checked = this.#selectAllCheckbox.checked; + } + this.#displaySelectedResources(); + } else { + let checkboxes = this.#shadowRoot.querySelectorAll( + 'label[data-resource-type]:not([hidden]) > input[type="checkbox"]' + ); + + let allVisibleChecked = Array.from(checkboxes).every(checkbox => { + return checkbox.checked; + }); + + this.#selectAllCheckbox.checked = allVisibleChecked; + this.#displaySelectedResources(); + } + break; + } + } + } +} + +if (globalThis.customElements) { + customElements.define("migration-wizard", MigrationWizard); +} diff --git a/browser/components/migration/content/migration.js b/browser/components/migration/content/migration.js new file mode 100644 index 0000000000..bc27bb4c9d --- /dev/null +++ b/browser/components/migration/content/migration.js @@ -0,0 +1,812 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" +); +const { MigratorBase } = ChromeUtils.importESModule( + "resource:///modules/MigratorBase.sys.mjs" +); + +/** + * Map from data types that match Ci.nsIBrowserProfileMigrator's types to + * prefixes for strings used to label these data types in the migration + * dialog. We use these strings with -checkbox and -label suffixes for the + * checkboxes on the "importItems" page, and for the labels on the "migrating" + * and "done" pages, respectively. + */ +const kDataToStringMap = new Map([ + ["cookies", "browser-data-cookies"], + ["history", "browser-data-history"], + ["formdata", "browser-data-formdata"], + ["passwords", "browser-data-passwords"], + ["bookmarks", "browser-data-bookmarks"], + ["otherdata", "browser-data-otherdata"], + ["session", "browser-data-session"], + ["payment_methods", "browser-data-payment-methods"], +]); + +var MigrationWizard = { + /* exported MigrationWizard */ + _source: "", // Source Profile Migrator ContractID suffix + _itemsFlags: MigrationUtils.resourceTypes.ALL, // Selected Import Data Sources (16-bit bitfield) + _selectedProfile: null, // Selected Profile name to import from + _wiz: null, + _migrator: null, + _autoMigrate: null, + _receivedPermissions: new Set(), + _succeededMigrationEventArgs: null, + + init() { + Services.telemetry.setEventRecordingEnabled("browser.migration", true); + + let os = Services.obs; + os.addObserver(this, "Migration:Started"); + os.addObserver(this, "Migration:ItemBeforeMigrate"); + os.addObserver(this, "Migration:ItemAfterMigrate"); + os.addObserver(this, "Migration:ItemError"); + os.addObserver(this, "Migration:Ended"); + + this._wiz = document.querySelector("wizard"); + + let args = window.arguments[0]?.wrappedJSObject || {}; + let entrypoint = + args.entrypoint || MigrationUtils.MIGRATION_ENTRYPOINTS.UNKNOWN; + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT_CATEGORICAL") + .add(entrypoint); + + // The legacy entrypoint Histogram wasn't categorical, so we translate to the right + // numeric value before writing it. We'll keep this Histogram around to ensure a + // smooth transition to the new FX_MIGRATION_ENTRY_POINT_CATEGORICAL categorical + // histogram. + let entryPointId = MigrationUtils.getLegacyMigrationEntrypoint(entrypoint); + Services.telemetry + .getHistogramById("FX_MIGRATION_ENTRY_POINT") + .add(entryPointId); + + this.isInitialMigration = + entrypoint == MigrationUtils.MIGRATION_ENTRYPOINTS.FIRSTRUN; + + // Record that the uninstaller requested a profile refresh + if (Services.env.get("MOZ_UNINSTALLER_PROFILE_REFRESH")) { + Services.env.set("MOZ_UNINSTALLER_PROFILE_REFRESH", ""); + Services.telemetry.scalarSet( + "migration.uninstaller_profile_refresh", + true + ); + } + + this._source = args.migratorKey; + this._migrator = + args.migrator instanceof MigratorBase ? args.migrator : null; + this._autoMigrate = !!args.isStartupMigration; + this._skipImportSourcePage = !!args.skipSourceSelection; + + if (this._migrator && args.profileId) { + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = sourceProfiles.find( + profile => profile.id == args.profileId + ); + } + + if (this._autoMigrate) { + // Show the "nothing" option in the automigrate case to provide an + // easily identifiable way to avoid migration and create a new profile. + document.getElementById("nothing").hidden = false; + } + + this._setSourceForDataLocalization(); + + document.addEventListener("wizardcancel", function () { + MigrationWizard.onWizardCancel(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageshow", function () { + MigrationWizard.onSelectProfilePageShow(); + }); + document + .getElementById("importItems") + .addEventListener("pageshow", function () { + MigrationWizard.onImportItemsPageShow(); + }); + document + .getElementById("migrating") + .addEventListener("pageshow", function () { + MigrationWizard.onMigratingPageShow(); + }); + document.getElementById("done").addEventListener("pageshow", function () { + MigrationWizard.onDonePageShow(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pagerewound", function () { + MigrationWizard.onSelectProfilePageRewound(); + }); + document + .getElementById("importItems") + .addEventListener("pagerewound", function () { + MigrationWizard.onImportItemsPageRewound(); + }); + + document + .getElementById("selectProfile") + .addEventListener("pageadvanced", function () { + MigrationWizard.onSelectProfilePageAdvanced(); + }); + document + .getElementById("importItems") + .addEventListener("pageadvanced", function () { + MigrationWizard.onImportItemsPageAdvanced(); + }); + document + .getElementById("importPermissions") + .addEventListener("pageadvanced", function (e) { + MigrationWizard.onImportPermissionsPageAdvanced(e); + }); + document + .getElementById("importSource") + .addEventListener("pageadvanced", function (e) { + MigrationWizard.onImportSourcePageAdvanced(e); + }); + + this.recordEvent("opened"); + + this.onImportSourcePageShow(); + }, + + uninit() { + var os = Services.obs; + os.removeObserver(this, "Migration:Started"); + os.removeObserver(this, "Migration:ItemBeforeMigrate"); + os.removeObserver(this, "Migration:ItemAfterMigrate"); + os.removeObserver(this, "Migration:ItemError"); + os.removeObserver(this, "Migration:Ended"); + os.notifyObservers(this, "MigrationWizard:Destroyed"); + MigrationUtils.finishMigration(); + }, + + /** + * Used for recording telemetry in the migration wizard. + * + * @param {string} type + * The type of event being recorded. + * @param {object} args + * The data to pass to telemetry when the event is recorded. + */ + recordEvent(type, args = null) { + Services.telemetry.recordEvent( + "browser.migration", + type, + "legacy_wizard", + null, + args + ); + }, + + spinResolve(promise) { + let canAdvance = this._wiz.canAdvance; + let canRewind = this._wiz.canRewind; + this._wiz.canAdvance = false; + this._wiz.canRewind = false; + let result = MigrationUtils.spinResolve(promise); + this._wiz.canAdvance = canAdvance; + this._wiz.canRewind = canRewind; + return result; + }, + + _setSourceForDataLocalization() { + this._sourceForDataLocalization = this._source; + // Ensure consistency for various channels, brandings and versions of + // Chromium and MS Edge. + if (this._sourceForDataLocalization) { + this._sourceForDataLocalization = this._sourceForDataLocalization + .replace(/^(chromium-edge-beta|chromium-edge)$/, "edge") + .replace(/^(canary|chromium|chrome-beta|chrome-dev)$/, "chrome"); + } + }, + + onWizardCancel() { + MigrationUtils.forceExitSpinResolve(); + return true; + }, + + // 1 - Import Source + onImportSourcePageShow() { + this._wiz.canRewind = false; + + var selectedMigrator = null; + this._availableMigrators = []; + + // Figure out what source apps are are available to import from: + var group = document.getElementById("importSourceGroup"); + for (var i = 0; i < group.childNodes.length; ++i) { + var migratorKey = group.childNodes[i].id; + if (migratorKey != "nothing") { + var migrator = this.spinResolve( + MigrationUtils.getMigrator(migratorKey) + ); + + if (migrator?.enabled) { + // Save this as the first selectable item, if we don't already have + // one, or if it is the migrator that was passed to us. + if (!selectedMigrator || this._source == migratorKey) { + selectedMigrator = group.childNodes[i]; + } + + let profiles = this.spinResolve(migrator.getSourceProfiles()); + if (profiles?.length) { + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + migratorKey, + profiles.length + ); + } else { + Services.telemetry.keyedScalarAdd( + "migration.discovered_migrators", + migratorKey, + 1 + ); + } + + this._availableMigrators.push([migratorKey, migrator]); + } else { + // Hide this option + group.childNodes[i].hidden = true; + } + } + } + if (this.isInitialMigration) { + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_BROWSER_COUNT") + .add(this._availableMigrators.length); + let defaultBrowser = MigrationUtils.getMigratorKeyForDefaultBrowser(); + // This will record 0 for unknown default browser IDs. + defaultBrowser = MigrationUtils.getSourceIdForTelemetry(defaultBrowser); + Services.telemetry + .getHistogramById("FX_STARTUP_MIGRATION_EXISTING_DEFAULT_BROWSER") + .add(defaultBrowser); + } + + if (selectedMigrator) { + group.selectedItem = selectedMigrator; + } else { + this.recordEvent("no_browsers_found"); + // We didn't find a migrator, notify the user + document.getElementById("noSources").hidden = false; + + this._wiz.canAdvance = false; + + document.getElementById("importAll").hidden = true; + } + + // Advance to the next page if the caller told us to. + if (this._migrator && this._skipImportSourcePage) { + this._wiz.advance(); + this._wiz.canRewind = false; + } + }, + + onImportSourcePageAdvanced(event) { + var newSource = + document.getElementById("importSourceGroup").selectedItem.id; + + this.recordEvent("browser_selected", { migrator_key: newSource }); + + if (newSource == "nothing") { + // Need to do telemetry here because we're closing the dialog before we get to + // do actual migration. For actual migration, this doesn't happen until after + // migration takes place. + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry("nothing")); + this._wiz.cancel(); + event.preventDefault(); + } + + if (!this._migrator || newSource != this._source) { + // Create the migrator for the selected source. + this._migrator = this.spinResolve(MigrationUtils.getMigrator(newSource)); + + this._itemsFlags = MigrationUtils.resourceTypes.ALL; + this._selectedProfile = null; + } + this._source = newSource; + this._setSourceForDataLocalization(); + + // check for more than one source profile + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + if (this._skipImportSourcePage) { + this._updateNextPageForPermissions(); + } else if (sourceProfiles && sourceProfiles.length > 1) { + this._wiz.currentPage.next = "selectProfile"; + } else { + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } else { + this._wiz.currentPage.next = "importItems"; + } + + if (sourceProfiles && sourceProfiles.length == 1) { + this._selectedProfile = sourceProfiles[0]; + } else { + this._selectedProfile = null; + } + } + }, + + // 2 - [Profile Selection] + onSelectProfilePageShow() { + // Disabling this for now, since we ask about import sources in automigration + // too and don't want to disable the back button + // if (this._autoMigrate) + // document.documentElement.getButton("back").disabled = true; + + var profiles = document.getElementById("profiles"); + while (profiles.hasChildNodes()) { + profiles.firstChild.remove(); + } + + // Note that this block is still reached even if the user chose 'From File' + // and we canceled the dialog. When that happens, _migrator will be null. + if (this._migrator) { + var sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + + for (let profile of sourceProfiles) { + var item = document.createXULElement("radio"); + item.id = profile.id; + item.setAttribute("label", profile.name); + profiles.appendChild(item); + } + } + + profiles.selectedItem = this._selectedProfile + ? document.getElementById(this._selectedProfile.id) + : profiles.firstChild; + }, + + onSelectProfilePageRewound() { + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + }, + + onSelectProfilePageAdvanced() { + this.recordEvent("profile_selected", { + migrator_key: this._source, + }); + var profiles = document.getElementById("profiles"); + let sourceProfiles = this.spinResolve(this._migrator.getSourceProfiles()); + this._selectedProfile = + sourceProfiles.find(profile => profile.id == profiles.selectedItem.id) || + null; + + // If we're automigrating or just doing bookmarks don't show the item selection page + if (this._autoMigrate) { + this._updateNextPageForPermissions(); + } + }, + + // 3 - ImportItems + onImportItemsPageShow() { + var dataSources = document.getElementById("dataSources"); + while (dataSources.hasChildNodes()) { + dataSources.firstChild.remove(); + } + + var items = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()]; + if (items & itemValue) { + let checkbox = document.createXULElement("checkbox"); + checkbox.id = itemValue; + checkbox.setAttribute("native", true); + document.l10n.setAttributes( + checkbox, + kDataToStringMap.get(itemType) + "-checkbox", + { browser: this._sourceForDataLocalization } + ); + dataSources.appendChild(checkbox); + if (!this._itemsFlags || this._itemsFlags & itemValue) { + checkbox.checked = true; + } + } + } + }, + + onImportItemsPageRewound() { + this._wiz.canAdvance = true; + this.onImportItemsPageAdvanced(true /* viaRewind */); + }, + + onImportItemsPageAdvanced(viaRewind = false) { + let extraKeys = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + + configured: "0", + }; + + 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) { + let flag = parseInt(checkbox.id); + + switch (flag) { + case MigrationUtils.resourceTypes.HISTORY: + extraKeys.history = "1"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + extraKeys.formdata = "1"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + extraKeys.passwords = "1"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + extraKeys.bookmarks = "1"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + extraKeys.payment_methods = "1"; + break; + default: + extraKeys.other++; + } + + this._itemsFlags |= parseInt(checkbox.id); + } + } + + extraKeys.other = String(extraKeys.other); + + if (!viaRewind) { + this.recordEvent("resources_selected", extraKeys); + } + + this._updateNextPageForPermissions(); + }, + + onImportItemCommand() { + var items = document.getElementById("dataSources"); + var checkboxes = items.getElementsByTagName("checkbox"); + + var oneChecked = false; + for (var i = 0; i < checkboxes.length; ++i) { + if (checkboxes[i].checked) { + oneChecked = true; + break; + } + } + + this._wiz.canAdvance = oneChecked; + + this._updateNextPageForPermissions(); + }, + + _updateNextPageForPermissions() { + // We would like to just go straight to work: + this._wiz.currentPage.next = "migrating"; + // If we already have permissions, this is easy: + if (this._receivedPermissions.has(this._source)) { + return; + } + + // Otherwise, if we're on mojave or later and importing from + // Safari, prompt for the bookmarks file. + // We may add other browser/OS combos here in future. + if ( + this._source == "safari" && + AppConstants.isPlatformAndVersionAtLeast("macosx", "18") && + (this._itemsFlags & MigrationUtils.resourceTypes.BOOKMARKS || + this._itemsFlags == MigrationUtils.resourceTypes.ALL) + ) { + let havePermissions = this.spinResolve(this._migrator.hasPermissions()); + + if (!havePermissions) { + this._wiz.currentPage.next = "importPermissions"; + this.recordEvent("safari_perms"); + } + } + }, + + // 3b: permissions. This gets invoked when the user clicks "Next" + async onImportPermissionsPageAdvanced(event) { + // We're done if we have permission: + if (this._receivedPermissions.has(this._source)) { + return; + } + // The wizard helper is sync, and we need to check some stuff, so just stop + // advancing for now and prompt the user, then advance the wizard if everything + // worked. + event.preventDefault(); + + await this._migrator.getPermissions(window); + if (await this._migrator.hasPermissions()) { + this._receivedPermissions.add(this._source); + // Re-enter (we'll then allow the advancement through the early return above) + this._wiz.advance(); + } + // if we didn't have permissions after the `getPermissions` call, the user + // cancelled the dialog. Just no-op out now; the user can re-try by clicking + // the 'Continue' button again, or go back and pick a different browser. + }, + + // 4 - Migrating + onMigratingPageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._wiz.canAdvance = false; + + // When automigrating, show all of the data that can be received from this source. + if (this._autoMigrate) { + this._itemsFlags = this.spinResolve( + this._migrator.getMigrateData(this._selectedProfile) + ); + } + + this._listItems("migratingItems"); + setTimeout(() => this.onMigratingMigrate(), 0); + }, + + async onMigratingMigrate() { + await this._migrator.migrate( + this._itemsFlags, + this._autoMigrate, + this._selectedProfile + ); + + Services.telemetry + .getHistogramById("FX_MIGRATION_SOURCE_BROWSER") + .add(MigrationUtils.getSourceIdForTelemetry(this._source)); + if (!this._autoMigrate) { + let hist = Services.telemetry.getKeyedHistogramById("FX_MIGRATION_USAGE"); + let exp = 0; + let items = this._itemsFlags; + while (items) { + if (items & 1) { + hist.add(this._source, exp); + } + items = items >> 1; + exp++; + } + } + }, + + _listItems(aID) { + var items = document.getElementById(aID); + while (items.hasChildNodes()) { + items.firstChild.remove(); + } + + for (let itemType of kDataToStringMap.keys()) { + let itemValue = MigrationUtils.resourceTypes[itemType.toUpperCase()]; + if (this._itemsFlags & itemValue) { + var label = document.createXULElement("label"); + label.id = itemValue + "_migrated"; + try { + document.l10n.setAttributes( + label, + kDataToStringMap.get(itemType) + "-label", + { browser: this._sourceForDataLocalization } + ); + items.appendChild(label); + } catch (e) { + // if the block above throws, we've enumerated all the import data types we + // currently support and are now just wasting time, break. + break; + } + } + } + }, + + recordResourceMigration(obj, resourceType) { + // Sometimes, the resourceType that gets passed here is a string, which + // is bizarre. We'll hold our nose and accept either a string or a + // number. + resourceType = parseInt(resourceType, 10); + + switch (resourceType) { + case MigrationUtils.resourceTypes.HISTORY: + obj.history = "1"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + obj.formdata = "1"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + obj.passwords = "1"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + obj.bookmarks = "1"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + obj.payment_methods = "1"; + break; + default: + obj.other++; + } + }, + + recordMigrationStartEvent(resourceFlags) { + let extraKeys = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + }; + + for (let resourceTypeKey in MigrationUtils.resourceTypes) { + let resourceType = MigrationUtils.resourceTypes[resourceTypeKey]; + if (resourceFlags & resourceType) { + this.recordResourceMigration(extraKeys, resourceType); + } + } + + extraKeys.other = String(extraKeys.other); + this.recordEvent("migration_started", extraKeys); + }, + + observe(aSubject, aTopic, aData) { + var label; + switch (aTopic) { + case "Migration:Started": + this._succeededMigrationEventArgs = { + migrator_key: this._source, + history: "0", + formdata: "0", + passwords: "0", + bookmarks: "0", + payment_methods: "0", + // "other" will get incremented, so we keep this as a number for + // now, and will cast to a string before submitting to Event telemetry. + other: 0, + }; + this.recordMigrationStartEvent(this._itemsFlags); + break; + case "Migration:ItemBeforeMigrate": + label = document.getElementById(aData + "_migrated"); + if (label) { + label.setAttribute("style", "font-weight: bold"); + } + break; + case "Migration:ItemAfterMigrate": + this.recordResourceMigration(this._succeededMigrationEventArgs, aData); + label = document.getElementById(aData + "_migrated"); + if (label) { + label.removeAttribute("style"); + } + break; + case "Migration:Ended": + this._succeededMigrationEventArgs.other = String( + this._succeededMigrationEventArgs.other + ); + this.recordEvent( + "migration_finished", + this._succeededMigrationEventArgs + ); + + if (this.isInitialMigration) { + // Ensure errors in reporting data recency do not affect the rest of the migration. + try { + this.reportDataRecencyTelemetry(); + } catch (ex) { + console.error(ex); + } + } + if (this._autoMigrate) { + // We're done now. + this._wiz.canAdvance = true; + this._wiz.advance(); + + setTimeout(close, 5000); + } else { + this._wiz.canAdvance = true; + var nextButton = this._wiz.getButton("next"); + nextButton.click(); + } + break; + case "Migration:ItemError": + let type = "undefined"; + let numericType = parseInt(aData); + switch (numericType) { + case MigrationUtils.resourceTypes.COOKIES: + type = "cookies"; + break; + case MigrationUtils.resourceTypes.HISTORY: + type = "history"; + break; + case MigrationUtils.resourceTypes.FORMDATA: + type = "form data"; + break; + case MigrationUtils.resourceTypes.PASSWORDS: + type = "passwords"; + break; + case MigrationUtils.resourceTypes.BOOKMARKS: + type = "bookmarks"; + break; + case MigrationUtils.resourceTypes.PAYMENT_METHODS: + type = "payment methods"; + break; + case MigrationUtils.resourceTypes.OTHERDATA: + type = "misc. data"; + break; + } + Services.console.logStringMessage( + "some " + type + " did not successfully migrate." + ); + Services.telemetry + .getKeyedHistogramById("FX_MIGRATION_ERRORS") + .add(this._source, Math.log2(numericType)); + break; + } + }, + + onDonePageShow() { + this._wiz.getButton("cancel").disabled = true; + this._wiz.canRewind = false; + this._listItems("doneItems"); + }, + + reportDataRecencyTelemetry() { + let histogram = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_DATA_RECENCY" + ); + let lastUsedPromises = []; + for (let [key, migrator] of this._availableMigrators) { + // No block-scoped let in for...of loop conditions, so get the source: + let localKey = key; + lastUsedPromises.push( + migrator.getLastUsedDate().then(date => { + const ONE_YEAR = 24 * 365; + let diffInHours = Math.round((Date.now() - date) / (60 * 60 * 1000)); + if (diffInHours > ONE_YEAR) { + diffInHours = ONE_YEAR; + } + histogram.add(localKey, diffInHours); + return [localKey, diffInHours]; + }) + ); + } + Promise.all(lastUsedPromises).then(migratorUsedTimeDiff => { + // Sort low to high. + migratorUsedTimeDiff.sort( + ([keyA, diffA], [keyB, diffB]) => diffA - diffB + ); /* eslint no-unused-vars: off */ + let usedMostRecentBrowser = + migratorUsedTimeDiff.length && + this._source == migratorUsedTimeDiff[0][0]; + let usedRecentBrowser = Services.telemetry.getKeyedHistogramById( + "FX_STARTUP_MIGRATION_USED_RECENT_BROWSER" + ); + usedRecentBrowser.add(this._source, usedMostRecentBrowser); + }); + }, +}; diff --git a/browser/components/migration/content/migration.xhtml b/browser/components/migration/content/migration.xhtml new file mode 100644 index 0000000000..be6c06c6fb --- /dev/null +++ b/browser/components/migration/content/migration.xhtml @@ -0,0 +1,113 @@ + +# 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/. + + + + + + + + + + + + + + + + + +

+
+ + + +
+

+  
+
diff --git a/browser/components/migration/tests/marionette/manifest.ini b/browser/components/migration/tests/marionette/manifest.ini
new file mode 100644
index 0000000000..afba7ead73
--- /dev/null
+++ b/browser/components/migration/tests/marionette/manifest.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+run-if = buildapp == 'browser'
+
+[test_refresh_firefox.py]
diff --git a/browser/components/migration/tests/marionette/test_refresh_firefox.py b/browser/components/migration/tests/marionette/test_refresh_firefox.py
new file mode 100644
index 0000000000..603534ff17
--- /dev/null
+++ b/browser/components/migration/tests/marionette/test_refresh_firefox.py
@@ -0,0 +1,690 @@
+import os
+import time
+
+from marionette_driver.errors import NoAlertPresentException
+from marionette_harness import MarionetteTestCase
+
+
+# Holds info about things we need to cleanup after the tests are done.
+class PendingCleanup:
+    desktop_backup_path = None
+    reset_profile_path = None
+    reset_profile_local_path = None
+
+    def __init__(self, profile_name_to_remove):
+        self.profile_name_to_remove = profile_name_to_remove
+
+
+class TestFirefoxRefresh(MarionetteTestCase):
+    _sandbox = "firefox-refresh"
+
+    _username = "marionette-test-login"
+    _password = "marionette-test-password"
+    _bookmarkURL = "about:mozilla"
+    _bookmarkText = "Some bookmark from Marionette"
+
+    _cookieHost = "firefox-refresh.marionette-test.mozilla.org"
+    _cookiePath = "some/cookie/path"
+    _cookieName = "somecookie"
+    _cookieValue = "some cookie value"
+
+    _historyURL = "http://firefox-refresh.marionette-test.mozilla.org/"
+    _historyTitle = "Test visit for Firefox Reset"
+
+    _formHistoryFieldName = "some-very-unique-marionette-only-firefox-reset-field"
+    _formHistoryValue = "special-pumpkin-value"
+
+    _formAutofillAvailable = False
+    _formAutofillAddressGuid = None
+
+    _expectedURLs = ["about:robots", "about:mozilla"]
+
+    def savePassword(self):
+        self.runAsyncCode(
+            """
+          let [username, password, resolve] = arguments;
+          let myLogin = new global.LoginInfo(
+            "test.marionette.mozilla.com",
+            "http://test.marionette.mozilla.com/some/form/",
+            null,
+            username,
+            password,
+            "username",
+            "password"
+          );
+          Services.logins.addLoginAsync(myLogin)
+            .then(() => resolve(false), resolve);
+        """,
+            script_args=(self._username, self._password),
+        )
+
+    def createBookmarkInMenu(self):
+        error = self.runAsyncCode(
+            """
+          // let url = arguments[0];
+          // let title = arguments[1];
+          // let resolve = arguments[arguments.length - 1];
+          let [url, title, resolve] = arguments;
+          PlacesUtils.bookmarks.insert({
+            parentGuid: PlacesUtils.bookmarks.menuGuid, url, title
+          }).then(() => resolve(false), resolve);
+        """,
+            script_args=(self._bookmarkURL, self._bookmarkText),
+        )
+        if error:
+            print(error)
+
+    def createBookmarksOnToolbar(self):
+        error = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          let children = [];
+          for (let i = 1; i <= 5; i++) {
+            children.push({url: `about:rights?p=${i}`, title: `Bookmark ${i}`});
+          }
+          PlacesUtils.bookmarks.insertTree({
+            guid: PlacesUtils.bookmarks.toolbarGuid,
+            children
+          }).then(() => resolve(false), resolve);
+        """
+        )
+        if error:
+            print(error)
+
+    def createHistory(self):
+        error = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          PlacesUtils.history.insert({
+            url: arguments[0],
+            title: arguments[1],
+            visits: [{
+              date: new Date(Date.now() - 5000),
+              referrer: "about:mozilla"
+            }]
+          }).then(() => resolve(false),
+                  ex => resolve("Unexpected error in adding visit: " + ex));
+        """,
+            script_args=(self._historyURL, self._historyTitle),
+        )
+        if error:
+            print(error)
+
+    def createFormHistory(self):
+        error = self.runAsyncCode(
+            """
+          let updateDefinition = {
+            op: "add",
+            fieldname: arguments[0],
+            value: arguments[1],
+            firstUsed: (Date.now() - 5000) * 1000,
+          };
+          let resolve = arguments[arguments.length - 1];
+          global.FormHistory.update(updateDefinition).then(() => {
+            resolve(false);
+          }, error => {
+            resolve("Unexpected error in adding formhistory: " + error);
+          });
+        """,
+            script_args=(self._formHistoryFieldName, self._formHistoryValue),
+        )
+        if error:
+            print(error)
+
+    def createFormAutofill(self):
+        if not self._formAutofillAvailable:
+            return
+        self._formAutofillAddressGuid = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          const TEST_ADDRESS_1 = {
+            "given-name": "John",
+            "additional-name": "R.",
+            "family-name": "Smith",
+            organization: "World Wide Web Consortium",
+            "street-address": "32 Vassar Street\\\nMIT Room 32-G524",
+            "address-level2": "Cambridge",
+            "address-level1": "MA",
+            "postal-code": "02139",
+            country: "US",
+            tel: "+15195555555",
+            email: "user@example.com",
+          };
+          return global.formAutofillStorage.initialize().then(() => {
+            return global.formAutofillStorage.addresses.add(TEST_ADDRESS_1);
+          }).then(resolve);
+        """
+        )
+
+    def createCookie(self):
+        self.runCode(
+            """
+          // Expire in 15 minutes:
+          let expireTime = Math.floor(Date.now() / 1000) + 15 * 60;
+          Services.cookies.add(arguments[0], arguments[1], arguments[2], arguments[3],
+                               true, false, false, expireTime, {},
+                               Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_UNSET);
+        """,
+            script_args=(
+                self._cookieHost,
+                self._cookiePath,
+                self._cookieName,
+                self._cookieValue,
+            ),
+        )
+
+    def createSession(self):
+        self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          const COMPLETE_STATE = Ci.nsIWebProgressListener.STATE_STOP +
+                                 Ci.nsIWebProgressListener.STATE_IS_NETWORK;
+          let { TabStateFlusher } = ChromeUtils.importESModule(
+            "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+          );
+          let expectedURLs = Array.from(arguments[0])
+          gBrowser.addTabsProgressListener({
+            onStateChange(browser, webprogress, request, flags, status) {
+              try {
+                request && request.QueryInterface(Ci.nsIChannel);
+              } catch (ex) {}
+              let uriLoaded = request.originalURI && request.originalURI.spec;
+              if ((flags & COMPLETE_STATE == COMPLETE_STATE) && uriLoaded &&
+                  expectedURLs.includes(uriLoaded)) {
+                TabStateFlusher.flush(browser).then(function() {
+                  expectedURLs.splice(expectedURLs.indexOf(uriLoaded), 1);
+                  if (!expectedURLs.length) {
+                    gBrowser.removeTabsProgressListener(this);
+                    resolve();
+                  }
+                });
+              }
+            }
+          });
+          let expectedTabs = new Set();
+          for (let url of expectedURLs) {
+            expectedTabs.add(gBrowser.addTab(url, {
+              triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+            }));
+          }
+          // Close any other tabs that might be open:
+          let allTabs = Array.from(gBrowser.tabs);
+          for (let tab of allTabs) {
+            if (!expectedTabs.has(tab)) {
+              gBrowser.removeTab(tab);
+            }
+          }
+        """,  # NOQA: E501
+            script_args=(self._expectedURLs,),
+        )
+
+    def createFxa(self):
+        # This script will write an entry to the login manager and create
+        # a signedInUser.json in the profile dir.
+        self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          let { FxAccountsStorageManager } = ChromeUtils.import(
+            "resource://gre/modules/FxAccountsStorage.jsm"
+          );
+          let storage = new FxAccountsStorageManager();
+          let data = {email: "test@test.com", uid: "uid", keyFetchToken: "top-secret"};
+          storage.initialize(data);
+          storage.finalize().then(resolve);
+        """
+        )
+
+    def createSync(self):
+        # This script will write the canonical preference which indicates a user
+        # is signed into sync.
+        self.marionette.execute_script(
+            """
+            Services.prefs.setStringPref("services.sync.username", "test@test.com");
+        """
+        )
+
+    def checkPassword(self):
+        loginInfo = self.marionette.execute_script(
+            """
+          let ary = Services.logins.findLogins(
+            "test.marionette.mozilla.com",
+            "http://test.marionette.mozilla.com/some/form/",
+            null, {});
+          return ary.length ? ary : {username: "null", password: "null"};
+        """
+        )
+        self.assertEqual(len(loginInfo), 1)
+        self.assertEqual(loginInfo[0]["username"], self._username)
+        self.assertEqual(loginInfo[0]["password"], self._password)
+
+        loginCount = self.marionette.execute_script(
+            """
+          return Services.logins.getAllLogins().length;
+        """
+        )
+        # Note that we expect 2 logins - one from us, one from sync.
+        self.assertEqual(loginCount, 2, "No other logins are present")
+
+    def checkBookmarkInMenu(self):
+        titleInBookmarks = self.runAsyncCode(
+            """
+          let [url, resolve] = arguments;
+          PlacesUtils.bookmarks.fetch({url}).then(
+            bookmark => resolve(bookmark ? bookmark.title : ""),
+            ex => resolve(ex)
+          );
+        """,
+            script_args=(self._bookmarkURL,),
+        )
+        self.assertEqual(titleInBookmarks, self._bookmarkText)
+
+    def checkBookmarkToolbarVisibility(self):
+        toolbarVisible = self.marionette.execute_script(
+            """
+          const BROWSER_DOCURL = AppConstants.BROWSER_CHROME_URL;
+          return Services.xulStore.getValue(BROWSER_DOCURL, "PersonalToolbar", "collapsed");
+        """
+        )
+        if toolbarVisible == "":
+            toolbarVisible = "false"
+        self.assertEqual(toolbarVisible, "false")
+
+    def checkHistory(self):
+        historyResult = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          PlacesUtils.history.fetch(arguments[0]).then(pageInfo => {
+            if (!pageInfo) {
+              resolve("No visits found");
+            } else {
+              resolve(pageInfo);
+            }
+          }).catch(e => {
+            resolve("Unexpected error in fetching page: " + e);
+          });
+        """,
+            script_args=(self._historyURL,),
+        )
+        if type(historyResult) == str:
+            self.fail(historyResult)
+            return
+
+        self.assertEqual(historyResult["title"], self._historyTitle)
+
+    def checkFormHistory(self):
+        formFieldResults = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          let results = [];
+          global.FormHistory.search(["value"], {fieldname: arguments[0]})
+            .then(resolve);
+        """,
+            script_args=(self._formHistoryFieldName,),
+        )
+        if type(formFieldResults) == str:
+            self.fail(formFieldResults)
+            return
+
+        formFieldResultCount = len(formFieldResults)
+        self.assertEqual(
+            formFieldResultCount,
+            1,
+            "Should have exactly 1 entry for this field, got %d" % formFieldResultCount,
+        )
+        if formFieldResultCount == 1:
+            self.assertEqual(formFieldResults[0]["value"], self._formHistoryValue)
+
+        formHistoryCount = self.runAsyncCode(
+            """
+          let [resolve] = arguments;
+          global.FormHistory.count({}).then(resolve);
+        """
+        )
+        self.assertEqual(
+            formHistoryCount, 1, "There should be only 1 entry in the form history"
+        )
+
+    def checkFormAutofill(self):
+        if not self._formAutofillAvailable:
+            return
+
+        formAutofillResults = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1];
+          return global.formAutofillStorage.initialize().then(() => {
+            return global.formAutofillStorage.addresses.getAll()
+          }).then(resolve);
+        """,
+        )
+        if type(formAutofillResults) == str:
+            self.fail(formAutofillResults)
+            return
+
+        formAutofillAddressCount = len(formAutofillResults)
+        self.assertEqual(
+            formAutofillAddressCount,
+            1,
+            "Should have exactly 1 saved address, got %d" % formAutofillAddressCount,
+        )
+        if formAutofillAddressCount == 1:
+            self.assertEqual(
+                formAutofillResults[0]["guid"], self._formAutofillAddressGuid
+            )
+
+    def checkCookie(self):
+        cookieInfo = self.runCode(
+            """
+          try {
+            let cookies = Services.cookies.getCookiesFromHost(arguments[0], {});
+            let cookie = null;
+            for (let hostCookie of cookies) {
+              // getCookiesFromHost returns any cookie from the BASE host.
+              if (hostCookie.rawHost != arguments[0])
+                continue;
+              if (cookie != null) {
+                return "more than 1 cookie! That shouldn't happen!";
+              }
+              cookie = hostCookie;
+            }
+            return {path: cookie.path, name: cookie.name, value: cookie.value};
+          } catch (ex) {
+            return "got exception trying to fetch cookie: " + ex;
+          }
+        """,
+            script_args=(self._cookieHost,),
+        )
+        if not isinstance(cookieInfo, dict):
+            self.fail(cookieInfo)
+            return
+        self.assertEqual(cookieInfo["path"], self._cookiePath)
+        self.assertEqual(cookieInfo["value"], self._cookieValue)
+        self.assertEqual(cookieInfo["name"], self._cookieName)
+
+    def checkSession(self):
+        tabURIs = self.runCode(
+            """
+          return [... gBrowser.browsers].map(b => b.currentURI && b.currentURI.spec)
+        """
+        )
+        self.assertSequenceEqual(tabURIs, ["about:welcomeback"])
+
+        # Dismiss modal dialog if any. This is mainly to dismiss the check for
+        # default browser dialog if it shows up.
+        try:
+            alert = self.marionette.switch_to_alert()
+            alert.dismiss()
+        except NoAlertPresentException:
+            pass
+
+        tabURIs = self.runAsyncCode(
+            """
+          let resolve = arguments[arguments.length - 1]
+          let mm = gBrowser.selectedBrowser.messageManager;
+
+          window.addEventListener("SSWindowStateReady", function() {
+            window.addEventListener("SSTabRestored", function() {
+              resolve(Array.from(gBrowser.browsers, b => b.currentURI?.spec));
+            }, { capture: false, once: true });
+          }, { capture: false, once: true });
+
+          let fs = function() {
+            if (content.document.readyState === "complete") {
+              content.document.getElementById("errorTryAgain").click();
+            } else {
+              content.window.addEventListener("load", function(event) {
+                content.document.getElementById("errorTryAgain").click();
+              }, { once: true });
+            }
+          };
+
+          Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", true);
+          mm.loadFrameScript("data:application/javascript,(" + fs.toString() + ")()", true);
+          Services.prefs.setBoolPref("security.allow_parent_unrestricted_js_loads", false);
+        """  # NOQA: E501
+        )
+        self.assertSequenceEqual(tabURIs, self._expectedURLs)
+
+    def checkFxA(self):
+        result = self.runAsyncCode(
+            """
+          let { FxAccountsStorageManager } = ChromeUtils.import(
+            "resource://gre/modules/FxAccountsStorage.jsm"
+          );
+          let resolve = arguments[arguments.length - 1];
+          let storage = new FxAccountsStorageManager();
+          let result = {};
+          storage.initialize();
+          storage.getAccountData().then(data => {
+            result.accountData = data;
+            return storage.finalize();
+          }).then(() => {
+            resolve(result);
+          }).catch(err => {
+            resolve(err.toString());
+          });
+        """
+        )
+        if type(result) != dict:
+            self.fail(result)
+            return
+        self.assertEqual(result["accountData"]["email"], "test@test.com")
+        self.assertEqual(result["accountData"]["uid"], "uid")
+        self.assertEqual(result["accountData"]["keyFetchToken"], "top-secret")
+
+    def checkSync(self, expect_sync_user):
+        pref_value = self.marionette.execute_script(
+            """
+            return Services.prefs.getStringPref("services.sync.username", null);
+        """
+        )
+        expected_value = "test@test.com" if expect_sync_user else None
+        self.assertEqual(pref_value, expected_value)
+
+    def checkProfile(self, has_migrated=False, expect_sync_user=True):
+        self.checkPassword()
+        self.checkBookmarkInMenu()
+        self.checkHistory()
+        self.checkFormHistory()
+        self.checkFormAutofill()
+        self.checkCookie()
+        self.checkFxA()
+        self.checkSync(expect_sync_user)
+        if has_migrated:
+            self.checkBookmarkToolbarVisibility()
+            self.checkSession()
+
+    def createProfileData(self):
+        self.savePassword()
+        self.createBookmarkInMenu()
+        self.createBookmarksOnToolbar()
+        self.createHistory()
+        self.createFormHistory()
+        self.createFormAutofill()
+        self.createCookie()
+        self.createSession()
+        self.createFxa()
+        self.createSync()
+
+    def setUpScriptData(self):
+        self.marionette.set_context(self.marionette.CONTEXT_CHROME)
+        self.runCode(
+            """
+          window.global = {};
+          global.LoginInfo = Components.Constructor("@mozilla.org/login-manager/loginInfo;1", "nsILoginInfo", "init");
+          global.profSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService(Ci.nsIToolkitProfileService);
+          global.Preferences = ChromeUtils.importESModule(
+            "resource://gre/modules/Preferences.sys.mjs"
+          ).Preferences;
+          global.FormHistory = ChromeUtils.import(
+            "resource://gre/modules/FormHistory.jsm"
+          ).FormHistory;
+        """  # NOQA: E501
+        )
+        self._formAutofillAvailable = self.runCode(
+            """
+          try {
+            global.formAutofillStorage = ChromeUtils.import(
+              "resource://formautofill/FormAutofillStorage.jsm"
+            ).formAutofillStorage;
+          } catch(e) {
+            return false;
+          }
+          return true;
+        """  # NOQA: E501
+        )
+
+    def runCode(self, script, *args, **kwargs):
+        return self.marionette.execute_script(
+            script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
+        )
+
+    def runAsyncCode(self, script, *args, **kwargs):
+        return self.marionette.execute_async_script(
+            script, new_sandbox=False, sandbox=self._sandbox, *args, **kwargs
+        )
+
+    def setUp(self):
+        MarionetteTestCase.setUp(self)
+        self.setUpScriptData()
+
+        self.cleanups = []
+
+    def tearDown(self):
+        # Force yet another restart with a clean profile to disconnect from the
+        # profile and environment changes we've made, to leave a more or less
+        # blank slate for the next person.
+        self.marionette.restart(in_app=False, clean=True)
+        self.setUpScriptData()
+
+        # Super
+        MarionetteTestCase.tearDown(self)
+
+        # A helper to deal with removing a load of files
+        import mozfile
+
+        for cleanup in self.cleanups:
+            if cleanup.desktop_backup_path:
+                mozfile.remove(cleanup.desktop_backup_path)
+
+            if cleanup.reset_profile_path:
+                # Remove ourselves from profiles.ini
+                self.runCode(
+                    """
+                  let name = arguments[0];
+                  let profile = global.profSvc.getProfileByName(name);
+                  profile.remove(false)
+                  global.profSvc.flush();
+                """,
+                    script_args=(cleanup.profile_name_to_remove,),
+                )
+                # Remove the local profile dir if it's not the same as the profile dir:
+                different_path = (
+                    cleanup.reset_profile_local_path != cleanup.reset_profile_path
+                )
+                if cleanup.reset_profile_local_path and different_path:
+                    mozfile.remove(cleanup.reset_profile_local_path)
+
+                # And delete all the files.
+                mozfile.remove(cleanup.reset_profile_path)
+
+    def doReset(self):
+        profileName = "marionette-test-profile-" + str(int(time.time() * 1000))
+        cleanup = PendingCleanup(profileName)
+        self.runCode(
+            """
+          // Ensure the current (temporary) profile is in profiles.ini:
+          let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+          let profileName = arguments[1];
+          let myProfile = global.profSvc.createProfile(profD, profileName);
+          global.profSvc.flush()
+
+          // Now add the reset parameters:
+          let prefsToKeep = Array.from(Services.prefs.getChildList("marionette."));
+          // Add all the modified preferences set from geckoinstance.py to avoid
+          // non-local connections.
+          prefsToKeep = prefsToKeep.concat(JSON.parse(
+              Services.env.get("MOZ_MARIONETTE_REQUIRED_PREFS")));
+          let prefObj = {};
+          for (let pref of prefsToKeep) {
+            prefObj[pref] = global.Preferences.get(pref);
+          }
+          Services.env.set("MOZ_MARIONETTE_PREF_STATE_ACROSS_RESTARTS", JSON.stringify(prefObj));
+          Services.env.set("MOZ_RESET_PROFILE_RESTART", "1");
+          Services.env.set("XRE_PROFILE_PATH", arguments[0]);
+        """,
+            script_args=(
+                self.marionette.instance.profile.profile,
+                profileName,
+            ),
+        )
+
+        profileLeafName = os.path.basename(
+            os.path.normpath(self.marionette.instance.profile.profile)
+        )
+
+        # Now restart the browser to get it reset:
+        self.marionette.restart(clean=False, in_app=True)
+        self.setUpScriptData()
+
+        # Determine the new profile path (we'll need to remove it when we're done)
+        [cleanup.reset_profile_path, cleanup.reset_profile_local_path] = self.runCode(
+            """
+          let profD = Services.dirsvc.get("ProfD", Ci.nsIFile);
+          let localD = Services.dirsvc.get("ProfLD", Ci.nsIFile);
+          return [profD.path, localD.path];
+        """
+        )
+
+        # Determine the backup path
+        cleanup.desktop_backup_path = self.runCode(
+            """
+          let container;
+          try {
+            container = Services.dirsvc.get("Desk", Ci.nsIFile);
+          } catch (ex) {
+            container = Services.dirsvc.get("Home", Ci.nsIFile);
+          }
+          let bundle = Services.strings.createBundle("chrome://mozapps/locale/profile/profileSelection.properties");
+          let dirName = bundle.formatStringFromName("resetBackupDirectory", [Services.appinfo.name]);
+          container.append(dirName);
+          container.append(arguments[0]);
+          return container.path;
+        """,  # NOQA: E501
+            script_args=(profileLeafName,),
+        )
+
+        self.assertTrue(
+            os.path.isdir(cleanup.reset_profile_path),
+            "Reset profile path should be present",
+        )
+        self.assertTrue(
+            os.path.isdir(cleanup.desktop_backup_path),
+            "Backup profile path should be present",
+        )
+        self.assertIn(cleanup.profile_name_to_remove, cleanup.reset_profile_path)
+        return cleanup
+
+    def testResetEverything(self):
+        self.createProfileData()
+
+        self.checkProfile(expect_sync_user=True)
+
+        this_cleanup = self.doReset()
+        self.cleanups.append(this_cleanup)
+
+        # Now check that we're doing OK...
+        self.checkProfile(has_migrated=True, expect_sync_user=True)
+
+    def testFxANoSync(self):
+        # This test doesn't need to repeat all the non-sync tests...
+        # Setup FxA but *not* sync
+        self.createFxa()
+
+        self.checkFxA()
+        self.checkSync(False)
+
+        this_cleanup = self.doReset()
+        self.cleanups.append(this_cleanup)
+
+        self.checkFxA()
+        self.checkSync(False)
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons
new file mode 100644
index 0000000000..fddee798b3
Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Favicons differ
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data
new file mode 100644
index 0000000000..7e6e843a03
Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Login Data differ
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data
new file mode 100644
index 0000000000..c557c9b851
Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Default/Web Data differ
diff --git a/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State
new file mode 100644
index 0000000000..3f3fecb651
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Local/Google/Chrome/User Data/Local State	
@@ -0,0 +1,5 @@
+{
+  "os_crypt" : {
+    "encrypted_key" : "RFBBUEk/ThisNPAPIKeyCanOnlyBeDecryptedByTheOriginalDeviceSoThisWillThrowFromDecryptData"
+  }
+}
diff --git a/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data
new file mode 100644
index 0000000000..fd135624c4
Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/LocalWithNoData/Google/Chrome/User Data/Default/Login Data differ
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
new file mode 100644
index 0000000000..1835c33583
Binary files /dev/null and b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat differ
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks
new file mode 100644
index 0000000000..f51195f54c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/360Bookmarks	
@@ -0,0 +1 @@
+Encrypted canonical bookmarks storage, since 360 SE 10
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb
new file mode 100644
index 0000000000..ea466a25bf
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2020_08_28.favdb	
@@ -0,0 +1,3 @@
+{
+    "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb
new file mode 100644
index 0000000000..ea466a25bf
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360default_ori_2021_12_02.favdb	
@@ -0,0 +1,3 @@
+{
+    "note": "Plain text bookmarks backup, since 360 SE 10."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb
new file mode 100644
index 0000000000..32b4002a32
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2020_08_28.favdb	
@@ -0,0 +1 @@
+Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb
new file mode 100644
index 0000000000..32b4002a32
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default/DailyBackup/360sefav_new_2021_12_02.favdb	
@@ -0,0 +1 @@
+Encrypted bookmarks backup, since 360 SE 10.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat
new file mode 100644
index 0000000000..440e7145bd
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/360sefav.dat	
@@ -0,0 +1 @@
+Bookmarks storage in legacy SQLite format.
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb
new file mode 100644
index 0000000000..d5d939629c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/0f3ab103a522f4463ecacc36d34eb996/DailyBackup/360sefav_2020_08_28.favdb	
@@ -0,0 +1,3 @@
+{
+    "note": "Plain text bookmarks backup, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks
new file mode 100644
index 0000000000..6f47e5a55c
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Default4SE9Test/Bookmarks	
@@ -0,0 +1,3 @@
+{
+    "note": "Plain text canonical bookmarks storage, 360 SE 9."
+}
diff --git a/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State
new file mode 100644
index 0000000000..dd3fecce45
--- /dev/null
+++ b/browser/components/migration/tests/unit/AppData/Roaming/360se6/User Data/Local State	
@@ -0,0 +1,12 @@
+{
+    "profile": {
+        "info_cache": {
+            "Default": {
+                "name": "用户1"
+            }
+        }
+    },
+    "sync_login_info": {
+        "filepath": "0f3ab103a522f4463ecacc36d34eb996"
+    }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies
new file mode 100644
index 0000000000..83d855cb33
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Cookies differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json
new file mode 100644
index 0000000000..44e855edbd
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/_locales/en_US/messages.json	
@@ -0,0 +1,9 @@
+{
+   "description": {
+      "description": "Extension description in manifest. Should not exceed 132 characters.",
+      "message": "It is the description of fake app 1."
+   },
+   "name": {
+      "message": "Fake App 1"
+   }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json
new file mode 100644
index 0000000000..1550bf1c0e
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-app-1/1.0_0/manifest.json	
@@ -0,0 +1,10 @@
+{
+   "app": {
+      "launch": {
+         "local_path": "main.html"
+      }
+   },
+   "default_locale": "en_US",
+   "description": "__MSG_description__",
+   "name": "__MSG_name__"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json
new file mode 100644
index 0000000000..11657460d8
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/_locales/en_US/messages.json	
@@ -0,0 +1,9 @@
+{
+   "description": {
+      "description": "Extension description in manifest. Should not exceed 132 characters.",
+      "message": "It is the description of fake extension 1."
+   },
+   "name": {
+      "message": "Fake Extension 1"
+   }
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json
new file mode 100644
index 0000000000..5ceced8031
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-1/1.0_0/manifest.json	
@@ -0,0 +1,5 @@
+{
+   "default_locale": "en_US",
+   "description": "__MSG_description__",
+   "name": "__MSG_name__"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json
new file mode 100644
index 0000000000..0333a91e56
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Extensions/fake-extension-2/1.0_0/manifest.json	
@@ -0,0 +1,5 @@
+{
+   "default_locale": "en_US",
+   "description": "It is the description of fake extension 2.",
+   "name": "Fake Extension 2"
+}
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt
new file mode 100644
index 0000000000..8585f308c5
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryCorrupt differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster
new file mode 100644
index 0000000000..7fb19903b0
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/HistoryMaster differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data
new file mode 100644
index 0000000000..19b8542b98
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Default/Login Data differ
diff --git a/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State
new file mode 100644
index 0000000000..01b99455e4
--- /dev/null
+++ b/browser/components/migration/tests/unit/Library/Application Support/Google/Chrome/Local State	
@@ -0,0 +1,22 @@
+{
+  "profile" : {
+    "info_cache" : {
+      "Default" : {
+        "active_time" : 1430950755.65137,
+        "is_using_default_name" : true,
+        "is_ephemeral" : false,
+        "is_omitted_from_profile_list" : false,
+        "user_name" : "",
+        "background_apps" : false,
+        "is_using_default_avatar" : true,
+        "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
+        "name" : "Person 1"
+      }
+    },
+    "profiles_created" : 1,
+    "last_used" : "Default",
+    "last_active_profiles" : [
+      "Default"
+    ]
+  }
+}
diff --git a/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist
new file mode 100644
index 0000000000..a9c33e1b1a
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Bookmarks.plist differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db
new file mode 100644
index 0000000000..dd5d0c7512
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-lock
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm
new file mode 100644
index 0000000000..edd607898b
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-shm differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal
new file mode 100644
index 0000000000..e145119298
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons.db-wal differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9
new file mode 100644
index 0000000000..1c6741c165
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/04860FA3D07D8936B87D2B965317C6E9 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271
new file mode 100644
index 0000000000..47b40f707f
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/19A777E4F7BDA0C0E350D6C681B6E271 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42
new file mode 100644
index 0000000000..2a4c30b31e
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/2558A57A1AE576AA31F0DCD1364B3F42 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804
new file mode 100644
index 0000000000..f4996ba082
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/57D1907A1EBDA1889AA85B8AB7A90804 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80
new file mode 100644
index 0000000000..f519ce9ad2
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/6EEDD53B65A19CB364EB6FB07DEACF80 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F
new file mode 100644
index 0000000000..e70021849b
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/7F65370AD319C7B294EDF2E2BEBA880F differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8
new file mode 100644
index 0000000000..559502b02b
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/999E2BD5CD612AA550F222A1088DB3D8 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A
new file mode 100644
index 0000000000..89ed9a1c39
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/9D8A6E2153D42043A7AE0430B41D374A differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC
new file mode 100644
index 0000000000..7b86185e67
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/A21F634481CF5188329FD2052F07ADBC differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08
new file mode 100644
index 0000000000..a1d03856b5
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/BC2288B5BA9B7BE352BA586257442E08 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B
new file mode 100644
index 0000000000..ba1145ca83
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/CFFC3831D8E7201BF8B77728FC79B52B differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137
new file mode 100644
index 0000000000..82339b3b1d
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/Favicon Cache/favicons/F3FA61DDA95B78A8B5F2C392C0382137 differ
diff --git a/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db
new file mode 100644
index 0000000000..5a317c70e8
Binary files /dev/null and b/browser/components/migration/tests/unit/Library/Safari/HistoryTemplate.db differ
diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data
new file mode 100644
index 0000000000..b2d425eb4a
Binary files /dev/null and b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Default/Login Data differ
diff --git a/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State
new file mode 100644
index 0000000000..bb03d6b9a1
--- /dev/null
+++ b/browser/components/migration/tests/unit/LibraryWithNoData/Application Support/Google/Chrome/Local State	
@@ -0,0 +1,22 @@
+{
+  "profile" : {
+    "info_cache" : {
+      "Default" : {
+        "active_time" : 1430950755.65137,
+        "is_using_default_name" : true,
+        "is_ephemeral" : false,
+        "is_omitted_from_profile_list" : false,
+        "user_name" : "",
+        "background_apps" : false,
+        "is_using_default_avatar" : true,
+        "avatar_icon" : "chrome://theme/IDR_PROFILE_AVATAR_0",
+        "name" : "Person With No Data"
+      }
+    },
+    "profiles_created" : 1,
+    "last_used" : "Default",
+    "last_active_profiles" : [
+      "Default"
+    ]
+  }
+}
diff --git a/browser/components/migration/tests/unit/bookmarks.exported.html b/browser/components/migration/tests/unit/bookmarks.exported.html
new file mode 100644
index 0000000000..5a9ec43325
--- /dev/null
+++ b/browser/components/migration/tests/unit/bookmarks.exported.html
@@ -0,0 +1,32 @@
+
+
+
+
+Bookmarks
+

Bookmarks Menu

+ +

+

Mozilla Firefox

+

+

Help and Tutorials +
Customize Firefox +
Get Involved +
About Us +

+


test

+

+

test post keyword +

+

Bookmarks Toolbar

+

+

Getting Started +
Latest Headlines +

+

Other Bookmarks

+

+

Example.tld +

+

diff --git a/browser/components/migration/tests/unit/bookmarks.exported.json b/browser/components/migration/tests/unit/bookmarks.exported.json new file mode 100644 index 0000000000..2a73f00b31 --- /dev/null +++ b/browser/components/migration/tests/unit/bookmarks.exported.json @@ -0,0 +1,194 @@ +{ + "guid": "root________", + "title": "", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 1, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "placesRoot", + "children": [ + { + "guid": "menu________", + "title": "menu", + "index": 0, + "dateAdded": 1685116351936000, + "lastModified": 1685116352325000, + "id": 2, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "bookmarksMenuFolder", + "children": [ + { + "guid": "jCs_9YrgXKq7", + "title": "Firefox Nightly Resources", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 7, + "typeCode": 2, + "type": "text/x-moz-place-container", + "children": [ + { + "guid": "xwdRLsUWYFwm", + "title": "Firefox Nightly blog", + "index": 0, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 8, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://blog.nightly.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://blog.nightly.mozilla.org/" + }, + { + "guid": "uhdiDrWjH0-n", + "title": "Mozilla Bug Tracker", + "index": 1, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 9, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://bugzilla.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://bugzilla.mozilla.org/", + "keyword": "bz", + "postData": null + }, + { + "guid": "zOK7d-gjJ5Vy", + "title": "Mozilla Developer Network", + "index": 2, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 10, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://developer.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://developer.mozilla.org/", + "keyword": "mdn", + "postData": null + }, + { + "guid": "7gcb4320A_y6", + "title": "Nightly Tester Tools", + "index": 3, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 11, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://addons.mozilla.org/firefox/addon/nightly-tester-tools/", + "type": "text/x-moz-place", + "uri": "https://addons.mozilla.org/firefox/addon/nightly-tester-tools/" + }, + { + "guid": "c4753lDvJwNE", + "title": "All your crashes", + "index": 4, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 12, + "typeCode": 1, + "iconUri": "fake-favicon-uri:about:crashes", + "type": "text/x-moz-place", + "uri": "about:crashes" + }, + { + "guid": "IyYGIH9VCs2t", + "title": "Planet Mozilla", + "index": 5, + "dateAdded": 1685116352325000, + "lastModified": 1685116352325000, + "id": 13, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://planet.mozilla.org/", + "type": "text/x-moz-place", + "uri": "https://planet.mozilla.org/" + } + ] + } + ] + }, + { + "guid": "toolbar_____", + "title": "toolbar", + "index": 1, + "dateAdded": 1685116351936000, + "lastModified": 1685372151518000, + "id": 3, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "toolbarFolder", + "children": [ + { + "guid": "5jN1vdzOEnHx", + "title": "Get Involved", + "index": 0, + "dateAdded": 1685116352413000, + "lastModified": 1685116352413000, + "id": 14, + "typeCode": 1, + "iconUri": "fake-favicon-uri:https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global", + "type": "text/x-moz-place", + "uri": "https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global" + }, + { + "guid": "5RsMT9sWsmIe", + "title": "Why More Psychiatrists Think Mindfulness Can Help Treat ADHD", + "index": 1, + "dateAdded": 1685372143048000, + "lastModified": 1685372143048000, + "id": 15, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/why-more-psychiatrists-think-mindfulness-can-help-treat-adhd?utm_source=pocket-newtab" + }, + { + "guid": "ejoNUqAfEMQL", + "title": "Your New Favorite Weeknight Recipe Is Meat-Free (and Easy, Too)", + "index": 2, + "dateAdded": 1685372148200000, + "lastModified": 1685372148200000, + "id": 16, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/collections/your-new-favorite-weeknight-recipe-is-meat-free-and-easy-too?utm_source=pocket-newtab" + }, + { + "guid": "O5QCiQ1zrqHY", + "title": "8 Natural Ways to Repel Insects Without Bug Spray", + "index": 3, + "dateAdded": 1685372151518000, + "lastModified": 1685372151518000, + "id": 17, + "typeCode": 1, + "type": "text/x-moz-place", + "uri": "https://getpocket.com/explore/item/8-natural-ways-to-repel-insects-without-bug-spray?utm_source=pocket-newtab" + } + ] + }, + { + "guid": "unfiled_____", + "title": "unfiled", + "index": 3, + "dateAdded": 1685116351936000, + "lastModified": 1685116352272000, + "id": 5, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "unfiledBookmarksFolder" + }, + { + "guid": "mobile______", + "title": "mobile", + "index": 4, + "dateAdded": 1685116351968000, + "lastModified": 1685116352272000, + "id": 6, + "typeCode": 2, + "type": "text/x-moz-place-container", + "root": "mobileFolder" + } + ] +} diff --git a/browser/components/migration/tests/unit/head_migration.js b/browser/components/migration/tests/unit/head_migration.js new file mode 100644 index 0000000000..00db0734a0 --- /dev/null +++ b/browser/components/migration/tests/unit/head_migration.js @@ -0,0 +1,261 @@ +"use strict"; + +var { MigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MigrationUtils.sys.mjs" +); +var { LoginHelper } = ChromeUtils.importESModule( + "resource://gre/modules/LoginHelper.sys.mjs" +); +var { NetUtil } = ChromeUtils.import("resource://gre/modules/NetUtil.jsm"); +var { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +var { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); +var { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +// Initialize profile. +var gProfD = do_get_profile(); + +var { getAppInfo, newAppInfo, updateAppInfo } = ChromeUtils.importESModule( + "resource://testing-common/AppInfo.sys.mjs" +); +updateAppInfo(); + +/** + * Migrates the requested resource and waits for the migration to be complete. + * + * @param {MigratorBase} migrator + * The migrator being used to migrate the data. + * @param {number} resourceType + * This is a bitfield with bits from nsIBrowserProfileMigrator flipped to indicate what + * resources should be migrated. + * @param {object|string|null} [aProfile=null] + * The profile to be migrated. If set to null, the default profile will be + * migrated. + * @param {boolean} succeeds + * True if this migration is expected to succeed. + * @returns {Promise>} + * An array of the results from each nsIObserver topics being observed to + * verify if the migration succeeded or failed. Those results are 2-element + * arrays of [subject, data]. + */ +async function promiseMigration( + migrator, + resourceType, + aProfile = null, + succeeds = null +) { + // Ensure resource migration is available. + let availableSources = await migrator.getMigrateData(aProfile); + Assert.ok( + (availableSources & resourceType) > 0, + "Resource supported by migrator" + ); + let promises = [TestUtils.topicObserved("Migration:Ended")]; + + if (succeeds !== null) { + // Check that the specific resource type succeeded + promises.push( + TestUtils.topicObserved( + succeeds ? "Migration:ItemAfterMigrate" : "Migration:ItemError", + (_, data) => data == resourceType + ) + ); + } + + // Start the migration. + migrator.migrate(resourceType, null, aProfile); + + return Promise.all(promises); +} +/** + * Function that returns a favicon url for a given page url + * + * @param {string} uri + * The Bookmark URI + * @returns {string} faviconURI + * The Favicon URI + */ +async function getFaviconForPageURI(uri) { + let faviconURI = await new Promise(resolve => { + PlacesUtils.favicons.getFaviconDataForPage(uri, favURI => { + resolve(favURI); + }); + }); + return faviconURI; +} + +/** + * Takes an array of page URIs and checks that the favicon was imported for each page URI + * + * @param {Array} pageURIs An array of page URIs + */ +async function assertFavicons(pageURIs) { + for (let uri of pageURIs) { + let faviconURI = await getFaviconForPageURI(uri); + Assert.ok(faviconURI, `Got favicon for ${uri.spec}`); + } +} + +/** + * Replaces a directory service entry with a given nsIFile. + * + * @param {string} key + * The nsIDirectoryService directory key to register a fake path for. + * For example: "AppData", "ULibDir". + * @param {nsIFile} file + * The nsIFile to map the key to. Note that this nsIFile should represent + * a directory and not an individual file. + * @see nsDirectoryServiceDefs.h for the list of directories that can be + * overridden. + */ +function registerFakePath(key, file) { + let dirsvc = Services.dirsvc.QueryInterface(Ci.nsIProperties); + let originalFile; + try { + // If a file is already provided save it and undefine, otherwise set will + // throw for persistent entries (ones that are cached). + originalFile = dirsvc.get(key, Ci.nsIFile); + dirsvc.undefine(key); + } catch (e) { + // dirsvc.get will throw if nothing provides for the key and dirsvc.undefine + // will throw if it's not a persistent entry, in either case we don't want + // to set the original file in cleanup. + originalFile = undefined; + } + + dirsvc.set(key, file); + registerCleanupFunction(() => { + dirsvc.undefine(key); + if (originalFile) { + dirsvc.set(key, originalFile); + } + }); +} + +function getRootPath() { + let dirKey; + if (AppConstants.platform == "win") { + dirKey = "LocalAppData"; + } else if (AppConstants.platform == "macosx") { + dirKey = "ULibDir"; + } else { + dirKey = "Home"; + } + return Services.dirsvc.get(dirKey, Ci.nsIFile).path; +} + +/** + * Returns a PRTime value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {number} + */ +function PRTimeDaysAgo(daysAgo) { + return PlacesUtils.toPRTime(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Returns a Date value for the current date minus daysAgo number + * of days. + * + * @param {number} daysAgo + * How many days in the past from now the returned date should be. + * @returns {Date} + */ +function dateDaysAgo(daysAgo) { + return new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000); +} + +/** + * Constructs and returns a data structure consistent with the Chrome + * browsers bookmarks storage. This data structure can then be serialized + * to JSON and written to disk to simulate a Chrome browser's bookmarks + * database. + * + * @param {number} [totalBookmarks=100] + * How many bookmarks to create. + * @returns {object} + */ +function createChromeBookmarkStructure(totalBookmarks = 100) { + let bookmarksData = { + roots: { + bookmark_bar: { children: [] }, + other: { children: [] }, + synced: { children: [] }, + }, + }; + const MAX_BMS = totalBookmarks; + let barKids = bookmarksData.roots.bookmark_bar.children; + let menuKids = bookmarksData.roots.other.children; + let syncedKids = bookmarksData.roots.synced.children; + let currentMenuKids = menuKids; + let currentBarKids = barKids; + let currentSyncedKids = syncedKids; + for (let i = 0; i < MAX_BMS; i++) { + currentBarKids.push({ + url: "https://www.chrome-bookmark-bar-bookmark" + i + ".com", + name: "bookmark " + i, + type: "url", + }); + currentMenuKids.push({ + url: "https://www.chrome-menu-bookmark" + i + ".com", + name: "bookmark for menu " + i, + type: "url", + }); + currentSyncedKids.push({ + url: "https://www.chrome-synced-bookmark" + i + ".com", + name: "bookmark for synced " + i, + type: "url", + }); + if (i % 20 == 19) { + let nextFolder = { + name: "toolbar folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentBarKids.push(nextFolder); + currentBarKids = nextFolder.children; + + nextFolder = { + name: "menu folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentMenuKids.push(nextFolder); + currentMenuKids = nextFolder.children; + + nextFolder = { + name: "synced folder " + Math.ceil(i / 20), + type: "folder", + children: [], + }; + currentSyncedKids.push(nextFolder); + currentSyncedKids = nextFolder.children; + } + } + return bookmarksData; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp new file mode 100644 index 0000000000..bd4f69657a --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/InsertIEHistory.cpp @@ -0,0 +1,37 @@ +/* Any copyright is dedicated to the Public Domain. + * https://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * Insert URLs into Internet Explorer (IE) history so we can test importing + * them. + * + * See API docs at + * https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/ms774949(v%3dvs.85) + */ + +#include // IUrlHistoryStg +#include // SID_SUrlHistory + +int main(int argc, char** argv) { + HRESULT hr = CoInitializeEx(NULL, COINIT_APARTMENTTHREADED); + if (FAILED(hr)) { + CoUninitialize(); + return -1; + } + IUrlHistoryStg* ieHist; + + hr = + ::CoCreateInstance(CLSID_CUrlHistory, nullptr, CLSCTX_INPROC_SERVER, + IID_IUrlHistoryStg, reinterpret_cast(&ieHist)); + if (FAILED(hr)) return -2; + + hr = ieHist->AddUrl(L"http://www.mozilla.org/1", L"Mozilla HTTP Test", 0); + if (FAILED(hr)) return -3; + + hr = ieHist->AddUrl(L"https://www.mozilla.org/2", L"Mozilla HTTPS Test 🦊", 0); + if (FAILED(hr)) return -4; + + CoUninitialize(); + + return 0; +} diff --git a/browser/components/migration/tests/unit/insertIEHistory/moz.build b/browser/components/migration/tests/unit/insertIEHistory/moz.build new file mode 100644 index 0000000000..61ca96d48a --- /dev/null +++ b/browser/components/migration/tests/unit/insertIEHistory/moz.build @@ -0,0 +1,19 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +FINAL_TARGET = "_tests/xpcshell/browser/components/migration/tests/unit" + +Program("InsertIEHistory") +OS_LIBS += [ + "ole32", + "uuid", +] +SOURCES += [ + "InsertIEHistory.cpp", +] + +NO_PGO = True +DisableStlWrapping() diff --git a/browser/components/migration/tests/unit/test_360seMigrationUtils.js b/browser/components/migration/tests/unit/test_360seMigrationUtils.js new file mode 100644 index 0000000000..3a882b516d --- /dev/null +++ b/browser/components/migration/tests/unit/test_360seMigrationUtils.js @@ -0,0 +1,164 @@ +"use strict"; + +const { Qihoo360seMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/360seMigrationUtils.sys.mjs" +); + +const parentPath = do_get_file("AppData/Roaming/360se6/User Data").path; +const loggedInPath = "0f3ab103a522f4463ecacc36d34eb996"; +const loggedInBackup = PathUtils.join( + parentPath, + "Default", + loggedInPath, + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); +const loggedOutBackup = PathUtils.join( + parentPath, + "Default", + "DailyBackup", + "360default_ori_2021_12_02.favdb" +); + +function getSqlitePath(profileId) { + return PathUtils.join(parentPath, profileId, loggedInPath, "360sefav.dat"); +} + +add_task(async function test_360se10_logged_in() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime(sqlitePath); + await IOUtils.copy( + PathUtils.join(parentPath, "Default", "360Bookmarks"), + PathUtils.join(parentPath, "Default", loggedInPath) + ); + await IOUtils.copy(loggedOutBackup, loggedInBackup); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.ok( + alternativeBookmarks.resource && alternativeBookmarks.resource.exists, + "Should return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + undefined, + "Should not return any path to plain text bookmarks." + ); +}); + +add_task(async function test_360se10_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the outdated legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedInBackup, + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se10_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + loggedOutBackup, + "Should return path to the most recent plain text bookmarks backup." + ); +}); + +add_task(async function test_360se9_logged_in_outdated_sqlite() { + const sqlitePath = getSqlitePath("Default4SE9Test"); + await IOUtils.setModificationTime( + sqlitePath, + new Date("2020-08-18").valueOf() + ); + + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: loggedInPath, + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join( + parentPath, + "Default4SE9Test", + loggedInPath, + "DailyBackup", + "360sefav_2020_08_28.favdb" + ), + "Should return path to the most recent plain text bookmarks backup." + ); + + await IOUtils.setModificationTime(sqlitePath); +}); + +add_task(async function test_360se9_logged_out() { + const alternativeBookmarks = + await Qihoo360seMigrationUtils.getAlternativeBookmarks({ + bookmarksPath: PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + localState: { + sync_login_info: { + filepath: "", + }, + }, + }); + Assert.strictEqual( + alternativeBookmarks.resource, + undefined, + "Should not return the legacy bookmark resource." + ); + Assert.strictEqual( + alternativeBookmarks.path, + PathUtils.join(parentPath, "Default4SE9Test", "Bookmarks"), + "Should return path to the plain text canonical bookmarks." + ); +}); diff --git a/browser/components/migration/tests/unit/test_360se_bookmarks.js b/browser/components/migration/tests/unit/test_360se_bookmarks.js new file mode 100644 index 0000000000..e4f42d1880 --- /dev/null +++ b/browser/components/migration/tests/unit/test_360se_bookmarks.js @@ -0,0 +1,62 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("AppData", do_get_file("AppData/Roaming/")); + + let migrator = await MigrationUtils.getMigrator("chromium-360se"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let importedToBookmarksToolbar = false; + let itemsSeen = { bookmarks: 0, folders: 0 }; + + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + if (event.parentGuid == PlacesUtils.bookmarks.toolbarGuid) { + importedToBookmarksToolbar = true; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS, { + id: "Default", + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(importedToBookmarksToolbar, "Bookmarks imported in the toolbar"); + Assert.equal(itemsSeen.bookmarks, 8, "Should import all bookmarks."); + Assert.equal(itemsSeen.folders, 2, "Should import all folders."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js new file mode 100644 index 0000000000..a30221b5bd --- /dev/null +++ b/browser/components/migration/tests/unit/test_BookmarksFileMigrator.js @@ -0,0 +1,120 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { BookmarksFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); + +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +/** + * Tests that the BookmarksFileMigrator properly subclasses FileMigratorBase + * and delegates to BookmarkHTMLUtils or BookmarkJSONUtils. + * + * Normally, we'd override the BookmarkHTMLUtils and BookmarkJSONUtils methods + * in our test here so that we just ensure that they're called with the + * right arguments, rather than testing their behaviour. Unfortunately, both + * BookmarkHTMLUtils and BookmarkJSONUtils are frozen with Object.freeze, which + * prevents sinon from stubbing out any of their methods. Rather than unfreezing + * those objects just for testing, we test the whole flow end-to-end, including + * the import to Places. + */ + +add_setup(() => { + Services.prefs.setBoolPref("browser.migrate.bookmarks-file.enabled", true); + registerCleanupFunction(async () => { + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref("browser.migrate.bookmarks-file.enabled"); + }); +}); + +/** + * First check that the BookmarksFileMigrator implements the required parts + * of the parent class. + */ +add_task(async function test_BookmarksFileMigrator_members() { + let migrator = new BookmarksFileMigrator(); + Assert.ok( + migrator.constructor.key, + `BookmarksFileMigrator implements static getter 'key'` + ); + + Assert.ok( + migrator.constructor.displayNameL10nID, + `BookmarksFileMigrator implements static getter 'displayNameL10nID'` + ); + + Assert.ok( + migrator.constructor.brandImage, + `BookmarksFileMigrator implements static getter 'brandImage'` + ); + + Assert.ok( + migrator.progressHeaderL10nID, + `BookmarksFileMigrator implements getter 'progressHeaderL10nID'` + ); + + Assert.ok( + migrator.successHeaderL10nID, + `BookmarksFileMigrator implements getter 'successHeaderL10nID'` + ); + + Assert.ok( + await migrator.getFilePickerConfig(), + `BookmarksFileMigrator implements method 'getFilePickerConfig'` + ); + + Assert.ok( + migrator.displayedResourceTypes, + `BookmarksFileMigrator implements getter 'displayedResourceTypes'` + ); + + Assert.ok(migrator.enabled, `BookmarksFileMigrator is enabled`); +}); + +add_task(async function test_BookmarksFileMigrator_HTML() { + let migrator = new BookmarksFileMigrator(); + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "8 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.html" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); + +add_task(async function test_BookmarksFileMigrator_JSON() { + let migrator = new BookmarksFileMigrator(); + + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES + .BOOKMARKS_FROM_FILE]: "10 bookmarks", + }; + + const BOOKMARKS_PATH = PathUtils.join( + do_get_cwd().path, + "bookmarks.exported.json" + ); + + let result = await migrator.migrate(BOOKMARKS_PATH); + + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Returned the expected success state." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js new file mode 100644 index 0000000000..bf76863fe2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils.js @@ -0,0 +1,86 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +// Setup chrome user data path for all platforms. +ChromeMigrationUtils.getDataPath = () => { + return Promise.resolve( + do_get_file("Library/Application Support/Google/Chrome/").path + ); +}; + +add_task(async function test_getExtensionList_function() { + let extensionList = await ChromeMigrationUtils.getExtensionList("Default"); + Assert.equal( + extensionList.length, + 2, + "There should be 2 extensions installed." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-1"), + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "First extension should match expectations." + ); + Assert.deepEqual( + extensionList.find(extension => extension.id == "fake-extension-2"), + { + id: "fake-extension-2", + name: "Fake Extension 2", + description: "It is the description of fake extension 2.", + }, + "Second extension should match expectations." + ); +}); + +add_task(async function test_getExtensionInformation_function() { + let extension = await ChromeMigrationUtils.getExtensionInformation( + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + extension, + { + id: "fake-extension-1", + name: "Fake Extension 1", + description: "It is the description of fake extension 1.", + }, + "Should get the extension information correctly." + ); +}); + +add_task(async function test_getLocaleString_function() { + let name = await ChromeMigrationUtils._getLocaleString( + "__MSG_name__", + "en_US", + "fake-extension-1", + "Default" + ); + Assert.deepEqual( + name, + "Fake Extension 1", + "The value of __MSG_name__ locale key is Fake Extension 1." + ); +}); + +add_task(async function test_isExtensionInstalled_function() { + let isInstalled = await ChromeMigrationUtils.isExtensionInstalled( + "fake-extension-1", + "Default" + ); + Assert.ok(isInstalled, "The fake-extension-1 extension should be installed."); +}); + +add_task(async function test_getLastUsedProfileId_function() { + let profileId = await ChromeMigrationUtils.getLastUsedProfileId(); + Assert.equal( + profileId, + "Default", + "The last used profile ID should be Default." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js new file mode 100644 index 0000000000..ca75595ea9 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path.js @@ -0,0 +1,141 @@ +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + win: { + Chrome: ["Google", "Chrome", "User Data"], + Chromium: ["Chromium", "User Data"], + Canary: ["Google", "Chrome SxS", "User Data"], + }, + macosx: { + Chrome: ["Application Support", "Google", "Chrome"], + Chromium: ["Application Support", "Chromium"], + Canary: ["Application Support", "Google", "Chrome Canary"], + }, + linux: { + Chrome: [".config", "google-chrome"], + Chromium: [".config", "chromium"], + Canary: [], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let projects = ["Chrome", "Chromium", "Canary"]; + let rootPath = getRootPath(); + + for (let project of projects) { + let subfolders = SUB_DIRECTORIES[AppConstants.platform][project]; + + await IOUtils.makeDirectory(PathUtils.join(rootPath, ...subfolders), { + createAncestor: true, + ignoreExisting: true, + }); + } + + let chromeUserDataPath = await ChromeMigrationUtils.getDataPath("Chrome"); + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + let canaryUserDataPath = await ChromeMigrationUtils.getDataPath("Canary"); + if (AppConstants.platform == "win") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome", "User Data"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Chromium", "User Data"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join(getRootPath(), "Google", "Chrome SxS", "User Data"), + "Should get the path of Canary data directory." + ); + } else if (AppConstants.platform == "macosx") { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Google", "Chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), "Application Support", "Chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal( + canaryUserDataPath, + PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome Canary" + ), + "Should get the path of Canary data directory." + ); + } else { + Assert.equal( + chromeUserDataPath, + PathUtils.join(getRootPath(), ".config", "google-chrome"), + "Should get the path of Chrome data directory." + ); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ".config", "chromium"), + "Should get the path of Chromium data directory." + ); + Assert.equal(canaryUserDataPath, null, "Should get null for Canary."); + } +}); + +add_task(async function test_getExtensionPath_function() { + let extensionPath = await ChromeMigrationUtils.getExtensionPath("Default"); + let expectedPath; + if (AppConstants.platform == "win") { + expectedPath = PathUtils.join( + getRootPath(), + "Google", + "Chrome", + "User Data", + "Default", + "Extensions" + ); + } else if (AppConstants.platform == "macosx") { + expectedPath = PathUtils.join( + getRootPath(), + "Application Support", + "Google", + "Chrome", + "Default", + "Extensions" + ); + } else { + expectedPath = PathUtils.join( + getRootPath(), + ".config", + "google-chrome", + "Default", + "Extensions" + ); + } + Assert.equal( + extensionPath, + expectedPath, + "Should get the path of extensions directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js new file mode 100644 index 0000000000..5011991536 --- /dev/null +++ b/browser/components/migration/tests/unit/test_ChromeMigrationUtils_path_chromium_snap.js @@ -0,0 +1,55 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SUB_DIRECTORIES = { + linux: { + Chromium: [".config", "chromium"], + SnapChromium: ["snap", "chromium", "common", "chromium"], + }, +}; + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + + registerFakePath(pathId, do_get_file("chromefiles/", true)); +}); + +add_task(async function test_getDataPath_function() { + let rootPath = getRootPath(); + let chromiumSubFolders = SUB_DIRECTORIES[AppConstants.platform].Chromium; + // must remove normal chromium path + await IOUtils.remove(PathUtils.join(rootPath, ...chromiumSubFolders), { + ignoreAbsent: true, + }); + + let snapChromiumSubFolders = + SUB_DIRECTORIES[AppConstants.platform].SnapChromium; + // must create snap chromium path + await IOUtils.makeDirectory( + PathUtils.join(rootPath, ...snapChromiumSubFolders), + { + createAncestor: true, + ignoreExisting: true, + } + ); + + let chromiumUserDataPath = await ChromeMigrationUtils.getDataPath("Chromium"); + Assert.equal( + chromiumUserDataPath, + PathUtils.join(getRootPath(), ...snapChromiumSubFolders), + "Should get the path of Snap Chromium data directory." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_bookmarks.js b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js new file mode 100644 index 0000000000..c10ab6dcd6 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_bookmarks.js @@ -0,0 +1,199 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +const { PlacesUIUtils } = ChromeUtils.importESModule( + "resource:///modules/PlacesUIUtils.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function setup_initialBookmarks() { + let bookmarks = []; + for (let i = 0; i < PlacesUIUtils.NUM_TOOLBAR_BOOKMARKS_TO_UNHIDE + 1; i++) { + bookmarks.push({ url: "https://example.com/" + i, title: "" + i }); + } + + // Ensure we have enough items in both the menu and toolbar to trip creating a "from" folder. + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.toolbarGuid, + children: bookmarks, + }); + await PlacesUtils.bookmarks.insertTree({ + guid: PlacesUtils.bookmarks.menuGuid, + children: bookmarks, + }); +}); + +async function testBookmarks(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Favicons database into Default profile + const sourcePath = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Favicons" + ).path; + await IOUtils.copy(sourcePath, target.path); + + // Get page url for each favicon + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + sourcePath, + "Chrome Bookmark Favicons", + `select page_url from icon_mapping` + ); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + let itemsSeen = { bookmarks: 0, folders: 0 }; + let listener = events => { + for (let event of events) { + itemsSeen[ + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER + ? "folders" + : "bookmarks" + ]++; + } + }; + + PlacesUtils.observers.addListener(["bookmark-added"], listener); + const PROFILE = { + id: "Default", + name: "Default", + }; + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + const initialToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const initialUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const initialmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.BOOKMARKS, + PROFILE + ); + const postToolbarCount = await getFolderItemCount( + PlacesUtils.bookmarks.toolbarGuid + ); + const postUnfiledCount = await getFolderItemCount( + PlacesUtils.bookmarks.unfiledGuid + ); + const postmenuCount = await getFolderItemCount( + PlacesUtils.bookmarks.menuGuid + ); + + Assert.equal( + postUnfiledCount - initialUnfiledCount, + 210, + "Should have seen 210 items in unsorted bookmarks" + ); + Assert.equal( + postToolbarCount - initialToolbarCount, + 105, + "Should have seen 105 items in toolbar" + ); + Assert.equal( + postmenuCount - initialmenuCount, + 0, + "Should have seen 0 items in menu toolbar" + ); + + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + Assert.equal(itemsSeen.bookmarks, 300, "Should have seen 300 bookmarks."); + Assert.equal(itemsSeen.folders, 15, "Should have seen 15 folders."); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemsSeen.bookmarks + itemsSeen.folders, + "Telemetry reporting correct." + ); + Assert.ok(observerNotified, "The observer should be notified upon migration"); + let pageUrls = Array.from(faviconURIs, f => + Services.io.newURI(f.getResultByName("page_url")) + ); + await assertFavicons(pageUrls); +} + +add_task(async function test_Chrome() { + // Expire all favicons before the test to make sure favicons are imported + PlacesUtils.favicons.expireAllFavicons(); + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testBookmarks("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + PlacesUtils.favicons.expireAllFavicons(); + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testBookmarks("chromium-edge", subDirs); +}); + +async function getFolderItemCount(guid) { + let results = await PlacesUtils.promiseBookmarksTree(guid); + + return results.itemsCount; +} diff --git a/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js new file mode 100644 index 0000000000..31541f9fdb --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_corrupt_history.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +let rootDir = do_get_file("chromefiles/", true); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +add_setup(async function setup_fake_paths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); + + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryCorrupt`); + file.copyTo(file.parent, "History"); + + registerCleanupFunction(() => { + let historyFile = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + historyFile.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } + }); + + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Bookmarks"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + let bookmarksData = createChromeBookmarkStructure(); + await IOUtils.writeJSON(target.path, bookmarksData); +}); + +add_task(async function test_corrupt_history() { + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok(await migrator.isSourceAvailable()); + + let data = await migrator.getMigrateData(PROFILE); + Assert.ok( + data & MigrationUtils.resourceTypes.BOOKMARKS, + "Bookmarks resource available." + ); + Assert.ok( + !(data & MigrationUtils.resourceTypes.HISTORY), + "Corrupt history resource unavailable." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_credit_cards.js b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js new file mode 100644 index 0000000000..5c4d3517d2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_credit_cards.js @@ -0,0 +1,239 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* global structuredClone */ + +const PROFILE = { + id: "Default", + name: "Default", +}; + +const PAYMENT_METHODS = [ + { + name_on_card: "Name Name", + card_number: "4532962432748929", // Visa + expiration_month: 3, + expiration_year: 2027, + }, + { + name_on_card: "Name Name Name", + card_number: "5359908373796416", // Mastercard + expiration_month: 5, + expiration_year: 2028, + }, + { + name_on_card: "Name", + card_number: "346624461807588", // AMEX + expiration_month: 4, + expiration_year: 2026, + }, +]; + +let OSKeyStoreTestUtils; +add_setup(async function os_key_store_setup() { + ({ OSKeyStoreTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/OSKeyStoreTestUtils.sys.mjs" + )); + OSKeyStoreTestUtils.setup(); + registerCleanupFunction(async function cleanup() { + await OSKeyStoreTestUtils.cleanup(); + }); +}); + +let rootDir = do_get_file("chromefiles/", true); + +function checkCardsAreEqual(importedCard, testCard, id) { + const CC_NUMBER_RE = /^(\*+)(.{4})$/; + + Assert.equal( + importedCard["cc-name"], + testCard.name_on_card, + "The two logins ID " + id + " have the same name on card" + ); + + let matches = CC_NUMBER_RE.exec(importedCard["cc-number"]); + Assert.notEqual(matches, null); + Assert.equal(importedCard["cc-number"].length, testCard.card_number.length); + Assert.equal(testCard.card_number.endsWith(matches[2]), true); + Assert.notEqual(importedCard["cc-number-encrypted"], ""); + + Assert.equal( + importedCard["cc-exp-month"], + testCard.expiration_month, + "The two logins ID " + id + " have the same expiration month" + ); + Assert.equal( + importedCard["cc-exp-year"], + testCard.expiration_year, + "The two logins ID " + id + " have the same expiration year" + ); +} + +add_task(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +add_task(async function test_credit_cards() { + if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) { + todo_check_true( + OSKeyStoreTestUtils.canTestOSKeyStoreLogin(), + "Cannot test OS key store login on official builds." + ); + return; + } + + let loginCrypto; + let profilePathSegments; + + let mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + let { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + ]; + } else if (AppConstants.platform == "win") { + let { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + profilePathSegments = ["Google", "Chrome", "User Data", "Default"]; + } else { + throw new Error("Not implemented"); + } + + let target = rootDir.clone(); + let defaultFolderPath = PathUtils.join(target.path, ...profilePathSegments); + let webDataPath = PathUtils.join(defaultFolderPath, "Web Data"); + let localStatePath = defaultFolderPath.replace("Default", ""); + + await IOUtils.makeDirectory(defaultFolderPath, { + createAncestor: true, + ignoreExisting: true, + }); + + // Copy Web Data database into Default profile + const sourcePathWebData = do_get_file( + "AppData/Local/Google/Chrome/User Data/Default/Web Data" + ).path; + await IOUtils.copy(sourcePathWebData, webDataPath); + + const sourcePathLocalState = do_get_file( + "AppData/Local/Google/Chrome/User Data/Local State" + ).path; + await IOUtils.copy(sourcePathLocalState, localStatePath); + + let dbConn = await Sqlite.openConnection({ path: webDataPath }); + + for (let card of PAYMENT_METHODS) { + let encryptedCardNumber = await loginCrypto.encryptData(card.card_number); + let cardNumberEncryptedValue = new Uint8Array( + loginCrypto.stringToArray(encryptedCardNumber) + ); + + let cardCopy = structuredClone(card); + + cardCopy.card_number_encrypted = cardNumberEncryptedValue; + delete cardCopy.card_number; + + await dbConn.execute( + `INSERT INTO credit_cards + (name_on_card, card_number_encrypted, expiration_month, expiration_year) + VALUES (:name_on_card, :card_number_encrypted, :expiration_month, :expiration_year) + `, + cardCopy + ); + } + + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator("chrome"); + if (AppConstants.platform == "macosx") { + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + false + ); + Assert.ok( + !( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS + ), + "Should be able to disable migrating payment methods" + ); + // Clear the cached resources now so that a re-check for payment methods + // will look again. + delete migrator._resourcesByProfile[PROFILE.id]; + + Services.prefs.setBoolPref( + "browser.migrate.chrome.payment_methods.enabled", + true + ); + + Assert.ok( + (await migrator.getMigrateData(PROFILE)) & + MigrationUtils.resourceTypes.PAYMENT_METHODS, + "Should be able to enable migrating payment methods" + ); + + let { formAutofillStorage } = ChromeUtils.importESModule( + "resource://autofill/FormAutofillStorage.sys.mjs" + ); + await formAutofillStorage.initialize(); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PAYMENT_METHODS, + PROFILE + ); + + let cards = await formAutofillStorage.creditCards.getAll(); + + Assert.equal( + cards.length, + PAYMENT_METHODS.length, + "Check there are still the same number of credit cards after re-importing the data" + ); + Assert.equal( + cards.length, + MigrationUtils._importQuantities.cards, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < PAYMENT_METHODS.length; i++) { + checkCardsAreEqual(cards[i], PAYMENT_METHODS[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_formdata.js b/browser/components/migration/tests/unit/test_Chrome_formdata.js new file mode 100644 index 0000000000..1dc411cb14 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_formdata.js @@ -0,0 +1,118 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { FormHistory } = ChromeUtils.importESModule( + "resource://gre/modules/FormHistory.sys.mjs" +); + +let rootDir = do_get_file("chromefiles/", true); + +add_setup(async function setup_fakePaths() { + let pathId; + if (AppConstants.platform == "macosx") { + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + pathId = "LocalAppData"; + } else { + pathId = "Home"; + } + registerFakePath(pathId, rootDir); +}); + +/** + * This function creates a testing database in the default profile, + * populates it with 10 example data entries,migrates the database, + * and then searches for each entry to ensure it exists in the FormHistory. + * + * @async + * @param {string} migratorKey + * A string that identifies the type of migrator object to be retrieved. + * @param {Array} subDirs + * An array of strings that specifies the subdirectories for the target profile directory. + * @returns {Promise} + * A Promise that resolves when the migration is completed. + */ +async function testFormdata(migratorKey, subDirs) { + if (AppConstants.platform == "macosx") { + subDirs.unshift("Application Support"); + } else if (AppConstants.platform == "win") { + subDirs.push("User Data"); + } else { + subDirs.unshift(".config"); + } + + let target = rootDir.clone(); + // Pretend this is the default profile + subDirs.push("Default"); + while (subDirs.length) { + target.append(subDirs.shift()); + } + + await IOUtils.makeDirectory(target.path, { + createAncestor: true, + ignoreExisting: true, + }); + + target.append("Web Data"); + await IOUtils.remove(target.path, { ignoreAbsent: true }); + + // Clear any search history results + await FormHistory.update({ op: "remove" }); + + let dbConn = await Sqlite.openConnection({ path: target.path }); + + await dbConn.execute( + `CREATE TABLE "autofill" (name VARCHAR, value VARCHAR, value_lower VARCHAR, date_created INTEGER DEFAULT 0, date_last_used INTEGER DEFAULT 0, count INTEGER DEFAULT 1, PRIMARY KEY (name, value))` + ); + for (let i = 0; i < 10; i++) { + await dbConn.execute( + `INSERT INTO autofill VALUES (:name, :value, :value_lower, :date_created, :date_last_used, :count)`, + { + name: `name${i}`, + value: `example${i}`, + value_lower: `example${i}`, + date_created: Math.round(Date.now() / 1000) - i * 10000, + date_last_used: Date.now(), + count: i, + } + ); + } + await dbConn.close(); + + let migrator = await MigrationUtils.getMigrator(migratorKey); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.FORMDATA, { + id: "Default", + name: "Person 1", + }); + + for (let i = 0; i < 10; i++) { + let results = await FormHistory.search(["fieldname", "value"], { + fieldname: `name${i}`, + value: `example${i}`, + }); + Assert.ok(results.length, `Should have item${i} in FormHistory`); + } +} + +add_task(async function test_Chrome() { + let subDirs = + AppConstants.platform == "linux" ? ["google-chrome"] : ["Google", "Chrome"]; + await testFormdata("chrome", subDirs); +}); + +add_task(async function test_ChromiumEdge() { + if (AppConstants.platform == "linux") { + // Edge isn't available on Linux. + return; + } + let subDirs = + AppConstants.platform == "macosx" + ? ["Microsoft Edge"] + : ["Microsoft", "Edge"]; + await testFormdata("chromium-edge", subDirs); +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_history.js b/browser/components/migration/tests/unit/test_Chrome_history.js new file mode 100644 index 0000000000..c88a6380c2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_history.js @@ -0,0 +1,206 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ChromeMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/ChromeMigrationUtils.sys.mjs" +); + +const SOURCE_PROFILE_DIR = "Library/Application Support/Google/Chrome/Default/"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +/** + * TEST_URLS reflects the data stored in '${SOURCE_PROFILE_DIR}HistoryMaster'. + * The main object reflects the data in the 'urls' table. The visits property + * reflects the associated data in the 'visits' table. + */ +const TEST_URLS = [ + { + id: 1, + url: "http://example.com/", + title: "test", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193151310368000, + hidden: 0, + visits: [ + { + id: 1, + url: 1, + visit_time: 13193151310368000, + from_visit: 0, + transition: 805306370, + segment_id: 0, + visit_duration: 10745006, + incremented_omnibox_typed_score: 0, + }, + ], + }, + { + id: 2, + url: "http://invalid.com/", + title: "test2", + visit_count: 1, + typed_count: 0, + last_visit_time: 13193154948901000, + hidden: 0, + visits: [ + { + id: 2, + url: 2, + visit_time: 13193154948901000, + from_visit: 0, + transition: 805306376, + segment_id: 0, + visit_duration: 6568270, + incremented_omnibox_typed_score: 0, + }, + ], + }, +]; + +async function setVisitTimes(time) { + let loginDataFile = do_get_file(`${SOURCE_PROFILE_DIR}History`); + let dbConn = await Sqlite.openConnection({ path: loginDataFile.path }); + + await dbConn.execute(`UPDATE urls SET last_visit_time = :last_visit_time`, { + last_visit_time: time, + }); + await dbConn.execute(`UPDATE visits SET visit_time = :visit_time`, { + visit_time: time, + }); + + await dbConn.close(); +} + +function setExpectedVisitTimes(time) { + for (let urlInfo of TEST_URLS) { + urlInfo.last_visit_time = time; + urlInfo.visits[0].visit_time = time; + } +} + +function assertEntryMatches(entry, urlInfo, dateWasInFuture = false) { + info(`Checking url: ${urlInfo.url}`); + Assert.ok(entry, `Should have stored an entry`); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits.length, + "Should have the correct number of visits" + ); + + for (let index in urlInfo.visits) { + Assert.equal( + entry.visits[index].transition, + PlacesUtils.history.TRANSITIONS.LINK, + "Should have Link type transition" + ); + + if (dateWasInFuture) { + Assert.lessOrEqual( + entry.visits[index].date.getTime(), + new Date().getTime(), + "Should have moved the date to no later than the current date." + ); + } else { + Assert.equal( + entry.visits[index].date.getTime(), + ChromeMigrationUtils.chromeTimeToDate( + urlInfo.visits[index].visit_time, + new Date() + ).getTime(), + "Should have the correct date" + ); + } + } +} + +function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(`${SOURCE_PROFILE_DIR}HistoryMaster`); + file.copyTo(file.parent, "History"); +} + +function removeHistoryFile() { + let file = do_get_file(`${SOURCE_PROFILE_DIR}History`, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +add_task(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function test_import() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + // Update to ~10 days ago since the date can't be too old or Places may expire it. + const pastDate = new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 10); + const pastChromeTime = ChromeMigrationUtils.dateToChromeTime(pastDate); + await setVisitTimes(pastChromeTime); + setExpectedVisitTimes(pastChromeTime); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo); + } +}); + +add_task(async function test_import_future_date() { + setupHistoryFile(); + await PlacesUtils.history.clear(); + const futureDate = new Date().getTime() + 6000 * 60 * 24; + await setVisitTimes(ChromeMigrationUtils.dateToChromeTime(futureDate)); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.HISTORY, + PROFILE + ); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + assertEntryMatches(entry, urlInfo, true); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords.js b/browser/components/migration/tests/unit/test_Chrome_passwords.js new file mode 100644 index 0000000000..16f98e7038 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords.js @@ -0,0 +1,373 @@ +"use strict"; + +const PROFILE = { + id: "Default", + name: "Person 1", +}; + +const TEST_LOGINS = [ + { + id: 1, // id of the row in the chrome login db + username: "username", + password: "password", + origin: "https://c9.io", + formActionOrigin: "https://c9.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, + { + id: 2, + username: "username@gmail.com", + password: "password2", + origin: "https://accounts.google.com", + formActionOrigin: "https://accounts.google.com", + httpRealm: null, + usernameField: "Email", + passwordField: "Passwd", + timeCreated: 1437418446598, + timePasswordChanged: 1437418446598, + timesUsed: 6, + }, + { + id: 3, + username: "username", + password: "password3", + origin: "https://www.facebook.com", + formActionOrigin: "https://www.facebook.com", + httpRealm: null, + usernameField: "email", + passwordField: "pass", + timeCreated: 1437418478851, + timePasswordChanged: 1437418478851, + timesUsed: 1, + }, + { + id: 4, + username: "user", + password: "اقرأPÀßwörd", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "me@kennethreitz.com", // Digest auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787462368, + timePasswordChanged: 1437787462368, + timesUsed: 1, + }, + { + id: 5, + username: "buser", + password: "bpassword", + origin: "http://httpbin.org", + formActionOrigin: null, + httpRealm: "Fake Realm", // Basic auth. + usernameField: "", + passwordField: "", + timeCreated: 1437787539233, + timePasswordChanged: 1437787539233, + timesUsed: 1, + }, + { + id: 6, + username: "username", + password: "password6", + origin: "https://www.example.com", + formActionOrigin: "", // NULL `action_url` + httpRealm: null, + usernameField: "", + passwordField: "pass", + timeCreated: 1557291348878, + timePasswordChanged: 1557291348878, + timesUsed: 1, + }, + { + id: 7, + version: "v10", + username: "username", + password: "password", + origin: "https://v10.io", + formActionOrigin: "https://v10.io", + httpRealm: null, + usernameField: "inputEmail", + passwordField: "inputPassword", + timeCreated: 1437418416037, + timePasswordChanged: 1437418416037, + timesUsed: 1, + }, +]; + +var loginCrypto; +var dbConn; + +async function promiseSetPassword(login) { + const encryptedString = await loginCrypto.encryptData( + login.password, + login.version + ); + info(`promiseSetPassword: ${encryptedString}`); + const passwordValue = new Uint8Array( + loginCrypto.stringToArray(encryptedString) + ); + return dbConn.execute( + `UPDATE logins + SET password_value = :password_value + WHERE rowid = :rowid + `, + { password_value: passwordValue, rowid: login.id } + ); +} + +function checkLoginsAreEqual(passwordManagerLogin, chromeLogin, id) { + passwordManagerLogin.QueryInterface(Ci.nsILoginMetaInfo); + + Assert.equal( + passwordManagerLogin.username, + chromeLogin.username, + "The two logins ID " + id + " have the same username" + ); + Assert.equal( + passwordManagerLogin.password, + chromeLogin.password, + "The two logins ID " + id + " have the same password" + ); + Assert.equal( + passwordManagerLogin.origin, + chromeLogin.origin, + "The two logins ID " + id + " have the same origin" + ); + Assert.equal( + passwordManagerLogin.formActionOrigin, + chromeLogin.formActionOrigin, + "The two logins ID " + id + " have the same formActionOrigin" + ); + Assert.equal( + passwordManagerLogin.httpRealm, + chromeLogin.httpRealm, + "The two logins ID " + id + " have the same httpRealm" + ); + Assert.equal( + passwordManagerLogin.usernameField, + chromeLogin.usernameField, + "The two logins ID " + id + " have the same usernameElement" + ); + Assert.equal( + passwordManagerLogin.passwordField, + chromeLogin.passwordField, + "The two logins ID " + id + " have the same passwordElement" + ); + Assert.equal( + passwordManagerLogin.timeCreated, + chromeLogin.timeCreated, + "The two logins ID " + id + " have the same timeCreated" + ); + Assert.equal( + passwordManagerLogin.timePasswordChanged, + chromeLogin.timePasswordChanged, + "The two logins ID " + id + " have the same timePasswordChanged" + ); + Assert.equal( + passwordManagerLogin.timesUsed, + chromeLogin.timesUsed, + "The two logins ID " + id + " have the same timesUsed" + ); +} + +function generateDifferentLogin(login) { + const newLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + + newLogin.init( + login.origin, + login.formActionOrigin, + null, + login.username, + login.password + 1, + login.usernameField + 1, + login.passwordField + 1 + ); + newLogin.QueryInterface(Ci.nsILoginMetaInfo); + newLogin.timeCreated = login.timeCreated + 1; + newLogin.timePasswordChanged = login.timePasswordChanged + 1; + newLogin.timesUsed = login.timesUsed + 1; + return newLogin; +} + +add_task(async function setup() { + let dirSvcPath; + let pathId; + let profilePathSegments; + + // Use a mock service and account name to avoid a Keychain auth. prompt that + // would block the test from finishing if Chrome has already created a matching + // Keychain entry. This allows us to still exercise the keychain lookup code. + // The mock encryption passphrase is used when the Keychain item isn't found. + const mockMacOSKeychain = { + passphrase: "bW96aWxsYWZpcmVmb3g=", + serviceName: "TESTING Chrome Safe Storage", + accountName: "TESTING Chrome", + }; + if (AppConstants.platform == "macosx") { + const { ChromeMacOSLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeMacOSLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeMacOSLoginCrypto( + mockMacOSKeychain.serviceName, + mockMacOSKeychain.accountName, + mockMacOSKeychain.passphrase + ); + dirSvcPath = "Library/"; + pathId = "ULibDir"; + profilePathSegments = [ + "Application Support", + "Google", + "Chrome", + "Default", + "Login Data", + ]; + } else if (AppConstants.platform == "win") { + const { ChromeWindowsLoginCrypto } = ChromeUtils.importESModule( + "resource:///modules/ChromeWindowsLoginCrypto.sys.mjs" + ); + loginCrypto = new ChromeWindowsLoginCrypto("Chrome"); + dirSvcPath = "AppData/Local/"; + pathId = "LocalAppData"; + profilePathSegments = [ + "Google", + "Chrome", + "User Data", + "Default", + "Login Data", + ]; + } else { + throw new Error("Not implemented"); + } + const dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + info(PathUtils.join(dirSvcFile.path, ...profilePathSegments)); + const loginDataFilePath = PathUtils.join( + dirSvcFile.path, + ...profilePathSegments + ); + dbConn = await Sqlite.openConnection({ path: loginDataFilePath }); + + if (AppConstants.platform == "macosx") { + const migrator = await MigrationUtils.getMigrator("chrome"); + Object.assign(migrator, { + _keychainServiceName: mockMacOSKeychain.serviceName, + _keychainAccountName: mockMacOSKeychain.accountName, + _keychainMockPassphrase: mockMacOSKeychain.passphrase, + }); + } + + registerCleanupFunction(() => { + Services.logins.removeAllUserFacingLogins(); + if (loginCrypto.finalize) { + loginCrypto.finalize(); + } + return dbConn.close(); + }); +}); + +add_task(async function test_importIntoEmptyDB() { + for (const login of TEST_LOGINS) { + await promiseSetPassword(login); + } + + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + let logins = Services.logins.getAllLogins(); + Assert.equal(logins.length, 0, "There are no logins initially"); + + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check login count after importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < TEST_LOGINS.length; i++) { + checkLoginsAreEqual(logins[i], TEST_LOGINS[i], i + 1); + } +}); + +// Test that existing logins for the same primary key don't get overwritten +add_task(async function test_importExistingLogins() { + const migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + await migrator.isSourceAvailable(), + "Sanity check the source exists" + ); + + Services.logins.removeAllUserFacingLogins(); + let logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + 0, + "There are no logins after removing all of them" + ); + + const 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])); + await Services.logins.addLoginAsync(newLogins[i]); + } + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + newLogins.length, + "Check login count after the insertion" + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } + // Migrate the logins. + await promiseMigration( + migrator, + MigrationUtils.resourceTypes.PASSWORDS, + PROFILE, + true + ); + + logins = Services.logins.getAllLogins(); + Assert.equal( + logins.length, + TEST_LOGINS.length, + "Check there are still the same number of logins after re-importing the data" + ); + Assert.equal( + logins.length, + MigrationUtils._importQuantities.logins, + "Check telemetry matches the actual import." + ); + + for (let i = 0; i < newLogins.length; i++) { + checkLoginsAreEqual(logins[i], newLogins[i], i + 1); + } +}); diff --git a/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js new file mode 100644 index 0000000000..02b721fd5c --- /dev/null +++ b/browser/components/migration/tests/unit/test_Chrome_passwords_emptySource.js @@ -0,0 +1,24 @@ +"use strict"; + +add_task(async function test_importEmptyDBWithoutAuthPrompts() { + let dirSvcPath; + let pathId; + + if (AppConstants.platform == "macosx") { + dirSvcPath = "LibraryWithNoData/"; + pathId = "ULibDir"; + } else if (AppConstants.platform == "win") { + dirSvcPath = "AppData/LocalWithNoData/"; + pathId = "LocalAppData"; + } else { + throw new Error("Not implemented"); + } + let dirSvcFile = do_get_file(dirSvcPath); + registerFakePath(pathId, dirSvcFile); + + let migrator = await MigrationUtils.getMigrator("chrome"); + Assert.ok( + !migrator, + "Migrator should not be available since there are no passwords" + ); +}); diff --git a/browser/components/migration/tests/unit/test_Edge_db_migration.js b/browser/components/migration/tests/unit/test_Edge_db_migration.js new file mode 100644 index 0000000000..e342c9be60 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_db_migration.js @@ -0,0 +1,849 @@ +"use strict"; + +const { ctypes } = ChromeUtils.importESModule( + "resource://gre/modules/ctypes.sys.mjs" +); +const { ESE, KERNEL, gLibs, COLUMN_TYPES, declareESEFunction, loadLibraries } = + ChromeUtils.importESModule("resource:///modules/ESEDBReader.sys.mjs"); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); + +let gESEInstanceCounter = 1; + +ESE.JET_COLUMNCREATE_W = new ctypes.StructType("JET_COLUMNCREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szColumnName: ESE.JET_PCWSTR }, + { coltyp: ESE.JET_COLTYP }, + { cbMax: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { pvDefault: ctypes.voidptr_t }, + { cbDefault: ctypes.unsigned_long }, + { cp: ctypes.unsigned_long }, + { columnid: ESE.JET_COLUMNID }, + { err: ESE.JET_ERR }, +]); + +function createColumnCreationWrapper({ name, type, cbMax }) { + // We use a wrapper object because we need to be sure the JS engine won't GC + // data that we're "only" pointing to. + let wrapper = {}; + wrapper.column = new ESE.JET_COLUMNCREATE_W(); + wrapper.column.cbStruct = ESE.JET_COLUMNCREATE_W.size; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(name.length + 1); + wrapper.name.value = String(name); + wrapper.column.szColumnName = wrapper.name; + wrapper.column.coltyp = type; + let fallback = 0; + switch (type) { + case COLUMN_TYPES.JET_coltypText: + fallback = 255; + // Intentional fall-through + case COLUMN_TYPES.JET_coltypLongText: + wrapper.column.cbMax = cbMax || fallback || 64 * 1024; + break; + case COLUMN_TYPES.JET_coltypGUID: + wrapper.column.cbMax = 16; + break; + case COLUMN_TYPES.JET_coltypBit: + wrapper.column.cbMax = 1; + break; + case COLUMN_TYPES.JET_coltypLongLong: + wrapper.column.cbMax = 8; + break; + default: + throw new Error("Unknown column type!"); + } + + wrapper.column.columnid = new ESE.JET_COLUMNID(); + wrapper.column.grbit = 0; + wrapper.column.pvDefault = null; + wrapper.column.cbDefault = 0; + wrapper.column.cp = 0; + + return wrapper; +} + +// "forward declarations" of indexcreate and setinfo structs, which we don't use. +ESE.JET_INDEXCREATE = new ctypes.StructType("JET_INDEXCREATE"); +ESE.JET_SETINFO = new ctypes.StructType("JET_SETINFO"); + +ESE.JET_TABLECREATE_W = new ctypes.StructType("JET_TABLECREATE_W", [ + { cbStruct: ctypes.unsigned_long }, + { szTableName: ESE.JET_PCWSTR }, + { szTemplateTableName: ESE.JET_PCWSTR }, + { ulPages: ctypes.unsigned_long }, + { ulDensity: ctypes.unsigned_long }, + { rgcolumncreate: ESE.JET_COLUMNCREATE_W.ptr }, + { cColumns: ctypes.unsigned_long }, + { rgindexcreate: ESE.JET_INDEXCREATE.ptr }, + { cIndexes: ctypes.unsigned_long }, + { grbit: ESE.JET_GRBIT }, + { tableid: ESE.JET_TABLEID }, + { cCreated: ctypes.unsigned_long }, +]); + +function createTableCreationWrapper(tableName, columns) { + let wrapper = {}; + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + wrapper.name = new wchar_tArray(tableName.length + 1); + wrapper.name.value = String(tableName); + wrapper.table = new ESE.JET_TABLECREATE_W(); + wrapper.table.cbStruct = ESE.JET_TABLECREATE_W.size; + wrapper.table.szTableName = wrapper.name; + wrapper.table.szTemplateTableName = null; + wrapper.table.ulPages = 1; + wrapper.table.ulDensity = 0; + let columnArrayType = ESE.JET_COLUMNCREATE_W.array(columns.length); + wrapper.columnAry = new columnArrayType(); + wrapper.table.rgcolumncreate = wrapper.columnAry.addressOfElement(0); + wrapper.table.cColumns = columns.length; + wrapper.columns = []; + for (let i = 0; i < columns.length; i++) { + let column = columns[i]; + let columnWrapper = createColumnCreationWrapper(column); + wrapper.columnAry.addressOfElement(i).contents = columnWrapper.column; + wrapper.columns.push(columnWrapper); + } + wrapper.table.rgindexcreate = null; + wrapper.table.cIndexes = 0; + return wrapper; +} + +function convertValueForWriting(value, valueType) { + let buffer; + let valueOfValueType = ctypes.UInt64.lo(valueType); + switch (valueOfValueType) { + case COLUMN_TYPES.JET_coltypLongLong: + if (value instanceof Date) { + buffer = new KERNEL.FILETIME(); + let sysTime = new KERNEL.SYSTEMTIME(); + sysTime.wYear = value.getUTCFullYear(); + sysTime.wMonth = value.getUTCMonth() + 1; + sysTime.wDay = value.getUTCDate(); + sysTime.wHour = value.getUTCHours(); + sysTime.wMinute = value.getUTCMinutes(); + sysTime.wSecond = value.getUTCSeconds(); + sysTime.wMilliseconds = value.getUTCMilliseconds(); + let rv = KERNEL.SystemTimeToFileTime( + sysTime.address(), + buffer.address() + ); + if (!rv) { + throw new Error("Failed to get FileTime."); + } + return [buffer, KERNEL.FILETIME.size]; + } + throw new Error("Unrecognized value for longlong column"); + case COLUMN_TYPES.JET_coltypLongText: + let wchar_tArray = ctypes.ArrayType(ctypes.char16_t); + buffer = new wchar_tArray(value.length + 1); + buffer.value = String(value); + return [buffer, buffer.length * 2]; + case COLUMN_TYPES.JET_coltypBit: + buffer = new ctypes.uint8_t(); + // Bizarre boolean values, but whatever: + buffer.value = value ? 255 : 0; + return [buffer, 1]; + case COLUMN_TYPES.JET_coltypGUID: + let byteArray = ctypes.ArrayType(ctypes.uint8_t); + buffer = new byteArray(16); + let j = 0; + for (let i = 0; i < value.length; i++) { + if (!/[0-9a-f]/i.test(value[i])) { + continue; + } + let byteAsHex = value.substr(i, 2); + buffer[j++] = parseInt(byteAsHex, 16); + i++; + } + return [buffer, 16]; + } + + throw new Error("Unknown type " + valueType); +} + +let initializedESE = false; + +let eseDBWritingHelpers = { + setupDB(dbFile, tables) { + if (!initializedESE) { + initializedESE = true; + loadLibraries(); + + KERNEL.SystemTimeToFileTime = gLibs.kernel.declare( + "SystemTimeToFileTime", + ctypes.winapi_abi, + ctypes.bool, + KERNEL.SYSTEMTIME.ptr, + KERNEL.FILETIME.ptr + ); + + declareESEFunction( + "CreateDatabaseW", + ESE.JET_SESID, + ESE.JET_PCWSTR, + ESE.JET_PCWSTR, + ESE.JET_DBID.ptr, + ESE.JET_GRBIT + ); + declareESEFunction( + "CreateTableColumnIndexW", + ESE.JET_SESID, + ESE.JET_DBID, + ESE.JET_TABLECREATE_W.ptr + ); + declareESEFunction("BeginTransaction", ESE.JET_SESID); + declareESEFunction("CommitTransaction", ESE.JET_SESID, ESE.JET_GRBIT); + declareESEFunction( + "PrepareUpdate", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.unsigned_long + ); + declareESEFunction( + "Update", + ESE.JET_SESID, + ESE.JET_TABLEID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ctypes.unsigned_long.ptr + ); + declareESEFunction( + "SetColumn", + ESE.JET_SESID, + ESE.JET_TABLEID, + ESE.JET_COLUMNID, + ctypes.voidptr_t, + ctypes.unsigned_long, + ESE.JET_GRBIT, + ESE.JET_SETINFO.ptr + ); + ESE.SetSystemParameterW( + null, + 0, + 64 /* JET_paramDatabasePageSize*/, + 8192, + null + ); + } + + let rootPath = dbFile.parent.path + "\\"; + let logPath = rootPath + "LogFiles\\"; + + try { + this._instanceId = new ESE.JET_INSTANCE(); + ESE.CreateInstanceW( + this._instanceId.address(), + "firefox-dbwriter-" + gESEInstanceCounter++ + ); + this._instanceCreated = true; + + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 0 /* JET_paramSystemPath*/, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 1 /* JET_paramTempPath */, + 0, + rootPath + ); + ESE.SetSystemParameterW( + this._instanceId.address(), + 0, + 2 /* JET_paramLogFilePath*/, + 0, + logPath + ); + // Shouldn't try to call JetTerm if the following call fails. + this._instanceCreated = false; + ESE.Init(this._instanceId.address()); + this._instanceCreated = true; + this._sessionId = new ESE.JET_SESID(); + ESE.BeginSessionW( + this._instanceId, + this._sessionId.address(), + null, + null + ); + this._sessionCreated = true; + + this._dbId = new ESE.JET_DBID(); + this._dbPath = rootPath + "spartan.edb"; + ESE.CreateDatabaseW( + this._sessionId, + this._dbPath, + null, + this._dbId.address(), + 0 + ); + this._opened = this._attached = true; + + for (let [tableName, data] of tables) { + let { rows, columns } = data; + let tableCreationWrapper = createTableCreationWrapper( + tableName, + columns + ); + ESE.CreateTableColumnIndexW( + this._sessionId, + this._dbId, + tableCreationWrapper.table.address() + ); + this._tableId = tableCreationWrapper.table.tableid; + + let columnIdMap = new Map(); + if (rows.length) { + // Iterate over the struct we passed into ESENT because they have the + // created column ids. + let columnCount = ctypes.UInt64.lo( + tableCreationWrapper.table.cColumns + ); + let columnsPassed = tableCreationWrapper.table.rgcolumncreate; + for (let i = 0; i < columnCount; i++) { + let column = columnsPassed.contents; + columnIdMap.set(column.szColumnName.readString(), column); + columnsPassed = columnsPassed.increment(); + } + ESE.ManualMove( + this._sessionId, + this._tableId, + -2147483648 /* JET_MoveFirst */, + 0 + ); + ESE.BeginTransaction(this._sessionId); + for (let row of rows) { + ESE.PrepareUpdate( + this._sessionId, + this._tableId, + 0 /* JET_prepInsert */ + ); + for (let columnName in row) { + let col = columnIdMap.get(columnName); + let colId = col.columnid; + let [val, valSize] = convertValueForWriting( + row[columnName], + col.coltyp + ); + /* JET_bitSetOverwriteLV */ + ESE.SetColumn( + this._sessionId, + this._tableId, + colId, + val.address(), + valSize, + 4, + null + ); + } + let actualBookmarkSize = new ctypes.unsigned_long(); + ESE.Update( + this._sessionId, + this._tableId, + null, + 0, + actualBookmarkSize.address() + ); + } + ESE.CommitTransaction( + this._sessionId, + 0 /* JET_bitWaitLastLevel0Commit */ + ); + } + } + } finally { + try { + this._close(); + } catch (ex) { + console.error(ex); + } + } + }, + + _close() { + if (this._tableId) { + ESE.FailSafeCloseTable(this._sessionId, this._tableId); + delete this._tableId; + } + if (this._opened) { + ESE.FailSafeCloseDatabase(this._sessionId, this._dbId, 0); + this._opened = false; + } + if (this._attached) { + ESE.FailSafeDetachDatabaseW(this._sessionId, this._dbPath); + this._attached = false; + } + if (this._sessionCreated) { + ESE.FailSafeEndSession(this._sessionId, 0); + this._sessionCreated = false; + } + if (this._instanceCreated) { + ESE.FailSafeTerm(this._instanceId); + this._instanceCreated = false; + } + }, +}; + +add_task(async function () { + let tempFile = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempFile.append("fx-xpcshell-edge-db"); + tempFile.createUnique(tempFile.DIRECTORY_TYPE, 0o600); + + let db = tempFile.clone(); + db.append("spartan.edb"); + + let logs = tempFile.clone(); + logs.append("LogFiles"); + logs.create(tempFile.DIRECTORY_TYPE, 0o600); + + let creationDate = new Date(Date.now() - 5000); + const kEdgeMenuParent = "62d07e2b-5f0d-4e41-8426-5f5ec9717beb"; + let bookmarkReferenceItems = [ + { + URL: "http://www.mozilla.org/", + Title: "Mozilla", + DateUpdated: new Date(creationDate.valueOf() + 100), + ItemId: "1c00c10a-15f6-4618-92dd-22575102a4da", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Folder", + DateUpdated: new Date(creationDate.valueOf() + 200), + ItemId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in folder", + URL: "http://www.iteminfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 300), + ItemId: "c295ddaf-04a1-424a-866c-0ebde011e7c8", + ParentId: "564b21f2-05d6-4f7d-8499-304d00ccc3aa", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "Deleted folder", + DateUpdated: new Date(creationDate.valueOf() + 400), + ItemId: "a547573c-4d4d-4406-a736-5b5462d93bca", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: true, + }, + { + Title: "Deleted item", + URL: "http://www.deleteditem.org/", + DateUpdated: new Date(creationDate.valueOf() + 500), + ItemId: "37a574bb-b44b-4bbc-a414-908615536435", + ParentId: kEdgeMenuParent, + IsFolder: false, + IsDeleted: true, + }, + { + Title: "Item in deleted folder (should be in root)", + URL: "http://www.itemindeletedfolder.org/", + DateUpdated: new Date(creationDate.valueOf() + 600), + ItemId: "74dd1cc3-4c5d-471f-bccc-7bc7c72fa621", + ParentId: "a547573c-4d4d-4406-a736-5b5462d93bca", + IsFolder: false, + IsDeleted: false, + }, + { + Title: "_Favorites_Bar_", + DateUpdated: new Date(creationDate.valueOf() + 700), + ItemId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + ParentId: kEdgeMenuParent, + IsFolder: true, + IsDeleted: false, + }, + { + Title: "Item in favorites bar", + URL: "http://www.iteminfavoritesbar.org/", + DateUpdated: new Date(creationDate.valueOf() + 800), + ItemId: "9f2b1ff8-b651-46cf-8f41-16da8bcb6791", + ParentId: "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf", + IsFolder: false, + IsDeleted: false, + }, + ]; + + let readingListReferenceItems = [ + { + Title: "Some mozilla page", + URL: "http://www.mozilla.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 900), + ItemId: "c88426fd-52a7-419d-acbc-d2310e8afebe", + IsDeleted: false, + }, + { + Title: "Some other page", + URL: "https://www.example.org/somepage/", + AddedDate: new Date(creationDate.valueOf() + 1000), + ItemId: "a35fc843-5d5a-4d1e-9be8-45214be24b5c", + IsDeleted: false, + }, + ]; + + // The following entries are expected to be skipped as being too old to + // migrate. + let expiredTypedURLsReferenceItems = [ + { + URL: "https://expired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(500), + }, + { + URL: "https://expired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(300), + }, + { + URL: "https://expired3.invalid/", + AccessDateTimeUTC: dateDaysAgo(190), + }, + ]; + + // The following entries should be new enough to migrate. + let unexpiredTypedURLsReferenceItems = [ + { + URL: "https://unexpired1.invalid/", + AccessDateTimeUTC: dateDaysAgo(179), + }, + { + URL: "https://unexpired2.invalid/", + AccessDateTimeUTC: dateDaysAgo(50), + }, + { + URL: "https://unexpired3.invalid/", + }, + ]; + + let typedURLsReferenceItems = [ + ...expiredTypedURLsReferenceItems, + ...unexpiredTypedURLsReferenceItems, + ]; + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + eseDBWritingHelpers.setupDB( + db, + new Map([ + [ + "Favorites", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "DateUpdated" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsFolder" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ParentId" }, + ], + rows: bookmarkReferenceItems, + }, + ], + [ + "ReadingList", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongText, + name: "Title", + cbMax: 4096, + }, + { type: COLUMN_TYPES.JET_coltypLongLong, name: "AddedDate" }, + { type: COLUMN_TYPES.JET_coltypGUID, name: "ItemId" }, + { type: COLUMN_TYPES.JET_coltypBit, name: "IsDeleted" }, + ], + rows: readingListReferenceItems, + }, + ], + [ + "TypedURLs", + { + columns: [ + { type: COLUMN_TYPES.JET_coltypLongText, name: "URL", cbMax: 4096 }, + { + type: COLUMN_TYPES.JET_coltypLongLong, + name: "AccessDateTimeUTC", + }, + ], + rows: typedURLsReferenceItems, + }, + ], + ]) + ); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let bookmarksMigrator = migrator.getBookmarksMigratorForTesting(db); + Assert.ok(bookmarksMigrator.exists, "Should recognize db we just created"); + + let seenBookmarks = []; + let listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + if (title.startsWith("Deleted")) { + ok(false, "Should not see deleted items being bookmarked!"); + } + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let migrateResult = await new Promise(resolve => + bookmarksMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 5, + "Should have seen 5 items being bookmarked." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + + let menuParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.menuGuid + ); + Assert.equal( + menuParents.length, + 3, + "Bookmarks are added to the menu without a folder" + ); + let toolbarParents = seenBookmarks.filter( + item => item.parentGuid == PlacesUtils.bookmarks.toolbarGuid + ); + Assert.equal( + toolbarParents.length, + 1, + "Should have a single item added to the toolbar" + ); + let menuParentGuid = PlacesUtils.bookmarks.menuGuid; + let toolbarParentGuid = PlacesUtils.bookmarks.toolbarGuid; + + let expectedTitlesInMenu = bookmarkReferenceItems + .filter(item => item.ParentId == kEdgeMenuParent) + .map(item => item.Title); + // Hacky, but seems like much the simplest way: + expectedTitlesInMenu.push("Item in deleted folder (should be in root)"); + let expectedTitlesInToolbar = bookmarkReferenceItems + .filter(item => item.ParentId == "921dc8a0-6c83-40ef-8df1-9bd1c5c56aaf") + .map(item => item.Title); + + for (let bookmark of seenBookmarks) { + let shouldBeInMenu = expectedTitlesInMenu.includes(bookmark.title); + let shouldBeInToolbar = expectedTitlesInToolbar.includes(bookmark.title); + if (bookmark.title == "Folder") { + Assert.equal( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should be a folder" + ); + } else { + Assert.notEqual( + bookmark.itemType, + PlacesUtils.bookmarks.TYPE_FOLDER, + "Bookmark " + bookmark.title + " should not be a folder" + ); + } + + if (shouldBeInMenu) { + Assert.equal( + bookmark.parentGuid, + menuParentGuid, + "Item '" + bookmark.title + "' should be in menu" + ); + } else if (shouldBeInToolbar) { + Assert.equal( + bookmark.parentGuid, + toolbarParentGuid, + "Item '" + bookmark.title + "' should be in toolbar" + ); + } else if ( + bookmark.guid == menuParentGuid || + bookmark.guid == toolbarParentGuid + ) { + Assert.ok( + true, + "Expect toolbar and menu folders to not be in menu or toolbar" + ); + } else { + // Bit hacky, but we do need to check this. + Assert.equal( + bookmark.title, + "Item in folder", + "Subfoldered item shouldn't be in menu or toolbar" + ); + let parent = seenBookmarks.find( + maybeParent => maybeParent.guid == bookmark.parentGuid + ); + Assert.equal( + parent && parent.title, + "Folder", + "Subfoldered item should be in subfolder labeled 'Folder'" + ); + } + + let dbItem = bookmarkReferenceItems.find( + someItem => bookmark.title == someItem.Title + ); + if (!dbItem) { + Assert.ok( + [menuParentGuid, toolbarParentGuid].includes(bookmark.guid), + "This item should be one of the containers" + ); + } else { + Assert.equal(dbItem.URL || "", bookmark.url, "URL is correct"); + Assert.equal( + dbItem.DateUpdated.valueOf(), + new Date(bookmark.dateAdded).valueOf(), + "Date added is correct" + ); + } + } + + MigrationUtils._importQuantities.bookmarks = 0; + seenBookmarks = []; + listener = events => { + for (let event of events) { + let { + id, + itemType, + url, + title, + dateAdded, + guid, + index, + parentGuid, + parentId, + } = event; + seenBookmarks.push({ + id, + parentId, + index, + itemType, + url, + title, + dateAdded, + guid, + parentGuid, + }); + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + let readingListMigrator = migrator.getReadingListMigratorForTesting(db); + Assert.ok(readingListMigrator.exists, "Should recognize db we just created"); + migrateResult = await new Promise(resolve => + readingListMigrator.migrate(resolve) + ).catch(ex => { + console.error(ex); + Assert.ok(false, "Got an exception trying to migrate data! " + ex); + return false; + }); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.ok(migrateResult, "Migration should succeed"); + Assert.equal( + seenBookmarks.length, + 3, + "Should have seen 3 items being bookmarked (2 items + 1 folder)." + ); + Assert.equal( + seenBookmarks.length, + MigrationUtils._importQuantities.bookmarks, + "Telemetry should have items" + ); + let readingListContainerLabel = await MigrationUtils.getLocalizedString( + "imported-edge-reading-list" + ); + + for (let bookmark of seenBookmarks) { + if (readingListContainerLabel == bookmark.title) { + continue; + } + let referenceItem = readingListReferenceItems.find( + item => item.Title == bookmark.title + ); + Assert.ok(referenceItem, "Should have imported what we expected"); + Assert.equal(referenceItem.URL, bookmark.url, "Should have the right URL"); + readingListReferenceItems.splice( + readingListReferenceItems.findIndex(item => item.Title == bookmark.title), + 1 + ); + } + Assert.ok( + !readingListReferenceItems.length, + "Should have seen all expected items." + ); + + let historyDBMigrator = migrator.getHistoryDBMigratorForTesting(db); + await new Promise(resolve => { + historyDBMigrator.migrate(resolve); + }); + Assert.ok(true, "History DB migration done!"); + for (let expiredEntry of expiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(expiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of unexpiredTypedURLsReferenceItems) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry.URL, { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry.URL, "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_Edge_registry_migration.js b/browser/components/migration/tests/unit/test_Edge_registry_migration.js new file mode 100644 index 0000000000..2a400f7858 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Edge_registry_migration.js @@ -0,0 +1,81 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { EdgeProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/EdgeProfileMigrator.sys.mjs" +); +const { MSMigrationUtils } = ChromeUtils.importESModule( + "resource:///modules/MSMigrationUtils.sys.mjs" +); + +/** + * Tests that history visits loaded from the registry from Edge (EdgeHTML) + * that have a visit date older than maxAgeInDays days do not get imported. + */ +add_task(async function test_Edge_history_past_max_days() { + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + sandbox.restore(); + }); + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + ["https://test1.invalid/", dateDaysAgo(500).getTime() * 1000], + ["https://test2.invalid/", dateDaysAgo(450).getTime() * 1000], + ["https://test3.invalid/", dateDaysAgo(300).getTime() * 1000], + ]; + + const UNEXPIRED_VISITS = [ + ["https://test4.invalid/"], + ["https://test5.invalid/", dateDaysAgo(160).getTime() * 1000], + ["https://test6.invalid/", dateDaysAgo(50).getTime() * 1000], + ["https://test7.invalid/", dateDaysAgo(0).getTime() * 1000], + ]; + + const ALL_VISITS = [...EXPIRED_VISITS, ...UNEXPIRED_VISITS]; + + // Fake out the getResources method of the migrator so that we return + // a single fake MigratorResource per availableResourceType. + sandbox.stub(MSMigrationUtils, "getTypedURLs").callsFake(() => { + return new Map(ALL_VISITS); + }); + + // Manually create an EdgeProfileMigrator rather than going through + // MigrationUtils.getMigrator to avoid the user data availability check, since + // we're mocking out that stuff. + let migrator = new EdgeProfileMigrator(); + let registryTypedHistoryMigrator = + migrator.getHistoryRegistryMigratorForTesting(); + await new Promise(resolve => { + registryTypedHistoryMigrator.migrate(resolve); + }); + Assert.ok(true, "History from registry migration done!"); + + for (let expiredEntry of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(expiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let unexpiredEntry of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(unexpiredEntry[0], { + includeVisits: true, + }); + Assert.equal(entry.url, unexpiredEntry[0], "Should have the correct URL"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } +}); diff --git a/browser/components/migration/tests/unit/test_IE_bookmarks.js b/browser/components/migration/tests/unit/test_IE_bookmarks.js new file mode 100644 index 0000000000..9816bb16e3 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_bookmarks.js @@ -0,0 +1,30 @@ +"use strict"; + +add_task(async function () { + let migrator = await MigrationUtils.getMigrator("ie"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable(), "Check migrator source"); + + // Since this test doesn't mock out the favorites, execution is dependent + // on the actual favorites stored on the local machine's IE favorites database. + // As such, we can't assert that bookmarks were migrated to both the bookmarks + // menu and the bookmarks toolbar. + let itemCount = 0; + let listener = events => { + for (let event of events) { + if (event.itemType == PlacesUtils.bookmarks.TYPE_BOOKMARK) { + info("bookmark added: " + event.parentGuid); + itemCount++; + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Ensure telemetry matches actual number of imported items." + ); +}); diff --git a/browser/components/migration/tests/unit/test_IE_history.js b/browser/components/migration/tests/unit/test_IE_history.js new file mode 100644 index 0000000000..f9a1e719a2 --- /dev/null +++ b/browser/components/migration/tests/unit/test_IE_history.js @@ -0,0 +1,187 @@ +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +// These match what we add to IE via InsertIEHistory.exe. +const TEST_ENTRIES = [ + { + url: "http://www.mozilla.org/1", + title: "Mozilla HTTP Test", + }, + { + url: "https://www.mozilla.org/2", + // Test character encoding with a fox emoji: + title: "Mozilla HTTPS Test 🦊", + }, +]; + +function insertIEHistory() { + let file = do_get_file("InsertIEHistory.exe", false); + let process = Cc["@mozilla.org/process/util;1"].createInstance(Ci.nsIProcess); + process.init(file); + + let args = []; + process.run(true, args, args.length); + + Assert.ok(!process.isRunning, "Should be done running"); + Assert.equal(process.exitValue, 0, "Check exit code"); +} + +add_task(async function setup() { + await PlacesUtils.history.clear(); + + insertIEHistory(); + + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_IE_history() { + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let { url, title } of TEST_ENTRIES) { + let entry = await PlacesUtils.history.fetch(url, { includeVisits: true }); + Assert.equal(entry.url, url, "Should have the correct URL"); + Assert.equal(entry.title, title, "Should have the correct title"); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); + +/** + * Tests that history visits from IE that have a visit date older than + * maxAgeInDays days do not get imported. + */ +add_task(async function test_IE_history_past_max_days() { + // The InsertIEHistory program inserts two history visits using the MS COM + // IUrlHistoryStg interface. That interface does not allow us to dictate + // the visit times of those history visits. Thankfully, we can temporarily + // mock out the @mozilla.org/profile/migrator/iehistoryenumerator;1 to return + // some entries that we expect to expire. + + /** + * An implmentation of nsISimpleEnumerator that wraps a JavaScript Array. + */ + class nsSimpleEnumerator { + #items; + #nextIndex; + + constructor(items) { + this.#items = items; + this.#nextIndex = 0; + } + + hasMoreElements() { + return this.#nextIndex < this.#items.length; + } + + getNext() { + if (!this.hasMoreElements()) { + throw Components.Exception("", Cr.NS_ERROR_NOT_AVAILABLE); + } + + return this.#items[this.#nextIndex++]; + } + + [Symbol.iterator]() { + return this.#items.values(); + } + + QueryInterface = ChromeUtils.generateQI(["nsISimpleEnumerator"]); + } + + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS < 300, + "This test expects the current pref to be less than the youngest expired visit." + ); + Assert.ok( + MigrationUtils.HISTORY_MAX_AGE_IN_DAYS > 160, + "This test expects the current pref to be greater than the oldest unexpired visit." + ); + + const EXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test1.invalid")], + ["title", "Test history visit 1"], + ["time", PRTimeDaysAgo(500)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test2.invalid")], + ["title", "Test history visit 2"], + ["time", PRTimeDaysAgo(450)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test3.invalid")], + ["title", "Test history visit 3"], + ["time", PRTimeDaysAgo(300)], + ]), + ]; + + const UNEXPIRED_VISITS = [ + new Map([ + ["uri", Services.io.newURI("https://test4.invalid")], + ["title", "Test history visit 4"], + ]), + new Map([ + ["uri", Services.io.newURI("https://test5.invalid")], + ["title", "Test history visit 5"], + ["time", PRTimeDaysAgo(160)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test6.invalid")], + ["title", "Test history visit 6"], + ["time", PRTimeDaysAgo(50)], + ]), + new Map([ + ["uri", Services.io.newURI("https://test7.invalid")], + ["title", "Test history visit 7"], + ["time", PRTimeDaysAgo(0)], + ]), + ]; + + let fakeIEHistoryEnumerator = MockRegistrar.register( + "@mozilla.org/profile/migrator/iehistoryenumerator;1", + new nsSimpleEnumerator([...EXPIRED_VISITS, ...UNEXPIRED_VISITS]) + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIEHistoryEnumerator); + }); + + let migrator = await MigrationUtils.getMigrator("ie"); + Assert.ok(await migrator.isSourceAvailable(), "Source is available"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let visit of EXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri").spec, { + includeVisits: true, + }); + Assert.equal(entry, null, "Should not have found an entry."); + } + + for (let visit of UNEXPIRED_VISITS) { + let entry = await PlacesUtils.history.fetch(visit.get("uri"), { + includeVisits: true, + }); + Assert.equal( + entry.url, + visit.get("uri").spec, + "Should have the correct URL" + ); + Assert.equal( + entry.title, + visit.get("title"), + "Should have the correct title" + ); + Assert.ok(!!entry.visits.length, "Should have some visits"); + } + + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js new file mode 100644 index 0000000000..12ca5a7b1d --- /dev/null +++ b/browser/components/migration/tests/unit/test_MigrationUtils_timedRetry.js @@ -0,0 +1,29 @@ +"use strict"; + +let tmpFile = FileUtils.getDir("TmpD", [], true); +let dbConn; + +add_task(async function setup() { + tmpFile.append("TestDB"); + dbConn = await Sqlite.openConnection({ path: tmpFile.path }); + + registerCleanupFunction(async () => { + await dbConn.close(); + await IOUtils.remove(tmpFile.path); + }); +}); + +add_task(async function testgetRowsFromDBWithoutLocksRetries() { + let deferred = PromiseUtils.defer(); + let promise = MigrationUtils.getRowsFromDBWithoutLocks( + tmpFile.path, + "Temp DB", + "SELECT * FROM moz_temp_table", + deferred.promise + ); + await new Promise(resolve => do_timeout(50, resolve)); + dbConn + .execute("CREATE TABLE moz_temp_table (id INTEGER PRIMARY KEY)") + .then(deferred.resolve); + await promise; +}); diff --git a/browser/components/migration/tests/unit/test_PasswordFileMigrator.js b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js new file mode 100644 index 0000000000..5f09080877 --- /dev/null +++ b/browser/components/migration/tests/unit/test_PasswordFileMigrator.js @@ -0,0 +1,83 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PasswordFileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FileMigrators.sys.mjs" +); +const { LoginCSVImport } = ChromeUtils.importESModule( + "resource://gre/modules/LoginCSVImport.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { MigrationWizardConstants } = ChromeUtils.importESModule( + "chrome://browser/content/migration/migration-wizard-constants.mjs" +); + +/** + * Tests that the PasswordFileMigrator properly subclasses FileMigratorBase + * and delegates to the LoginCSVImport module. + */ +add_task(async function test_PasswordFileMigrator() { + Services.prefs.setBoolPref("signon.management.page.fileImport.enabled", true); + let sandbox = sinon.createSandbox(); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("signon.management.page.fileImport.enabled"); + sandbox.restore(); + }); + + let migrator = new PasswordFileMigrator(); + Assert.ok( + migrator.constructor.key, + "PasswordFileMigrator implements static getter 'key'" + ); + Assert.ok( + migrator.constructor.displayNameL10nID, + "PasswordFileMigrator implements static getter 'displayNameL10nID'" + ); + Assert.ok( + await migrator.getFilePickerConfig(), + "PasswordFileMigrator returns something for getFilePickerConfig()" + ); + Assert.ok( + migrator.displayedResourceTypes, + "PasswordFileMigrator returns something for displayedResourceTypes" + ); + Assert.ok(migrator.enabled, "PasswordFileMigrator is enabled."); + + const IMPORT_SUMMARY = [ + { + result: "added", + }, + { + result: "added", + }, + { + result: "modified", + }, + ]; + const EXPECTED_SUCCESS_STATE = { + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_NEW]: + "2 added", + [MigrationWizardConstants.DISPLAYED_FILE_RESOURCE_TYPES.PASSWORDS_UPDATED]: + "1 updated", + }; + const FAKE_PATH = "some/fake/path.csv"; + + let importFromCSVStub = sandbox + .stub(LoginCSVImport, "importFromCSV") + .callsFake(somePath => { + Assert.equal(somePath, FAKE_PATH, "Got expected path"); + return Promise.resolve(IMPORT_SUMMARY); + }); + let result = await migrator.migrate(FAKE_PATH); + + Assert.ok(importFromCSVStub.called, "The stub should have been called."); + Assert.deepEqual( + result, + EXPECTED_SUCCESS_STATE, + "Got back the expected success state." + ); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_bookmarks.js b/browser/components/migration/tests/unit/test_Safari_bookmarks.js new file mode 100644 index 0000000000..85be9f0049 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_bookmarks.js @@ -0,0 +1,85 @@ +"use strict"; + +const { CustomizableUI } = ChromeUtils.importESModule( + "resource:///modules/CustomizableUI.sys.mjs" +); + +add_task(async function () { + registerFakePath("ULibDir", do_get_file("Library/")); + const faviconPath = do_get_file( + "Library/Safari/Favicon Cache/favicons.db" + ).path; + + let migrator = await MigrationUtils.getMigrator("safari"); + // Sanity check for the source. + Assert.ok(await migrator.isSourceAvailable()); + + // Wait for the imported bookmarks. We don't check that "From Safari" + // folders are created on the toolbar since the profile + // we're importing to has less than 3 bookmarks in the destination + // so a "From Safari" folder isn't created. + let expectedParentGuids = [PlacesUtils.bookmarks.toolbarGuid]; + let itemCount = 0; + + let gotFolder = false; + let listener = events => { + for (let event of events) { + itemCount++; + if ( + event.itemType == PlacesUtils.bookmarks.TYPE_FOLDER && + event.title == "Food and Travel" + ) { + gotFolder = true; + } + if (expectedParentGuids.length) { + let index = expectedParentGuids.indexOf(event.parentGuid); + Assert.ok(index != -1, "Found expected parent"); + expectedParentGuids.splice(index, 1); + } + } + }; + PlacesUtils.observers.addListener(["bookmark-added"], listener); + let observerNotified = false; + Services.obs.addObserver((aSubject, aTopic, aData) => { + let [toolbar, visibility] = JSON.parse(aData); + Assert.equal( + toolbar, + CustomizableUI.AREA_BOOKMARKS, + "Notification should be received for bookmarks toolbar" + ); + Assert.equal( + visibility, + "true", + "Notification should say to reveal the bookmarks toolbar" + ); + observerNotified = true; + }, "browser-set-toolbar-visibility"); + + await promiseMigration(migrator, MigrationUtils.resourceTypes.BOOKMARKS); + PlacesUtils.observers.removeListener(["bookmark-added"], listener); + + // Check the bookmarks have been imported to all the expected parents. + Assert.ok(!expectedParentGuids.length, "No more expected parents"); + Assert.ok(gotFolder, "Should have seen the folder get imported"); + Assert.equal(itemCount, 14, "Should import all 14 items."); + // Check that the telemetry matches: + Assert.equal( + MigrationUtils._importQuantities.bookmarks, + itemCount, + "Telemetry reporting correct." + ); + + // Check that favicons migrated + let faviconURIs = await MigrationUtils.getRowsFromDBWithoutLocks( + faviconPath, + "Safari Bookmark Favicons", + `SELECT I.uuid, I.url AS favicon_url, P.url + FROM icon_info I + INNER JOIN page_url P ON I.uuid = P.uuid;` + ); + let pageUrls = Array.from(faviconURIs, row => + Services.io.newURI(row.getResultByName("url")) + ); + await assertFavicons(pageUrls); + Assert.ok(observerNotified, "The observer should be notified upon migration"); +}); diff --git a/browser/components/migration/tests/unit/test_Safari_history.js b/browser/components/migration/tests/unit/test_Safari_history.js new file mode 100644 index 0000000000..c5b1210073 --- /dev/null +++ b/browser/components/migration/tests/unit/test_Safari_history.js @@ -0,0 +1,101 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const HISTORY_TEMPLATE_FILE_PATH = "Library/Safari/HistoryTemplate.db"; +const HISTORY_FILE_PATH = "Library/Safari/History.db"; + +// We want this to be some recent time, so we'll always add some time to our +// dates to keep them ~ five days ago. +const MS_FROM_REFERENCE_TIME = + new Date() - new Date("May 31, 2023 00:00:00 UTC"); + +const TEST_URLS = [ + { + url: "http://example.com/", + title: "Example Domain", + time: 706743588.04751, + jsTime: 1685050788047 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "http://mozilla.org/", + title: "", + time: 706743581.133386, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, + { + url: "https://www.mozilla.org/en-CA/", + title: "Internet for people, not profit - Mozilla", + time: 706743581.133679, + jsTime: 1685050781133 + MS_FROM_REFERENCE_TIME, + visits: 1, + }, +]; + +async function setupHistoryFile() { + removeHistoryFile(); + let file = do_get_file(HISTORY_TEMPLATE_FILE_PATH); + file.copyTo(file.parent, "History.db"); + await updateVisitTimes(); +} + +function removeHistoryFile() { + let file = do_get_file(HISTORY_FILE_PATH, true); + try { + file.remove(false); + } catch (ex) { + // It is ok if this doesn't exist. + if (ex.result != Cr.NS_ERROR_FILE_NOT_FOUND) { + throw ex; + } + } +} + +async function updateVisitTimes() { + let cocoaDifference = MS_FROM_REFERENCE_TIME / 1000; + let historyFile = do_get_file(HISTORY_FILE_PATH); + let dbConn = await Sqlite.openConnection({ path: historyFile.path }); + await dbConn.execute( + "UPDATE history_visits SET visit_time = visit_time + :difference;", + { difference: cocoaDifference } + ); + await dbConn.close(); +} + +add_setup(async function setup() { + registerFakePath("ULibDir", do_get_file("Library/")); + await setupHistoryFile(); + registerCleanupFunction(async () => { + await PlacesUtils.history.clear(); + removeHistoryFile(); + }); +}); + +add_task(async function testHistoryImport() { + await PlacesUtils.history.clear(); + + let migrator = await MigrationUtils.getMigrator("safari"); + await promiseMigration(migrator, MigrationUtils.resourceTypes.HISTORY); + + for (let urlInfo of TEST_URLS) { + let entry = await PlacesUtils.history.fetch(urlInfo.url, { + includeVisits: true, + }); + + Assert.equal(entry.url, urlInfo.url, "Should have the correct URL"); + Assert.equal(entry.title, urlInfo.title, "Should have the correct title"); + Assert.equal( + entry.visits.length, + urlInfo.visits, + "Should have the correct number of visits" + ); + Assert.equal( + entry.visits[0].date.getTime(), + urlInfo.jsTime, + "Should have the correct date" + ); + } +}); diff --git a/browser/components/migration/tests/unit/test_fx_telemetry.js b/browser/components/migration/tests/unit/test_fx_telemetry.js new file mode 100644 index 0000000000..2660998588 --- /dev/null +++ b/browser/components/migration/tests/unit/test_fx_telemetry.js @@ -0,0 +1,393 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; +const { FirefoxProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/FirefoxProfileMigrator.sys.mjs" +); +const { InternalTestingProfileMigrator } = ChromeUtils.importESModule( + "resource:///modules/InternalTestingProfileMigrator.sys.mjs" +); + +// These preferences are set to true anytime MigratorBase.migrate +// successfully completes a migration of their type. +const BOOKMARKS_PREF = "browser.migrate.interactions.bookmarks"; +const HISTORY_PREF = "browser.migrate.interactions.history"; +const PASSWORDS_PREF = "browser.migrate.interactions.passwords"; + +function readFile(file) { + let stream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance( + Ci.nsIFileInputStream + ); + stream.init(file, -1, -1, Ci.nsIFileInputStream.CLOSE_ON_EOF); + + let sis = Cc["@mozilla.org/scriptableinputstream;1"].createInstance( + Ci.nsIScriptableInputStream + ); + sis.init(stream); + let contents = sis.read(file.fileSize); + sis.close(); + return contents; +} + +function checkDirectoryContains(dir, files) { + print("checking " + dir.path + " - should contain " + Object.keys(files)); + let seen = new Set(); + for (let file of dir.directoryEntries) { + print("found file: " + file.path); + Assert.ok(file.leafName in files, file.leafName + " exists, but shouldn't"); + + let expectedContents = files[file.leafName]; + if (typeof expectedContents != "string") { + // it's a subdir - recurse! + Assert.ok(file.isDirectory(), "should be a subdir"); + let newDir = dir.clone(); + newDir.append(file.leafName); + checkDirectoryContains(newDir, expectedContents); + } else { + Assert.ok(!file.isDirectory(), "should be a regular file"); + let contents = readFile(file); + Assert.equal(contents, expectedContents); + } + seen.add(file.leafName); + } + let missing = []; + for (let x in files) { + if (!seen.has(x)) { + missing.push(x); + } + } + Assert.deepEqual(missing, [], "no missing files in " + dir.path); +} + +function getTestDirs() { + // we make a directory structure in a temp dir which mirrors what we are + // testing. + let tempDir = do_get_tempdir(); + let srcDir = tempDir.clone(); + srcDir.append("test_source_dir"); + srcDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let targetDir = tempDir.clone(); + targetDir.append("test_target_dir"); + targetDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + // no need to cleanup these dirs - the xpcshell harness will do it for us. + return [srcDir, targetDir]; +} + +function writeToFile(dir, leafName, contents) { + let file = dir.clone(); + file.append(leafName); + + let outputStream = FileUtils.openFileOutputStream(file); + outputStream.write(contents, contents.length); + outputStream.close(); +} + +function createSubDir(dir, subDirName) { + let subDir = dir.clone(); + subDir.append(subDirName); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + return subDir; +} + +async function promiseMigrator(name, srcDir, targetDir) { + // As the FirefoxProfileMigrator is a startup-only migrator, we import its + // module and instantiate it directly rather than going through MigrationUtils, + // to bypass that availability check. + let migrator = new FirefoxProfileMigrator(); + let migrators = migrator._getResourcesInternal(srcDir, targetDir); + for (let m of migrators) { + if (m.name == name) { + return new Promise(resolve => m.migrate(resolve)); + } + } + throw new Error("failed to find the " + name + " migrator"); +} + +function promiseTelemetryMigrator(srcDir, targetDir) { + return promiseMigrator("telemetry", srcDir, targetDir); +} + +add_task(async function test_empty() { + let [srcDir, targetDir] = getTestDirs(); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true with empty directories"); + // check both are empty + checkDirectoryContains(srcDir, {}); + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_migrate_files() { + let [srcDir, targetDir] = getTestDirs(); + + // Set up datareporting files, some to copy, some not. + let stateContent = JSON.stringify({ + clientId: "68d5474e-19dc-45c1-8e9a-81fca592707c", + }); + let sessionStateContent = "foobar 5432"; + let subDir = createSubDir(srcDir, "datareporting"); + writeToFile(subDir, "state.json", stateContent); + writeToFile(subDir, "session-state.json", sessionStateContent); + writeToFile(subDir, "other.file", "do not copy"); + + let archived = createSubDir(subDir, "archived"); + writeToFile(archived, "other.file", "do not copy"); + + // Set up FHR files, they should not be copied. + writeToFile(srcDir, "healthreport.sqlite", "do not copy"); + writeToFile(srcDir, "healthreport.sqlite-wal", "do not copy"); + subDir = createSubDir(srcDir, "healthreport"); + writeToFile(subDir, "state.json", "do not copy"); + writeToFile(subDir, "other.file", "do not copy"); + + // Perform migration. + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true with important telemetry files copied" + ); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_datareporting_not_dir() { + let [srcDir, targetDir] = getTestDirs(); + + writeToFile(srcDir, "datareporting", "I'm a file but should be a directory"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok( + ok, + "callback should have been true even though the directory was a file" + ); + + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with an empty 'datareporting' subdir. + createSubDir(srcDir, "datareporting"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, { + datareporting: {}, + }); +}); + +add_task(async function test_healthreport_empty() { + let [srcDir, targetDir] = getTestDirs(); + + // Migrate with no 'datareporting' and an empty 'healthreport' subdir. + createSubDir(srcDir, "healthreport"); + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + // We should end up with no migrated files. + checkDirectoryContains(targetDir, {}); +}); + +add_task(async function test_datareporting_many() { + let [srcDir, targetDir] = getTestDirs(); + + // Create some datareporting files. + let subDir = createSubDir(srcDir, "datareporting"); + let shouldBeCopied = "should be copied"; + writeToFile(subDir, "state.json", shouldBeCopied); + writeToFile(subDir, "session-state.json", shouldBeCopied); + writeToFile(subDir, "something.else", "should not"); + createSubDir(subDir, "emptyDir"); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": shouldBeCopied, + "session-state.json": shouldBeCopied, + }, + }); +}); + +add_task(async function test_no_session_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let stateContent = "abcd984"; + writeToFile(subDir, "state.json", stateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "state.json": stateContent, + }, + }); +}); + +add_task(async function test_no_state() { + let [srcDir, targetDir] = getTestDirs(); + + // Check that migration still works properly if we only have session-state.json. + let subDir = createSubDir(srcDir, "datareporting"); + let sessionStateContent = "abcd512"; + writeToFile(subDir, "session-state.json", sessionStateContent); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + checkDirectoryContains(targetDir, { + datareporting: { + "session-state.json": sessionStateContent, + }, + }); +}); + +add_task(async function test_times_migration() { + let [srcDir, targetDir] = getTestDirs(); + + // create a times.json in the source directory. + let contents = JSON.stringify({ created: 1234 }); + writeToFile(srcDir, "times.json", contents); + + let earliest = Date.now(); + let ok = await promiseMigrator("times", srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + let latest = Date.now(); + + let timesFile = targetDir.clone(); + timesFile.append("times.json"); + + let raw = readFile(timesFile); + let times = JSON.parse(raw); + Assert.ok(times.reset >= earliest && times.reset <= latest); + // and it should have left the creation time alone. + Assert.equal(times.created, 1234); +}); + +/** + * Tests that when importing bookmarks, history, or passwords, we + * set interaction prefs. These preferences are sent using + * TelemetryEnvironment.sys.mjs. + */ +add_task(async function test_interaction_telemetry() { + let testingMigrator = await MigrationUtils.getMigrator( + InternalTestingProfileMigrator.key + ); + + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + // Ensure that these prefs start false. + Assert.ok(!Services.prefs.getBoolPref(BOOKMARKS_PREF)); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.BOOKMARKS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(HISTORY_PREF)); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.HISTORY, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok(!Services.prefs.getBoolPref(PASSWORDS_PREF)); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.PASSWORDS, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); + + // Now make sure that we still record these if we migrate a + // series of resources at the same time. + Services.prefs.clearUserPref(BOOKMARKS_PREF); + Services.prefs.clearUserPref(HISTORY_PREF); + Services.prefs.clearUserPref(PASSWORDS_PREF); + + await testingMigrator.migrate( + MigrationUtils.resourceTypes.ALL, + false, + InternalTestingProfileMigrator.testProfile + ); + Assert.ok( + Services.prefs.getBoolPref(BOOKMARKS_PREF), + "Bookmarks pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(HISTORY_PREF), + "History pref should have been set." + ); + Assert.ok( + Services.prefs.getBoolPref(PASSWORDS_PREF), + "Passwords pref should have been set." + ); +}); + +/** + * Tests that interaction preferences used for TelemetryEnvironment are + * persisted across profile resets. + */ +add_task(async function test_interaction_telemetry_persist_across_reset() { + const PREFS = ` +user_pref("${BOOKMARKS_PREF}", true); +user_pref("${HISTORY_PREF}", true); +user_pref("${PASSWORDS_PREF}", true); + `; + + let [srcDir, targetDir] = getTestDirs(); + writeToFile(srcDir, "prefs.js", PREFS); + + let ok = await promiseTelemetryMigrator(srcDir, targetDir); + Assert.ok(ok, "callback should have been true"); + + let prefsPath = PathUtils.join(targetDir.path, "prefs.js"); + Assert.ok(await IOUtils.exists(prefsPath), "Prefs should have been written."); + let writtenPrefsString = await IOUtils.readUTF8(prefsPath); + for (let prefKey of [BOOKMARKS_PREF, HISTORY_PREF, PASSWORDS_PREF]) { + const EXPECTED = `user_pref("${prefKey}", true);`; + Assert.ok(writtenPrefsString.includes(EXPECTED), "Found persisted pref."); + } +}); diff --git a/browser/components/migration/tests/unit/xpcshell.ini b/browser/components/migration/tests/unit/xpcshell.ini new file mode 100644 index 0000000000..d35b021c61 --- /dev/null +++ b/browser/components/migration/tests/unit/xpcshell.ini @@ -0,0 +1,58 @@ +[DEFAULT] +head = head_migration.js +tags = condprof +firefox-appdir = browser +skip-if = toolkit == 'android' # bug 1730213 +prefs = + browser.migrate.showBookmarksToolbarAfterMigration=true +support-files = + Library/** + AppData/** + bookmarks.exported.html + bookmarks.exported.json + +[test_360se_bookmarks.js] +skip-if = os != "win" +[test_360seMigrationUtils.js] +run-if = os == "win" +[test_BookmarksFileMigrator.js] +[test_ChromeMigrationUtils_path_chromium_snap.js] +run-if = os == "linux" +[test_Chrome_bookmarks.js] +[test_Chrome_corrupt_history.js] +[test_Chrome_credit_cards.js] +skip-if = os != "win" && os != "mac" + condprof # bug 1769154 - not realistic for condprof +[test_Chrome_formdata.js] +[test_Chrome_history.js] +skip-if = os != "mac" # Relies on ULibDir +[test_Chrome_passwords.js] +skip-if = os != "win" && os != "mac" + condprof # bug 1769154 - not realistic for condprof +[test_Chrome_passwords_emptySource.js] +skip-if = os != "win" && os != "mac" + condprof # bug 1769154 - not realistic for condprof +support-files = + LibraryWithNoData/** +[test_ChromeMigrationUtils.js] +[test_ChromeMigrationUtils_path.js] +[test_Edge_db_migration.js] +skip-if = os != "win" +[test_Edge_registry_migration.js] +skip-if = os != "win" +[test_PasswordFileMigrator.js] +[test_fx_telemetry.js] +[test_IE_bookmarks.js] +skip-if = !(os == "win" && bits == 64) # bug 1392396 +[test_IE_history.js] +skip-if = + os != "win" + os == "win" && msix # https://bugzilla.mozilla.org/show_bug.cgi?id=1807928 +[test_IE7_passwords.js] +skip-if = os != "win" +[test_MigrationUtils_timedRetry.js] +skip-if = !debug && os == "mac" #Bug 1558330 +[test_Safari_bookmarks.js] +skip-if = os != "mac" +[test_Safari_history.js] +skip-if = os != "mac" -- cgit v1.2.3