diff options
Diffstat (limited to 'browser/components/backup')
27 files changed, 2466 insertions, 43 deletions
diff --git a/browser/components/backup/.eslintrc.js b/browser/components/backup/.eslintrc.js deleted file mode 100644 index 9aafb4a214..0000000000 --- a/browser/components/backup/.eslintrc.js +++ /dev/null @@ -1,9 +0,0 @@ -/* This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ - -"use strict"; - -module.exports = { - extends: ["plugin:mozilla/require-jsdoc"], -}; diff --git a/browser/components/backup/BackupResources.sys.mjs b/browser/components/backup/BackupResources.sys.mjs index 276fabefdf..ce7f53b10d 100644 --- a/browser/components/backup/BackupResources.sys.mjs +++ b/browser/components/backup/BackupResources.sys.mjs @@ -2,14 +2,28 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -// Remove this import after BackupResource is referenced elsewhere. -// eslint-disable-next-line no-unused-vars -import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; - /** * Classes exported here are registered as a resource that can be * backed up and restored in the BackupService. * * They must extend the BackupResource base class. */ -export {}; +import { AddonsBackupResource } from "resource:///modules/backup/AddonsBackupResource.sys.mjs"; +import { CookiesBackupResource } from "resource:///modules/backup/CookiesBackupResource.sys.mjs"; +import { CredentialsAndSecurityBackupResource } from "resource:///modules/backup/CredentialsAndSecurityBackupResource.sys.mjs"; +import { FormHistoryBackupResource } from "resource:///modules/backup/FormHistoryBackupResource.sys.mjs"; +import { MiscDataBackupResource } from "resource:///modules/backup/MiscDataBackupResource.sys.mjs"; +import { PlacesBackupResource } from "resource:///modules/backup/PlacesBackupResource.sys.mjs"; +import { PreferencesBackupResource } from "resource:///modules/backup/PreferencesBackupResource.sys.mjs"; +import { SessionStoreBackupResource } from "resource:///modules/backup/SessionStoreBackupResource.sys.mjs"; + +export { + AddonsBackupResource, + CookiesBackupResource, + CredentialsAndSecurityBackupResource, + FormHistoryBackupResource, + MiscDataBackupResource, + PlacesBackupResource, + PreferencesBackupResource, + SessionStoreBackupResource, +}; diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs index 853f4768ce..3521f315fd 100644 --- a/browser/components/backup/BackupService.sys.mjs +++ b/browser/components/backup/BackupService.sys.mjs @@ -2,7 +2,7 @@ * 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 * as BackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; +import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; const lazy = {}; @@ -37,6 +37,13 @@ export class BackupService { #resources = new Map(); /** + * True if a backup is currently in progress. + * + * @type {boolean} + */ + #backupInProgress = false; + + /** * Returns a reference to a BackupService singleton. If this is the first time * that this getter is accessed, this causes the BackupService singleton to be * be instantiated. @@ -48,27 +55,130 @@ export class BackupService { if (this.#instance) { return this.#instance; } - this.#instance = new BackupService(BackupResources); + this.#instance = new BackupService(DefaultBackupResources); this.#instance.takeMeasurements(); return this.#instance; } /** + * Returns a reference to the BackupService singleton. If the singleton has + * not been initialized, an error is thrown. + * + * @static + * @returns {BackupService} + */ + static get() { + if (!this.#instance) { + throw new Error("BackupService not initialized"); + } + return this.#instance; + } + + /** * Create a BackupService instance. * - * @param {object} [backupResources=BackupResources] - Object containing BackupResource classes to associate with this service. + * @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service. */ - constructor(backupResources = BackupResources) { + constructor(backupResources = DefaultBackupResources) { lazy.logConsole.debug("Instantiated"); for (const resourceName in backupResources) { - let resource = BackupResources[resourceName]; + let resource = backupResources[resourceName]; this.#resources.set(resource.key, resource); } } /** + * Create a backup of the user's profile. + * + * @param {object} [options] + * Options for the backup. + * @param {string} [options.profilePath=PathUtils.profileDir] + * The path to the profile to backup. By default, this is the current + * profile. + * @returns {Promise<undefined>} + */ + async createBackup({ profilePath = PathUtils.profileDir } = {}) { + // createBackup does not allow re-entry or concurrent backups. + if (this.#backupInProgress) { + lazy.logConsole.warn("Backup attempt already in progress"); + return; + } + + this.#backupInProgress = true; + + try { + lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`); + + // First, check to see if a `backups` directory already exists in the + // profile. + let backupDirPath = PathUtils.join(profilePath, "backups"); + lazy.logConsole.debug("Creating backups folder"); + + // ignoreExisting: true is the default, but we're being explicit that it's + // okay if this folder already exists. + await IOUtils.makeDirectory(backupDirPath, { ignoreExisting: true }); + + let stagingPath = await this.#prepareStagingFolder(backupDirPath); + + // Perform the backup for each resource. + for (let resourceClass of this.#resources.values()) { + try { + lazy.logConsole.debug( + `Backing up resource with key ${resourceClass.key}. ` + + `Requires encryption: ${resourceClass.requiresEncryption}` + ); + let resourcePath = PathUtils.join(stagingPath, resourceClass.key); + await IOUtils.makeDirectory(resourcePath); + + // `backup` on each BackupResource should return us a ManifestEntry + // that we eventually write to a JSON manifest file, but for now, + // we're just going to log it. + let manifestEntry = await new resourceClass().backup( + resourcePath, + profilePath + ); + lazy.logConsole.debug( + `Backup of resource with key ${resourceClass.key} completed`, + manifestEntry + ); + } catch (e) { + lazy.logConsole.error( + `Failed to backup resource: ${resourceClass.key}`, + e + ); + } + } + } finally { + this.#backupInProgress = false; + } + } + + /** + * Constructs the staging folder for the backup in the passed in backup + * folder. If a pre-existing staging folder exists, it will be cleared out. + * + * @param {string} backupDirPath + * The path to the backup folder. + * @returns {Promise<string>} + * The path to the empty staging folder. + */ + async #prepareStagingFolder(backupDirPath) { + let stagingPath = PathUtils.join(backupDirPath, "staging"); + lazy.logConsole.debug("Checking for pre-existing staging folder"); + if (await IOUtils.exists(stagingPath)) { + // A pre-existing staging folder exists. A previous backup attempt must + // have failed or been interrupted. We'll clear it out. + lazy.logConsole.warn("A pre-existing staging folder exists. Clearing."); + await IOUtils.remove(stagingPath, { recursive: true }); + } + await IOUtils.makeDirectory(stagingPath); + + return stagingPath; + } + + /** * Take measurements of the current profile state for Telemetry. * * @returns {Promise<undefined>} @@ -97,7 +207,14 @@ export class BackupService { // Measure the size of each file we are going to backup. for (let resourceClass of this.#resources.values()) { - await new resourceClass().measure(PathUtils.profileDir); + try { + await new resourceClass().measure(PathUtils.profileDir); + } catch (e) { + lazy.logConsole.error( + `Failed to measure for resource: ${resourceClass.key}`, + e + ); + } } } } diff --git a/browser/components/backup/content/debug.html b/browser/components/backup/content/debug.html new file mode 100644 index 0000000000..5d6517cf2a --- /dev/null +++ b/browser/components/backup/content/debug.html @@ -0,0 +1,46 @@ +<!-- This Source Code Form is subject to the terms of the Mozilla Public + - License, v. 2.0. If a copy of the MPL was not distributed with this + - file, You can obtain one at http://mozilla.org/MPL/2.0/. --> +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Profile backup debug tool</title> + + <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" /> + </head> + <body> + <header> + <h1>Profile backup debug tool</h1> + </header> + + <main> + <section> + <h2>State</h2> + <ol> + <li> + <input + type="checkbox" + preference="browser.backup.enabled" + />BackupService component enabled + </li> + <li> + <input + type="checkbox" + preference="browser.backup.log" + />BackupService debug logging enabled + </li> + </ol> + </section> + <section id="controls"> + <h2>Controls</h2> + <button id="create-backup">Create backup</button> + <button id="open-backup-folder">Open backups folder</button> + </section> + </main> + + <script src="chrome://global/content/preferencesBindings.js"></script> + <script src="chrome://browser/content/backup/debug.js"></script> + </body> +</html> diff --git a/browser/components/backup/content/debug.js b/browser/components/backup/content/debug.js new file mode 100644 index 0000000000..fd673818c0 --- /dev/null +++ b/browser/components/backup/content/debug.js @@ -0,0 +1,59 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +/* import-globals-from /toolkit/content/preferencesBindings.js */ + +Preferences.addAll([ + { id: "browser.backup.enabled", type: "bool" }, + { id: "browser.backup.log", type: "bool" }, +]); + +const { BackupService } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupService.sys.mjs" +); + +let DebugUI = { + init() { + let controls = document.querySelector("#controls"); + controls.addEventListener("click", this); + }, + + handleEvent(event) { + let target = event.target; + if (HTMLButtonElement.isInstance(event.target)) { + this.onButtonClick(target); + } + }, + + async onButtonClick(button) { + switch (button.id) { + case "create-backup": { + let service = BackupService.get(); + button.disabled = true; + await service.createBackup(); + button.disabled = false; + break; + } + case "open-backup-folder": { + let backupsDir = PathUtils.join(PathUtils.profileDir, "backups"); + + let nsLocalFile = Components.Constructor( + "@mozilla.org/file/local;1", + "nsIFile", + "initWithPath" + ); + + if (await IOUtils.exists(backupsDir)) { + new nsLocalFile(backupsDir).reveal(); + } else { + alert("backups folder doesn't exist yet"); + } + + break; + } + } + }, +}; + +DebugUI.init(); diff --git a/browser/components/backup/docs/backup-resources.rst b/browser/components/backup/docs/backup-resources.rst new file mode 100644 index 0000000000..4ead0d316d --- /dev/null +++ b/browser/components/backup/docs/backup-resources.rst @@ -0,0 +1,18 @@ +================================ +Backup Resources Reference +================================ + +A ``BackupResource`` is the base class used to represent a group of data within +a user profile that is logical to backup together. For example, the +``PlacesBackupResource`` represents both the ``places.sqlite`` SQLite database, +as well as the ``favicons.sqlite`` database. The ``AddonsBackupResource`` +represents not only the preferences for various addons, but also the XPI files +that those addons are defined in. + +Each ``BackupResource`` subclass is registered for use by the +``BackupService`` by adding it to the default set of exported classes in the +``BackupResources`` module in ``BackupResources.sys.mjs``. + +.. js:autoclass:: BackupResource + :members: + :private-members: diff --git a/browser/components/backup/docs/index.rst b/browser/components/backup/docs/index.rst index 1e201f8f1c..db9995dad2 100644 --- a/browser/components/backup/docs/index.rst +++ b/browser/components/backup/docs/index.rst @@ -11,3 +11,4 @@ into a single file that can be easily restored from. :maxdepth: 3 backup-service + backup-resources diff --git a/browser/components/backup/jar.mn b/browser/components/backup/jar.mn new file mode 100644 index 0000000000..7800962486 --- /dev/null +++ b/browser/components/backup/jar.mn @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +browser.jar: +#ifdef NIGHTLY_BUILD + content/browser/backup/debug.html (content/debug.html) + content/browser/backup/debug.js (content/debug.js) +#endif diff --git a/browser/components/backup/metrics.yaml b/browser/components/backup/metrics.yaml index 6d6a16a178..cf6f95ee75 100644 --- a/browser/components/backup/metrics.yaml +++ b/browser/components/backup/metrics.yaml @@ -28,3 +28,279 @@ browser.backup: - mconley@mozilla.com expires: never telemetry_mirror: BROWSER_BACKUP_PROF_D_DISK_SPACE + + places_size: + type: quantity + unit: kilobyte + description: > + The total file size of the places.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_PLACES_SIZE + + favicons_size: + type: quantity + unit: kilobyte + description: > + The total file size of the favicons.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883642 + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_FAVICONS_SIZE + + credentials_data_size: + type: quantity + unit: kilobyte + description: > + The total size of logins, payment method, and form autofill related files + in the current profile directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_CREDENTIALS_DATA_SIZE + + security_data_size: + type: quantity + unit: kilobyte + description: > + The total size of files needed for NSS initialization parameters and security + certificate settings in the current profile directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883736 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SECURITY_DATA_SIZE + + preferences_size: + type: quantity + unit: kilobyte + description: > + The total size of files relating to user preferences and permissions in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883739 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883739 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_PREFERENCES_SIZE + + misc_data_size: + type: quantity + unit: kilobyte + description: > + The total size of files for telemetry, site storage, media device origin mapping, + chrome privileged IndexedDB databases, and Mozilla Accounts in the current profile directory, + rounded to the nearest tenth kilobyte. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883747 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887746 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883747 + - https://bugzilla.mozilla.org/show_bug.cgi?id=1887746 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_MISC_DATA_SIZE + + cookies_size: + type: quantity + unit: kilobyte + description: > + The total file size of the cookies.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_COOKIES_SIZE + + form_history_size: + type: quantity + unit: kilobyte + description: > + The file size of the formhistory.sqlite db located in the current profile + directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_FORM_HISTORY_SIZE + + session_store_backups_directory_size: + type: quantity + unit: kilobyte + description: > + The total size of the session store backups directory, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SESSION_STORE_BACKUPS_DIRECTORY_SIZE + + session_store_size: + type: quantity + unit: kilobyte + description: > + The size of uncompressed session store json, in kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883740 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_SESSION_STORE_SIZE + + extensions_json_size: + type: quantity + unit: kilobyte + description: > + The total file size of the current profiles extensions metadata files, + rounded to the nearest 10 kilobytes. + Files included are: + - extensions.json + - extension-settings.json + - extension-preferences.json + - addonStartup.json.lz4 + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_JSON_SIZE + + extension_store_permissions_data_size: + type: quantity + unit: kilobyte + description: > + The file size of the current profiles extension-store-permissions/data.safe.bin + file, rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSION_STORE_PERMISSIONS_DATA_SIZE + + storage_sync_size: + type: quantity + unit: kilobyte + description: > + The file size of the current profiles storage-sync-v2.sqlite db, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_STORAGE_SYNC_SIZE + + browser_extension_data_size: + type: quantity + unit: kilobyte + description: > + The total size of the current profiles storage.local legacy JSON backend + in the browser-extension-data directory, rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_BROWSER_EXTENSION_DATA_SIZE + + extensions_xpi_directory_size: + type: quantity + unit: kilobyte + description: > + The total size of the current profiles extensions directory, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_XPI_DIRECTORY_SIZE + + extensions_storage_size: + type: quantity + unit: kilobyte + description: > + The total size of all extensions storage directories, + rounded to the nearest 10 kilobytes. + bugs: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_reviews: + - https://bugzilla.mozilla.org/show_bug.cgi?id=1883655 + data_sensitivity: + - technical + notification_emails: + - mconley@mozilla.com + expires: never + telemetry_mirror: BROWSER_BACKUP_EXTENSIONS_STORAGE_SIZE diff --git a/browser/components/backup/moz.build b/browser/components/backup/moz.build index 0ea7d66b7d..be548ce81f 100644 --- a/browser/components/backup/moz.build +++ b/browser/components/backup/moz.build @@ -7,6 +7,8 @@ with Files("**"): BUG_COMPONENT = ("Firefox", "Profiles") +JAR_MANIFESTS += ["jar.mn"] + SPHINX_TREES["docs"] = "docs" XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] @@ -14,5 +16,13 @@ XPCSHELL_TESTS_MANIFESTS += ["tests/xpcshell/xpcshell.toml"] EXTRA_JS_MODULES.backup += [ "BackupResources.sys.mjs", "BackupService.sys.mjs", + "resources/AddonsBackupResource.sys.mjs", "resources/BackupResource.sys.mjs", + "resources/CookiesBackupResource.sys.mjs", + "resources/CredentialsAndSecurityBackupResource.sys.mjs", + "resources/FormHistoryBackupResource.sys.mjs", + "resources/MiscDataBackupResource.sys.mjs", + "resources/PlacesBackupResource.sys.mjs", + "resources/PreferencesBackupResource.sys.mjs", + "resources/SessionStoreBackupResource.sys.mjs", ] diff --git a/browser/components/backup/resources/AddonsBackupResource.sys.mjs b/browser/components/backup/resources/AddonsBackupResource.sys.mjs new file mode 100644 index 0000000000..83b97ed2f2 --- /dev/null +++ b/browser/components/backup/resources/AddonsBackupResource.sys.mjs @@ -0,0 +1,100 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Backup for addons and extensions files and data. + */ +export class AddonsBackupResource extends BackupResource { + static get key() { + return "addons"; + } + + static get requiresEncryption() { + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + // Report the total size of the extension json files. + const jsonFiles = [ + "extensions.json", + "extension-settings.json", + "extension-preferences.json", + "addonStartup.json.lz4", + ]; + let extensionsJsonSize = 0; + for (const filePath of jsonFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + extensionsJsonSize += resourceSize; + } + } + Glean.browserBackup.extensionsJsonSize.set(extensionsJsonSize); + + // Report the size of permissions store data, if present. + let extensionStorePermissionsDataPath = PathUtils.join( + profilePath, + "extension-store-permissions", + "data.safe.bin" + ); + let extensionStorePermissionsDataSize = await BackupResource.getFileSize( + extensionStorePermissionsDataPath + ); + if (Number.isInteger(extensionStorePermissionsDataSize)) { + Glean.browserBackup.extensionStorePermissionsDataSize.set( + extensionStorePermissionsDataSize + ); + } + + // Report the size of extensions storage sync database. + let storageSyncPath = PathUtils.join(profilePath, "storage-sync-v2.sqlite"); + let storageSyncSize = await BackupResource.getFileSize(storageSyncPath); + Glean.browserBackup.storageSyncSize.set(storageSyncSize); + + // Report the total size of XPI files in the extensions directory. + let extensionsXpiDirectoryPath = PathUtils.join(profilePath, "extensions"); + let extensionsXpiDirectorySize = await BackupResource.getDirectorySize( + extensionsXpiDirectoryPath, + { + shouldExclude: (filePath, fileType) => + fileType !== "regular" || !filePath.endsWith(".xpi"), + } + ); + Glean.browserBackup.extensionsXpiDirectorySize.set( + extensionsXpiDirectorySize + ); + + // Report the total size of the browser extension data. + let browserExtensionDataPath = PathUtils.join( + profilePath, + "browser-extension-data" + ); + let browserExtensionDataSize = await BackupResource.getDirectorySize( + browserExtensionDataPath + ); + Glean.browserBackup.browserExtensionDataSize.set(browserExtensionDataSize); + + // Report the size of all moz-extension IndexedDB databases. + let defaultStoragePath = PathUtils.join(profilePath, "storage", "default"); + let extensionsStorageSize = await BackupResource.getDirectorySize( + defaultStoragePath, + { + shouldExclude: (filePath, _fileType, parentPath) => { + if ( + parentPath == defaultStoragePath && + !PathUtils.filename(filePath).startsWith("moz-extension") + ) { + return true; + } + return false; + }, + } + ); + if (Number.isInteger(extensionsStorageSize)) { + Glean.browserBackup.extensionsStorageSize.set(extensionsStorageSize); + } + } +} diff --git a/browser/components/backup/resources/BackupResource.sys.mjs b/browser/components/backup/resources/BackupResource.sys.mjs index bde3f0669c..d851eb5199 100644 --- a/browser/components/backup/resources/BackupResource.sys.mjs +++ b/browser/components/backup/resources/BackupResource.sys.mjs @@ -3,7 +3,19 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ // Convert from bytes to kilobytes (not kibibytes). -const BYTES_IN_KB = 1000; +export const BYTES_IN_KB = 1000; + +/** + * Convert bytes to the nearest 10th kilobyte to make the measurements fuzzier. + * + * @param {number} bytes - size in bytes. + * @returns {number} - size in kilobytes rounded to the nearest 10th kilobyte. + */ +export function bytesToFuzzyKilobytes(bytes) { + let sizeInKb = Math.ceil(bytes / BYTES_IN_KB); + let nearestTenthKb = Math.round(sizeInKb / 10) * 10; + return Math.max(nearestTenthKb, 1); +} /** * An abstract class representing a set of data within a user profile @@ -23,6 +35,21 @@ export class BackupResource { } /** + * This must be overridden to return a boolean indicating whether the + * resource requires encryption when being backed up. Encryption should be + * required for particularly sensitive data, such as passwords / credentials, + * cookies, or payment methods. If you're not sure, talk to someone from the + * Privacy team. + * + * @type {boolean} + */ + static get requiresEncryption() { + throw new Error( + "BackupResource::requiresEncryption needs to be overridden." + ); + } + + /** * Get the size of a file. * * @param {string} filePath - path to a file. @@ -40,21 +67,25 @@ export class BackupResource { return null; } - let sizeInKb = Math.ceil(size / BYTES_IN_KB); - // Make the measurement fuzzier by rounding to the nearest 10kb. - let nearestTenthKb = Math.round(sizeInKb / 10) * 10; + let nearestTenthKb = bytesToFuzzyKilobytes(size); - return Math.max(nearestTenthKb, 1); + return nearestTenthKb; } /** * Get the total size of a directory. * * @param {string} directoryPath - path to a directory. + * @param {object} options - A set of additional optional parameters. + * @param {Function} [options.shouldExclude] - an optional callback which based on file path and file type should return true + * if the file should be excluded from the computed directory size. * @returns {Promise<number|null>} - the size of all descendants of the directory in kilobytes, or null if the * directory does not exist, the path is not a directory or the size is unknown. */ - static async getDirectorySize(directoryPath) { + static async getDirectorySize( + directoryPath, + { shouldExclude = () => false } = {} + ) { if (!(await IOUtils.exists(directoryPath))) { return null; } @@ -75,15 +106,20 @@ export class BackupResource { childFilePath ); + if (shouldExclude(childFilePath, childType, directoryPath)) { + continue; + } + if (childSize >= 0) { - let sizeInKb = Math.ceil(childSize / BYTES_IN_KB); - // Make the measurement fuzzier by rounding to the nearest 10kb. - let nearestTenthKb = Math.round(sizeInKb / 10) * 10; - size += Math.max(nearestTenthKb, 1); + let nearestTenthKb = bytesToFuzzyKilobytes(childSize); + + size += nearestTenthKb; } if (childType == "directory") { - let childDirectorySize = await this.getDirectorySize(childFilePath); + let childDirectorySize = await this.getDirectorySize(childFilePath, { + shouldExclude, + }); if (Number.isInteger(childDirectorySize)) { size += childDirectorySize; } @@ -106,4 +142,29 @@ export class BackupResource { async measure(profilePath) { throw new Error("BackupResource::measure needs to be overridden."); } + + /** + * Perform a safe copy of the resource(s) and write them into the backup + * database. The Promise should resolve with an object that can be serialized + * to JSON, as it will be written to the manifest file. This same object will + * be deserialized and passed to restore() when restoring the backup. This + * object can be null if no additional information is needed to restore the + * backup. + * + * @param {string} stagingPath + * The path to the staging folder where copies of the datastores for this + * BackupResource should be written to. + * @param {string} [profilePath=null] + * This is null if the backup is being run on the currently running user + * profile. If, however, the backup is being run on a different user profile + * (for example, it's being run from a BackgroundTask on a user profile that + * just shut down, or during test), then this is a string set to that user + * profile path. + * + * @returns {Promise<object|null>} + */ + // eslint-disable-next-line no-unused-vars + async backup(stagingPath, profilePath = null) { + throw new Error("BackupResource::backup must be overridden"); + } } diff --git a/browser/components/backup/resources/CookiesBackupResource.sys.mjs b/browser/components/backup/resources/CookiesBackupResource.sys.mjs new file mode 100644 index 0000000000..8b988fd532 --- /dev/null +++ b/browser/components/backup/resources/CookiesBackupResource.sys.mjs @@ -0,0 +1,25 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing Cookies database within a user profile. + */ +export class CookiesBackupResource extends BackupResource { + static get key() { + return "cookies"; + } + + static get requiresEncryption() { + return true; + } + + async measure(profilePath = PathUtils.profileDir) { + let cookiesDBPath = PathUtils.join(profilePath, "cookies.sqlite"); + let cookiesSize = await BackupResource.getFileSize(cookiesDBPath); + + Glean.browserBackup.cookiesSize.set(cookiesSize); + } +} diff --git a/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs new file mode 100644 index 0000000000..89069de826 --- /dev/null +++ b/browser/components/backup/resources/CredentialsAndSecurityBackupResource.sys.mjs @@ -0,0 +1,53 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing files needed for logins, payment methods and form autofill within a user profile. + */ +export class CredentialsAndSecurityBackupResource extends BackupResource { + static get key() { + return "credentials_and_security"; + } + + static get requiresEncryption() { + return true; + } + + async measure(profilePath = PathUtils.profileDir) { + const securityFiles = ["cert9.db", "pkcs11.txt"]; + let securitySize = 0; + + for (let filePath of securityFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + securitySize += resourceSize; + } + } + + Glean.browserBackup.securityDataSize.set(securitySize); + + const credentialsFiles = [ + "key4.db", + "logins.json", + "logins-backup.json", + "autofill-profiles.json", + "credentialstate.sqlite", + "signedInUser.json", + ]; + let credentialsSize = 0; + + for (let filePath of credentialsFiles) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + credentialsSize += resourceSize; + } + } + + Glean.browserBackup.credentialsDataSize.set(credentialsSize); + } +} diff --git a/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs new file mode 100644 index 0000000000..cb314eb34d --- /dev/null +++ b/browser/components/backup/resources/FormHistoryBackupResource.sys.mjs @@ -0,0 +1,25 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +/** + * Class representing Form history database within a user profile. + */ +export class FormHistoryBackupResource extends BackupResource { + static get key() { + return "formhistory"; + } + + static get requiresEncryption() { + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + let formHistoryDBPath = PathUtils.join(profilePath, "formhistory.sqlite"); + let formHistorySize = await BackupResource.getFileSize(formHistoryDBPath); + + Glean.browserBackup.formHistorySize.set(formHistorySize); + } +} diff --git a/browser/components/backup/resources/MiscDataBackupResource.sys.mjs b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs new file mode 100644 index 0000000000..97224f0e31 --- /dev/null +++ b/browser/components/backup/resources/MiscDataBackupResource.sys.mjs @@ -0,0 +1,101 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +/** + * Class representing miscellaneous files for telemetry, site storage, + * media device origin mapping, chrome privileged IndexedDB databases, + * and Mozilla Accounts within a user profile. + */ +export class MiscDataBackupResource extends BackupResource { + static get key() { + return "miscellaneous"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + const files = [ + "times.json", + "enumerate_devices.txt", + "SiteSecurityServiceState.bin", + ]; + + for (let fileName of files) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + if (await IOUtils.exists(sourcePath)) { + await IOUtils.copy(sourcePath, destPath, { recursive: true }); + } + } + + const sqliteDatabases = ["protections.sqlite"]; + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await lazy.Sqlite.openConnection({ + path: sourcePath, + readOnly: true, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + + // Bug 1890585 - we don't currently have the ability to copy the + // chrome-privileged IndexedDB databases under storage/permanent/chrome, so + // we'll just skip that for now. + + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + const files = [ + "times.json", + "enumerate_devices.txt", + "protections.sqlite", + "SiteSecurityServiceState.bin", + ]; + + let fullSize = 0; + + for (let filePath of files) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + fullSize += resourceSize; + } + } + + let chromeIndexedDBDirPath = PathUtils.join( + profilePath, + "storage", + "permanent", + "chrome" + ); + let chromeIndexedDBDirSize = await BackupResource.getDirectorySize( + chromeIndexedDBDirPath + ); + if (Number.isInteger(chromeIndexedDBDirSize)) { + fullSize += chromeIndexedDBDirSize; + } + + Glean.browserBackup.miscDataSize.set(fullSize); + } +} diff --git a/browser/components/backup/resources/PlacesBackupResource.sys.mjs b/browser/components/backup/resources/PlacesBackupResource.sys.mjs new file mode 100644 index 0000000000..1955406f51 --- /dev/null +++ b/browser/components/backup/resources/PlacesBackupResource.sys.mjs @@ -0,0 +1,91 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + BookmarkJSONUtils: "resource://gre/modules/BookmarkJSONUtils.sys.mjs", + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + Sqlite: "resource://gre/modules/Sqlite.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isBrowsingHistoryEnabled", + "places.history.enabled", + true +); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "isSanitizeOnShutdownEnabled", + "privacy.sanitize.sanitizeOnShutdown", + false +); + +/** + * Class representing Places database related files within a user profile. + */ +export class PlacesBackupResource extends BackupResource { + static get key() { + return "places"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + const sqliteDatabases = ["places.sqlite", "favicons.sqlite"]; + let canBackupHistory = + !lazy.PrivateBrowsingUtils.permanentPrivateBrowsing && + !lazy.isSanitizeOnShutdownEnabled && + lazy.isBrowsingHistoryEnabled; + + /** + * Do not backup places.sqlite and favicons.sqlite if users have history disabled, want history cleared on shutdown or are using permanent private browsing mode. + * Instead, export all existing bookmarks to a compressed JSON file that we can read when restoring the backup. + */ + if (!canBackupHistory) { + let bookmarksBackupFile = PathUtils.join( + stagingPath, + "bookmarks.jsonlz4" + ); + await lazy.BookmarkJSONUtils.exportToFile(bookmarksBackupFile, { + compress: true, + }); + return { bookmarksOnly: true }; + } + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await lazy.Sqlite.openConnection({ + path: sourcePath, + readOnly: true, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + let placesDBPath = PathUtils.join(profilePath, "places.sqlite"); + let faviconsDBPath = PathUtils.join(profilePath, "favicons.sqlite"); + let placesDBSize = await BackupResource.getFileSize(placesDBPath); + let faviconsDBSize = await BackupResource.getFileSize(faviconsDBPath); + + Glean.browserBackup.placesSize.set(placesDBSize); + Glean.browserBackup.faviconsSize.set(faviconsDBSize); + } +} diff --git a/browser/components/backup/resources/PreferencesBackupResource.sys.mjs b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs new file mode 100644 index 0000000000..012c0bf91e --- /dev/null +++ b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs @@ -0,0 +1,98 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { BackupResource } from "resource:///modules/backup/BackupResource.sys.mjs"; +import { Sqlite } from "resource://gre/modules/Sqlite.sys.mjs"; + +/** + * Class representing files that modify preferences and permissions within a user profile. + */ +export class PreferencesBackupResource extends BackupResource { + static get key() { + return "preferences"; + } + + static get requiresEncryption() { + return false; + } + + async backup(stagingPath, profilePath = PathUtils.profileDir) { + // These are files that can be simply copied into the staging folder using + // IOUtils.copy. + const simpleCopyFiles = [ + "xulstore.json", + "containers.json", + "handlers.json", + "search.json.mozlz4", + "user.js", + "chrome", + ]; + + for (let fileName of simpleCopyFiles) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + if (await IOUtils.exists(sourcePath)) { + await IOUtils.copy(sourcePath, destPath, { recursive: true }); + } + } + + const sqliteDatabases = ["permissions.sqlite", "content-prefs.sqlite"]; + + for (let fileName of sqliteDatabases) { + let sourcePath = PathUtils.join(profilePath, fileName); + let destPath = PathUtils.join(stagingPath, fileName); + let connection; + + try { + connection = await Sqlite.openConnection({ + path: sourcePath, + }); + + await connection.backup(destPath); + } finally { + await connection.close(); + } + } + + // prefs.js is a special case - we have a helper function to flush the + // current prefs state to disk off of the main thread. + let prefsDestPath = PathUtils.join(stagingPath, "prefs.js"); + let prefsDestFile = await IOUtils.getFile(prefsDestPath); + await Services.prefs.backupPrefFile(prefsDestFile); + + return null; + } + + async measure(profilePath = PathUtils.profileDir) { + const files = [ + "prefs.js", + "xulstore.json", + "permissions.sqlite", + "content-prefs.sqlite", + "containers.json", + "handlers.json", + "search.json.mozlz4", + "user.js", + ]; + let fullSize = 0; + + for (let filePath of files) { + let resourcePath = PathUtils.join(profilePath, filePath); + let resourceSize = await BackupResource.getFileSize(resourcePath); + if (Number.isInteger(resourceSize)) { + fullSize += resourceSize; + } + } + + const chromeDirectoryPath = PathUtils.join(profilePath, "chrome"); + let chromeDirectorySize = await BackupResource.getDirectorySize( + chromeDirectoryPath + ); + if (Number.isInteger(chromeDirectorySize)) { + fullSize += chromeDirectorySize; + } + + Glean.browserBackup.preferencesSize.set(fullSize); + } +} diff --git a/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs new file mode 100644 index 0000000000..fa5dcca848 --- /dev/null +++ b/browser/components/backup/resources/SessionStoreBackupResource.sys.mjs @@ -0,0 +1,53 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +import { + BackupResource, + bytesToFuzzyKilobytes, +} from "resource:///modules/backup/BackupResource.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + * Class representing Session store related files within a user profile. + */ +export class SessionStoreBackupResource extends BackupResource { + static get key() { + return "sessionstore"; + } + + static get requiresEncryption() { + // Session store data does not require encryption, but if encryption is + // disabled, then session cookies will be cleared from the backup before + // writing it to the disk. + return false; + } + + async measure(profilePath = PathUtils.profileDir) { + // Get the current state of the session store JSON and + // measure it's uncompressed size. + let sessionStoreJson = lazy.SessionStore.getCurrentState(true); + let sessionStoreSize = new TextEncoder().encode( + JSON.stringify(sessionStoreJson) + ).byteLength; + let sessionStoreNearestTenthKb = bytesToFuzzyKilobytes(sessionStoreSize); + + Glean.browserBackup.sessionStoreSize.set(sessionStoreNearestTenthKb); + + let sessionStoreBackupsDirectoryPath = PathUtils.join( + profilePath, + "sessionstore-backups" + ); + let sessionStoreBackupsDirectorySize = + await BackupResource.getDirectorySize(sessionStoreBackupsDirectoryPath); + + Glean.browserBackup.sessionStoreBackupsDirectorySize.set( + sessionStoreBackupsDirectorySize + ); + } +} diff --git a/browser/components/backup/tests/xpcshell/head.js b/browser/components/backup/tests/xpcshell/head.js new file mode 100644 index 0000000000..2402870a13 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/head.js @@ -0,0 +1,167 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const { BackupService } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupService.sys.mjs" +); + +const { BackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/BackupResource.sys.mjs" +); + +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { Sqlite } = ChromeUtils.importESModule( + "resource://gre/modules/Sqlite.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const BYTES_IN_KB = 1000; + +do_get_profile(); + +/** + * Some fake backup resource classes to test with. + */ +class FakeBackupResource1 extends BackupResource { + static get key() { + return "fake1"; + } + static get requiresEncryption() { + return false; + } +} + +/** + * Another fake backup resource class to test with. + */ +class FakeBackupResource2 extends BackupResource { + static get key() { + return "fake2"; + } + static get requiresEncryption() { + return true; + } +} + +/** + * Yet another fake backup resource class to test with. + */ +class FakeBackupResource3 extends BackupResource { + static get key() { + return "fake3"; + } + static get requiresEncryption() { + return false; + } +} + +/** + * Create a file of a given size in kilobytes. + * + * @param {string} path the path where the file will be created. + * @param {number} sizeInKB size file in Kilobytes. + * @returns {Promise<undefined>} + */ +async function createKilobyteSizedFile(path, sizeInKB) { + let bytes = new Uint8Array(sizeInKB * BYTES_IN_KB); + await IOUtils.write(path, bytes); +} + +/** + * @typedef {object} TestFileObject + * @property {(string|Array.<string>)} path + * The relative path of the file. It can be a string or an array of strings + * in the event that directories need to be created. For example, this is + * an array of valid TestFileObjects. + * + * [ + * { path: "file1.txt" }, + * { path: ["dir1", "file2.txt"] }, + * { path: ["dir2", "dir3", "file3.txt"], sizeInKB: 25 }, + * { path: "file4.txt" }, + * ] + * + * @property {number} [sizeInKB=10] + * The size of the created file in kilobytes. Defaults to 10. + */ + +/** + * Easily creates a series of test files and directories under parentPath. + * + * @param {string} parentPath + * The path to the parent directory where the files will be created. + * @param {TestFileObject[]} testFilesArray + * An array of TestFileObjects describing what test files to create within + * the parentPath. + * @see TestFileObject + * @returns {Promise<undefined>} + */ +async function createTestFiles(parentPath, testFilesArray) { + for (let { path, sizeInKB } of testFilesArray) { + if (Array.isArray(path)) { + // Make a copy of the array of path elements, chopping off the last one. + // We'll assume the unchopped items are directories, and make sure they + // exist first. + let folders = path.slice(0, -1); + await IOUtils.getDirectory(PathUtils.join(parentPath, ...folders)); + } + + if (sizeInKB === undefined) { + sizeInKB = 10; + } + + // This little piece of cleverness coerces a string into an array of one + // if path is a string, or just leaves it alone if it's already an array. + let filePath = PathUtils.join(parentPath, ...[].concat(path)); + await createKilobyteSizedFile(filePath, sizeInKB); + } +} + +/** + * Checks that files exist within a particular folder. The filesize is not + * checked. + * + * @param {string} parentPath + * The path to the parent directory where the files should exist. + * @param {TestFileObject[]} testFilesArray + * An array of TestFileObjects describing what test files to search for within + * parentPath. + * @see TestFileObject + * @returns {Promise<undefined>} + */ +async function assertFilesExist(parentPath, testFilesArray) { + for (let { path } of testFilesArray) { + let copiedFileName = PathUtils.join(parentPath, ...[].concat(path)); + Assert.ok( + await IOUtils.exists(copiedFileName), + `${copiedFileName} should exist in the staging folder` + ); + } +} + +/** + * Remove a file or directory at a path if it exists and files are unlocked. + * + * @param {string} path path to remove. + */ +async function maybeRemovePath(path) { + try { + await IOUtils.remove(path, { ignoreAbsent: true, recursive: true }); + } catch (error) { + // Sometimes remove() throws when the file is not unlocked soon + // enough. + if (error.name != "NS_ERROR_FILE_IS_LOCKED") { + // Ignoring any errors, as the temp folder will be cleaned up. + console.error(error); + } + } +} diff --git a/browser/components/backup/tests/xpcshell/test_BrowserResource.js b/browser/components/backup/tests/xpcshell/test_BackupResource.js index 23c8e077a5..6623f4cd77 100644 --- a/browser/components/backup/tests/xpcshell/test_BrowserResource.js +++ b/browser/components/backup/tests/xpcshell/test_BackupResource.js @@ -3,16 +3,12 @@ http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; -const { BackupResource } = ChromeUtils.importESModule( +const { bytesToFuzzyKilobytes } = ChromeUtils.importESModule( "resource:///modules/backup/BackupResource.sys.mjs" ); const EXPECTED_KILOBYTES_FOR_XULSTORE = 1; -add_setup(() => { - do_get_profile(); -}); - /** * Tests that BackupService.getFileSize will get the size of a file in kilobytes. */ @@ -35,7 +31,7 @@ add_task(async function test_getFileSize() { }); /** - * Tests that BackupService.getFileSize will get the total size of all the files in a directory and it's children in kilobytes. + * Tests that BackupService.getDirectorySize will get the total size of all the files in a directory and it's children in kilobytes. */ add_task(async function test_getDirectorySize() { let file = do_get_file("data/test_xulstore.json"); @@ -61,3 +57,21 @@ add_task(async function test_getDirectorySize() { await IOUtils.remove(testDir, { recursive: true }); }); + +/** + * Tests that bytesToFuzzyKilobytes will convert bytes to kilobytes + * and round up to the nearest tenth kilobyte. + */ +add_task(async function test_bytesToFuzzyKilobytes() { + let largeSize = bytesToFuzzyKilobytes(1234000); + + Assert.equal( + largeSize, + 1230, + "1234 bytes is rounded up to the nearest tenth kilobyte, 1230" + ); + + let smallSize = bytesToFuzzyKilobytes(3); + + Assert.equal(smallSize, 1, "Sizes under 10 kilobytes return 1 kilobyte"); +}); diff --git a/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js new file mode 100644 index 0000000000..e57dd50cd3 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_MiscDataBackupResource.js @@ -0,0 +1,113 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { MiscDataBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/MiscDataBackupResource.sys.mjs" +); + +/** + * Tests that we can measure miscellaneous files in the profile directory. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_MISC_KILOBYTES_SIZE = 241; + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-measurement-test" + ); + + const mockFiles = [ + { path: "times.json", sizeInKB: 5 }, + { path: "enumerate_devices.txt", sizeInKB: 1 }, + { path: "protections.sqlite", sizeInKB: 100 }, + { path: "SiteSecurityServiceState.bin", sizeInKB: 10 }, + { path: ["storage", "permanent", "chrome", "123ABC.sqlite"], sizeInKB: 40 }, + { path: ["storage", "permanent", "chrome", "456DEF.sqlite"], sizeInKB: 40 }, + { + path: ["storage", "permanent", "chrome", "mockIDBDir", "890HIJ.sqlite"], + sizeInKB: 40, + }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let miscDataBackupResource = new MiscDataBackupResource(); + await miscDataBackupResource.measure(tempDir); + + let measurement = Glean.browserBackup.miscDataSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.misc_data_size", + measurement, + "Glean and telemetry measurements for misc data should be equal" + ); + Assert.equal( + measurement, + EXPECTED_MISC_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for misc files" + ); + + await maybeRemovePath(tempDir); +}); + +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let miscDataBackupResource = new MiscDataBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "MiscDataBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "times.json" }, + { path: "enumerate_devices.txt" }, + { path: "SiteSecurityServiceState.bin" }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + // We have no need to test that Sqlite.sys.mjs's backup method is working - + // this is something that is tested in Sqlite's own tests. We can just make + // sure that it's being called using sinon. Unfortunately, we cannot do the + // same thing with IOUtils.copy, as its methods are not stubbable. + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await miscDataBackupResource.backup(stagingPath, sourcePath); + + await assertFilesExist(stagingPath, simpleCopyFiles); + + // Next, we'll make sure that the Sqlite connection had `backup` called on it + // with the right arguments. + Assert.ok( + fakeConnection.backup.calledOnce, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "protections.sqlite") + ), + "Called backup on the protections.sqlite Sqlite connection" + ); + + // Bug 1890585 - we don't currently have the ability to copy the + // chrome-privileged IndexedDB databases under storage/permanent/chrome, so + // we'll just skip testing that for now. + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js new file mode 100644 index 0000000000..de97281372 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_PlacesBackupResource.js @@ -0,0 +1,226 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PlacesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/PlacesBackupResource.sys.mjs" +); +const { PrivateBrowsingUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PrivateBrowsingUtils.sys.mjs" +); + +const HISTORY_ENABLED_PREF = "places.history.enabled"; +const SANITIZE_ON_SHUTDOWN_PREF = "privacy.sanitize.sanitizeOnShutdown"; + +registerCleanupFunction(() => { + /** + * Even though test_backup_no_saved_history clears user prefs too, + * clear them here as well in case that test fails and we don't + * reach the end of the test, which handles the cleanup. + */ + Services.prefs.clearUserPref(HISTORY_ENABLED_PREF); + Services.prefs.clearUserPref(SANITIZE_ON_SHUTDOWN_PREF); +}); + +/** + * Tests that we can measure Places DB related files in the profile directory. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_PLACES_DB_SIZE = 5240; + const EXPECTED_FAVICONS_DB_SIZE = 5240; + + // Create resource files in temporary directory + const tempDir = PathUtils.tempDir; + let tempPlacesDBPath = PathUtils.join(tempDir, "places.sqlite"); + let tempFaviconsDBPath = PathUtils.join(tempDir, "favicons.sqlite"); + await createKilobyteSizedFile(tempPlacesDBPath, EXPECTED_PLACES_DB_SIZE); + await createKilobyteSizedFile(tempFaviconsDBPath, EXPECTED_FAVICONS_DB_SIZE); + + let placesBackupResource = new PlacesBackupResource(); + await placesBackupResource.measure(tempDir); + + let placesMeasurement = Glean.browserBackup.placesSize.testGetValue(); + let faviconsMeasurement = Glean.browserBackup.faviconsSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.places_size", + placesMeasurement, + "Glean and telemetry measurements for places.sqlite should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.favicons_size", + faviconsMeasurement, + "Glean and telemetry measurements for favicons.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + placesMeasurement, + EXPECTED_PLACES_DB_SIZE, + "Should have collected the correct glean measurement for places.sqlite" + ); + Assert.equal( + faviconsMeasurement, + EXPECTED_FAVICONS_DB_SIZE, + "Should have collected the correct glean measurement for favicons.sqlite" + ); + + await maybeRemovePath(tempPlacesDBPath); + await maybeRemovePath(tempFaviconsDBPath); +}); + +/** + * Tests that the backup method correctly copies places.sqlite and + * favicons.sqlite from the profile directory into the staging directory. + */ +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.calledTwice, + "Backup should have been called twice" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "places.sqlite") + ), + "places.sqlite should have been backed up first" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(stagingPath, "favicons.sqlite") + ), + "favicons.sqlite should have been backed up second" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); + +/** + * Tests that the backup method correctly creates a compressed bookmarks JSON file when users + * don't want history saved, even on shutdown. + */ +add_task(async function test_backup_no_saved_history() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + /** + * First verify that remember history pref alone affects backup file type for places, + * despite sanitize on shutdown pref value. + */ + Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, false); + Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, false); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with remember history disabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + await IOUtils.remove(PathUtils.join(stagingPath, "bookmarks.jsonlz4")); + + /** + * Now verify that the sanitize shutdown pref alone affects backup file type for places, + * even if the user is okay with remembering history while browsing. + */ + Services.prefs.setBoolPref(HISTORY_ENABLED_PREF, true); + Services.prefs.setBoolPref(SANITIZE_ON_SHUTDOWN_PREF, true); + + fakeConnection.backup.resetHistory(); + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with sanitize shutdown enabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); + Services.prefs.clearUserPref(HISTORY_ENABLED_PREF); + Services.prefs.clearUserPref(SANITIZE_ON_SHUTDOWN_PREF); +}); + +/** + * Tests that the backup method correctly creates a compressed bookmarks JSON file when + * permanent private browsing mode is enabled. + */ +add_task(async function test_backup_private_browsing() { + let sandbox = sinon.createSandbox(); + + let placesBackupResource = new PlacesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PlacesBackupResource-staging-test" + ); + + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + sandbox.stub(PrivateBrowsingUtils, "permanentPrivateBrowsing").value(true); + + await placesBackupResource.backup(stagingPath, sourcePath); + + Assert.ok( + fakeConnection.backup.notCalled, + "No sqlite connections should have been made with permanent private browsing enabled" + ); + await assertFilesExist(stagingPath, [{ path: "bookmarks.jsonlz4" }]); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js new file mode 100644 index 0000000000..6845431bb8 --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_PreferencesBackupResource.js @@ -0,0 +1,132 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PreferencesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/PreferencesBackupResource.sys.mjs" +); + +/** + * Test that the measure method correctly collects the disk-sizes of things that + * the PreferencesBackupResource is meant to back up. + */ +add_task(async function test_measure() { + Services.fog.testResetFOG(); + + const EXPECTED_PREFERENCES_KILOBYTES_SIZE = 415; + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-measure-test" + ); + const mockFiles = [ + { path: "prefs.js", sizeInKB: 20 }, + { path: "xulstore.json", sizeInKB: 1 }, + { path: "permissions.sqlite", sizeInKB: 100 }, + { path: "content-prefs.sqlite", sizeInKB: 260 }, + { path: "containers.json", sizeInKB: 1 }, + { path: "handlers.json", sizeInKB: 1 }, + { path: "search.json.mozlz4", sizeInKB: 1 }, + { path: "user.js", sizeInKB: 2 }, + { path: ["chrome", "userChrome.css"], sizeInKB: 5 }, + { path: ["chrome", "userContent.css"], sizeInKB: 5 }, + { path: ["chrome", "css", "mockStyles.css"], sizeInKB: 5 }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let preferencesBackupResource = new PreferencesBackupResource(); + + await preferencesBackupResource.measure(tempDir); + + let measurement = Glean.browserBackup.preferencesSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.preferences_size", + measurement, + "Glean and telemetry measurements for preferences data should be equal" + ); + Assert.equal( + measurement, + EXPECTED_PREFERENCES_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for preferences files" + ); + + await maybeRemovePath(tempDir); +}); + +/** + * Test that the backup method correctly copies items from the profile directory + * into the staging directory. + */ +add_task(async function test_backup() { + let sandbox = sinon.createSandbox(); + + let preferencesBackupResource = new PreferencesBackupResource(); + let sourcePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-source-test" + ); + let stagingPath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "PreferencesBackupResource-staging-test" + ); + + const simpleCopyFiles = [ + { path: "xulstore.json" }, + { path: "containers.json" }, + { path: "handlers.json" }, + { path: "search.json.mozlz4" }, + { path: "user.js" }, + { path: ["chrome", "userChrome.css"] }, + { path: ["chrome", "userContent.css"] }, + { path: ["chrome", "childFolder", "someOtherStylesheet.css"] }, + ]; + await createTestFiles(sourcePath, simpleCopyFiles); + + // We have no need to test that Sqlite.sys.mjs's backup method is working - + // this is something that is tested in Sqlite's own tests. We can just make + // sure that it's being called using sinon. Unfortunately, we cannot do the + // same thing with IOUtils.copy, as its methods are not stubbable. + let fakeConnection = { + backup: sandbox.stub().resolves(true), + close: sandbox.stub().resolves(true), + }; + sandbox.stub(Sqlite, "openConnection").returns(fakeConnection); + + await preferencesBackupResource.backup(stagingPath, sourcePath); + + await assertFilesExist(stagingPath, simpleCopyFiles); + + // Next, we'll make sure that the Sqlite connection had `backup` called on it + // with the right arguments. + Assert.ok( + fakeConnection.backup.calledTwice, + "Called backup the expected number of times for all connections" + ); + Assert.ok( + fakeConnection.backup.firstCall.calledWith( + PathUtils.join(stagingPath, "permissions.sqlite") + ), + "Called backup on the permissions.sqlite Sqlite connection" + ); + Assert.ok( + fakeConnection.backup.secondCall.calledWith( + PathUtils.join(stagingPath, "content-prefs.sqlite") + ), + "Called backup on the content-prefs.sqlite Sqlite connection" + ); + + // And we'll make sure that preferences were properly written out. + Assert.ok( + await IOUtils.exists(PathUtils.join(stagingPath, "prefs.js")), + "prefs.js should exist in the staging folder" + ); + + await maybeRemovePath(stagingPath); + await maybeRemovePath(sourcePath); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_createBackup.js b/browser/components/backup/tests/xpcshell/test_createBackup.js new file mode 100644 index 0000000000..fcace695ef --- /dev/null +++ b/browser/components/backup/tests/xpcshell/test_createBackup.js @@ -0,0 +1,74 @@ +/* Any copyright is dedicated to the Public Domain. +https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * Tests that calling BackupService.createBackup will call backup on each + * registered BackupResource, and that each BackupResource will have a folder + * created for them to write into. + */ +add_task(async function test_createBackup() { + let sandbox = sinon.createSandbox(); + sandbox + .stub(FakeBackupResource1.prototype, "backup") + .resolves({ fake1: "hello from 1" }); + sandbox + .stub(FakeBackupResource2.prototype, "backup") + .rejects(new Error("Some failure to backup")); + sandbox + .stub(FakeBackupResource3.prototype, "backup") + .resolves({ fake3: "hello from 3" }); + + let bs = new BackupService({ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + }); + + let fakeProfilePath = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "createBackupTest" + ); + + await bs.createBackup({ profilePath: fakeProfilePath }); + + // For now, we expect a staging folder to exist under the fakeProfilePath, + // and we should find a folder for each fake BackupResource. + let stagingPath = PathUtils.join(fakeProfilePath, "backups", "staging"); + Assert.ok(await IOUtils.exists(stagingPath), "Staging folder exists"); + + for (let backupResourceClass of [ + FakeBackupResource1, + FakeBackupResource2, + FakeBackupResource3, + ]) { + let expectedResourceFolder = PathUtils.join( + stagingPath, + backupResourceClass.key + ); + Assert.ok( + await IOUtils.exists(expectedResourceFolder), + `BackupResource staging folder exists for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledOnce, + `Backup was called for ${backupResourceClass.key}` + ); + Assert.ok( + backupResourceClass.prototype.backup.calledWith( + expectedResourceFolder, + fakeProfilePath + ), + `Backup was passed the right paths for ${backupResourceClass.key}` + ); + } + + // After createBackup is more fleshed out, we're going to want to make sure + // that we're writing the manifest file and that it contains the expected + // ManifestEntry objects, and that the staging folder was successfully + // renamed with the current date. + await IOUtils.remove(fakeProfilePath, { recursive: true }); + + sandbox.restore(); +}); diff --git a/browser/components/backup/tests/xpcshell/test_measurements.js b/browser/components/backup/tests/xpcshell/test_measurements.js index e5726126b2..0dece6b370 100644 --- a/browser/components/backup/tests/xpcshell/test_measurements.js +++ b/browser/components/backup/tests/xpcshell/test_measurements.js @@ -3,22 +3,59 @@ http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; -const { BackupService } = ChromeUtils.importESModule( - "resource:///modules/backup/BackupService.sys.mjs" +const { CredentialsAndSecurityBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CredentialsAndSecurityBackupResource.sys.mjs" +); +const { AddonsBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/AddonsBackupResource.sys.mjs" +); +const { CookiesBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/CookiesBackupResource.sys.mjs" +); + +const { FormHistoryBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/FormHistoryBackupResource.sys.mjs" ); -const { TelemetryTestUtils } = ChromeUtils.importESModule( - "resource://testing-common/TelemetryTestUtils.sys.mjs" +const { SessionStoreBackupResource } = ChromeUtils.importESModule( + "resource:///modules/backup/SessionStoreBackupResource.sys.mjs" ); add_setup(() => { - do_get_profile(); // FOG needs to be initialized in order for data to flow. Services.fog.initializeFOG(); Services.telemetry.clearScalars(); }); /** + * Tests that calling `BackupService.takeMeasurements` will call the measure + * method of all registered BackupResource classes. + */ +add_task(async function test_takeMeasurements() { + let sandbox = sinon.createSandbox(); + sandbox.stub(FakeBackupResource1.prototype, "measure").resolves(); + sandbox + .stub(FakeBackupResource2.prototype, "measure") + .rejects(new Error("Some failure to measure")); + + let bs = new BackupService({ FakeBackupResource1, FakeBackupResource2 }); + await bs.takeMeasurements(); + + for (let backupResourceClass of [FakeBackupResource1, FakeBackupResource2]) { + Assert.ok( + backupResourceClass.prototype.measure.calledOnce, + "Measure was called" + ); + Assert.ok( + backupResourceClass.prototype.measure.calledWith(PathUtils.profileDir), + "Measure was called with the profile directory argument" + ); + } + + sandbox.restore(); +}); + +/** * Tests that we can measure the disk space available in the profile directory. */ add_task(async function test_profDDiskSpace() { @@ -38,3 +75,503 @@ add_task(async function test_profDDiskSpace() { "device" ); }); + +/** + * Tests that we can measure credentials related files in the profile directory. + */ +add_task(async function test_credentialsAndSecurityBackupResource() { + Services.fog.testResetFOG(); + + const EXPECTED_CREDENTIALS_KILOBYTES_SIZE = 413; + const EXPECTED_SECURITY_KILOBYTES_SIZE = 231; + + // Create resource files in temporary directory + const tempDir = await IOUtils.createUniqueDirectory( + PathUtils.tempDir, + "CredentialsAndSecurityBackupResource-measurement-test" + ); + + const mockFiles = [ + // Set up credentials files + { path: "key4.db", sizeInKB: 300 }, + { path: "logins.json", sizeInKB: 1 }, + { path: "logins-backup.json", sizeInKB: 1 }, + { path: "autofill-profiles.json", sizeInKB: 1 }, + { path: "credentialstate.sqlite", sizeInKB: 100 }, + { path: "signedInUser.json", sizeInKB: 5 }, + // Set up security files + { path: "cert9.db", sizeInKB: 230 }, + { path: "pkcs11.txt", sizeInKB: 1 }, + ]; + + await createTestFiles(tempDir, mockFiles); + + let credentialsAndSecurityBackupResource = + new CredentialsAndSecurityBackupResource(); + await credentialsAndSecurityBackupResource.measure(tempDir); + + let credentialsMeasurement = + Glean.browserBackup.credentialsDataSize.testGetValue(); + let securityMeasurement = Glean.browserBackup.securityDataSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Credentials measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.credentials_data_size", + credentialsMeasurement, + "Glean and telemetry measurements for credentials data should be equal" + ); + + Assert.equal( + credentialsMeasurement, + EXPECTED_CREDENTIALS_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for credentials files" + ); + + // Security measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.security_data_size", + securityMeasurement, + "Glean and telemetry measurements for security data should be equal" + ); + Assert.equal( + securityMeasurement, + EXPECTED_SECURITY_KILOBYTES_SIZE, + "Should have collected the correct glean measurement for security files" + ); + + // Cleanup + await maybeRemovePath(tempDir); +}); + +/** + * Tests that we can measure the Cookies db in a profile directory. + */ +add_task(async function test_cookiesBackupResource() { + const EXPECTED_COOKIES_DB_SIZE = 1230; + + Services.fog.testResetFOG(); + + // Create resource files in temporary directory + let tempDir = PathUtils.tempDir; + let tempCookiesDBPath = PathUtils.join(tempDir, "cookies.sqlite"); + await createKilobyteSizedFile(tempCookiesDBPath, EXPECTED_COOKIES_DB_SIZE); + + let cookiesBackupResource = new CookiesBackupResource(); + await cookiesBackupResource.measure(tempDir); + + let cookiesMeasurement = Glean.browserBackup.cookiesSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.cookies_size", + cookiesMeasurement, + "Glean and telemetry measurements for cookies.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + cookiesMeasurement, + EXPECTED_COOKIES_DB_SIZE, + "Should have collected the correct glean measurement for cookies.sqlite" + ); + + await maybeRemovePath(tempCookiesDBPath); +}); + +/** + * Tests that we can measure the Form History db in a profile directory. + */ +add_task(async function test_formHistoryBackupResource() { + const EXPECTED_FORM_HISTORY_DB_SIZE = 500; + + Services.fog.testResetFOG(); + + // Create resource files in temporary directory + let tempDir = PathUtils.tempDir; + let tempFormHistoryDBPath = PathUtils.join(tempDir, "formhistory.sqlite"); + await createKilobyteSizedFile( + tempFormHistoryDBPath, + EXPECTED_FORM_HISTORY_DB_SIZE + ); + + let formHistoryBackupResource = new FormHistoryBackupResource(); + await formHistoryBackupResource.measure(tempDir); + + let formHistoryMeasurement = + Glean.browserBackup.formHistorySize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.form_history_size", + formHistoryMeasurement, + "Glean and telemetry measurements for formhistory.sqlite should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + formHistoryMeasurement, + EXPECTED_FORM_HISTORY_DB_SIZE, + "Should have collected the correct glean measurement for formhistory.sqlite" + ); + + await IOUtils.remove(tempFormHistoryDBPath); +}); + +/** + * Tests that we can measure the Session Store JSON and backups directory. + */ +add_task(async function test_sessionStoreBackupResource() { + const EXPECTED_KILOBYTES_FOR_BACKUPS_DIR = 1000; + Services.fog.testResetFOG(); + + // Create the sessionstore-backups directory. + let tempDir = PathUtils.tempDir; + let sessionStoreBackupsPath = PathUtils.join( + tempDir, + "sessionstore-backups", + "restore.jsonlz4" + ); + await createKilobyteSizedFile( + sessionStoreBackupsPath, + EXPECTED_KILOBYTES_FOR_BACKUPS_DIR + ); + + let sessionStoreBackupResource = new SessionStoreBackupResource(); + await sessionStoreBackupResource.measure(tempDir); + + let sessionStoreBackupsDirectoryMeasurement = + Glean.browserBackup.sessionStoreBackupsDirectorySize.testGetValue(); + let sessionStoreMeasurement = + Glean.browserBackup.sessionStoreSize.testGetValue(); + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + + // Compare glean vs telemetry measurements + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.session_store_backups_directory_size", + sessionStoreBackupsDirectoryMeasurement, + "Glean and telemetry measurements for session store backups directory should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.session_store_size", + sessionStoreMeasurement, + "Glean and telemetry measurements for session store should be equal" + ); + + // Compare glean measurements vs actual file sizes + Assert.equal( + sessionStoreBackupsDirectoryMeasurement, + EXPECTED_KILOBYTES_FOR_BACKUPS_DIR, + "Should have collected the correct glean measurement for the sessionstore-backups directory" + ); + + // Session store measurement is from `getCurrentState`, so exact size is unknown. + Assert.greater( + sessionStoreMeasurement, + 0, + "Should have collected a measurement for the session store" + ); + + await IOUtils.remove(sessionStoreBackupsPath); +}); + +/** + * Tests that we can measure the size of all the addons & extensions data. + */ +add_task(async function test_AddonsBackupResource() { + Services.fog.testResetFOG(); + Services.telemetry.clearScalars(); + + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON = 250; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE = 500; + const EXPECTED_KILOBYTES_FOR_STORAGE_SYNC = 50; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_A = 600; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_B = 400; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C = 150; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_DIRECTORY = 1000; + const EXPECTED_KILOBYTES_FOR_EXTENSION_DATA = 100; + const EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE = 200; + + let tempDir = PathUtils.tempDir; + + // Create extensions json files (all the same size). + const extensionsFilePath = PathUtils.join(tempDir, "extensions.json"); + await createKilobyteSizedFile( + extensionsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const extensionSettingsFilePath = PathUtils.join( + tempDir, + "extension-settings.json" + ); + await createKilobyteSizedFile( + extensionSettingsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const extensionsPrefsFilePath = PathUtils.join( + tempDir, + "extension-preferences.json" + ); + await createKilobyteSizedFile( + extensionsPrefsFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + const addonStartupFilePath = PathUtils.join(tempDir, "addonStartup.json.lz4"); + await createKilobyteSizedFile( + addonStartupFilePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON + ); + + // Create the extension store permissions data file. + let extensionStorePermissionsDataSize = PathUtils.join( + tempDir, + "extension-store-permissions", + "data.safe.bin" + ); + await createKilobyteSizedFile( + extensionStorePermissionsDataSize, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE + ); + + // Create the storage sync database file. + let storageSyncPath = PathUtils.join(tempDir, "storage-sync-v2.sqlite"); + await createKilobyteSizedFile( + storageSyncPath, + EXPECTED_KILOBYTES_FOR_STORAGE_SYNC + ); + + // Create the extensions directory with XPI files. + let extensionsXpiAPath = PathUtils.join( + tempDir, + "extensions", + "extension-b.xpi" + ); + let extensionsXpiBPath = PathUtils.join( + tempDir, + "extensions", + "extension-a.xpi" + ); + await createKilobyteSizedFile( + extensionsXpiAPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_A + ); + await createKilobyteSizedFile( + extensionsXpiBPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_B + ); + // Should be ignored. + let extensionsXpiStagedPath = PathUtils.join( + tempDir, + "extensions", + "staged", + "staged-test-extension.xpi" + ); + let extensionsXpiTrashPath = PathUtils.join( + tempDir, + "extensions", + "trash", + "trashed-test-extension.xpi" + ); + let extensionsXpiUnpackedPath = PathUtils.join( + tempDir, + "extensions", + "unpacked-extension.xpi", + "manifest.json" + ); + await createKilobyteSizedFile( + extensionsXpiStagedPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + await createKilobyteSizedFile( + extensionsXpiTrashPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + await createKilobyteSizedFile( + extensionsXpiUnpackedPath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_XPI_C + ); + + // Create the browser extension data directory. + let browserExtensionDataPath = PathUtils.join( + tempDir, + "browser-extension-data", + "test-file" + ); + await createKilobyteSizedFile( + browserExtensionDataPath, + EXPECTED_KILOBYTES_FOR_EXTENSION_DATA + ); + + // Create the extensions storage directory. + let extensionsStoragePath = PathUtils.join( + tempDir, + "storage", + "default", + "moz-extension+++test-extension-id", + "idb", + "data.sqlite" + ); + // Other storage files that should not be counted. + let otherStoragePath = PathUtils.join( + tempDir, + "storage", + "default", + "https+++accounts.firefox.com", + "ls", + "data.sqlite" + ); + + await createKilobyteSizedFile( + extensionsStoragePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE + ); + await createKilobyteSizedFile( + otherStoragePath, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE + ); + + // Measure all the extensions data. + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionsJsonSizeMeasurement = + Glean.browserBackup.extensionsJsonSize.testGetValue(); + Assert.equal( + extensionsJsonSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_JSON * 4, // There are 4 equally sized files. + "Should have collected the correct measurement of the total size of all extensions JSON files" + ); + + let extensionStorePermissionsDataSizeMeasurement = + Glean.browserBackup.extensionStorePermissionsDataSize.testGetValue(); + Assert.equal( + extensionStorePermissionsDataSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORE, + "Should have collected the correct measurement of the size of the extension store permissions data" + ); + + let storageSyncSizeMeasurement = + Glean.browserBackup.storageSyncSize.testGetValue(); + Assert.equal( + storageSyncSizeMeasurement, + EXPECTED_KILOBYTES_FOR_STORAGE_SYNC, + "Should have collected the correct measurement of the size of the storage sync database" + ); + + let extensionsXpiDirectorySizeMeasurement = + Glean.browserBackup.extensionsXpiDirectorySize.testGetValue(); + Assert.equal( + extensionsXpiDirectorySizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_DIRECTORY, + "Should have collected the correct measurement of the size 2 equally sized XPI files in the extensions directory" + ); + + let browserExtensionDataSizeMeasurement = + Glean.browserBackup.browserExtensionDataSize.testGetValue(); + Assert.equal( + browserExtensionDataSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSION_DATA, + "Should have collected the correct measurement of the size of the browser extension data directory" + ); + + let extensionsStorageSizeMeasurement = + Glean.browserBackup.extensionsStorageSize.testGetValue(); + Assert.equal( + extensionsStorageSizeMeasurement, + EXPECTED_KILOBYTES_FOR_EXTENSIONS_STORAGE, + "Should have collected the correct measurement of all the extensions storage" + ); + + // Compare glean vs telemetry measurements + let scalars = TelemetryTestUtils.getProcessScalars("parent", false, false); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_json_size", + extensionsJsonSizeMeasurement, + "Glean and telemetry measurements for extensions JSON should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extension_store_permissions_data_size", + extensionStorePermissionsDataSizeMeasurement, + "Glean and telemetry measurements for extension store permissions data should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.storage_sync_size", + storageSyncSizeMeasurement, + "Glean and telemetry measurements for storage sync database should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_xpi_directory_size", + extensionsXpiDirectorySizeMeasurement, + "Glean and telemetry measurements for extensions directory should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.browser_extension_data_size", + browserExtensionDataSizeMeasurement, + "Glean and telemetry measurements for browser extension data should be equal" + ); + TelemetryTestUtils.assertScalar( + scalars, + "browser.backup.extensions_storage_size", + extensionsStorageSizeMeasurement, + "Glean and telemetry measurements for extensions storage should be equal" + ); + + await maybeRemovePath(tempDir); +}); + +/** + * Tests that we can handle the extension store permissions data not existing. + */ +add_task( + async function test_AddonsBackupResource_no_extension_store_permissions_data() { + Services.fog.testResetFOG(); + + let tempDir = PathUtils.tempDir; + + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionStorePermissionsDataSizeMeasurement = + Glean.browserBackup.extensionStorePermissionsDataSize.testGetValue(); + Assert.equal( + extensionStorePermissionsDataSizeMeasurement, + null, + "Should NOT have collected a measurement for the missing data" + ); + } +); + +/** + * Tests that we can handle a profile with no moz-extension IndexedDB databases. + */ +add_task( + async function test_AddonsBackupResource_no_extension_storage_databases() { + Services.fog.testResetFOG(); + + let tempDir = PathUtils.tempDir; + + let extensionsBackupResource = new AddonsBackupResource(); + await extensionsBackupResource.measure(tempDir); + + let extensionsStorageSizeMeasurement = + Glean.browserBackup.extensionsStorageSize.testGetValue(); + Assert.equal( + extensionsStorageSizeMeasurement, + null, + "Should NOT have collected a measurement for the missing data" + ); + } +); diff --git a/browser/components/backup/tests/xpcshell/xpcshell.toml b/browser/components/backup/tests/xpcshell/xpcshell.toml index fb6dcd6846..07e517f1f2 100644 --- a/browser/components/backup/tests/xpcshell/xpcshell.toml +++ b/browser/components/backup/tests/xpcshell/xpcshell.toml @@ -1,8 +1,20 @@ [DEFAULT] +head = "head.js" firefox-appdir = "browser" skip-if = ["os == 'android'"] +prefs = [ + "browser.backup.log=true", +] -["test_BrowserResource.js"] +["test_BackupResource.js"] support-files = ["data/test_xulstore.json"] +["test_MiscDataBackupResource.js"] + +["test_PlacesBackupResource.js"] + +["test_PreferencesBackupResource.js"] + +["test_createBackup.js"] + ["test_measurements.js"] |