diff options
Diffstat (limited to 'browser/components/backup/BackupService.sys.mjs')
-rw-r--r-- | browser/components/backup/BackupService.sys.mjs | 583 |
1 files changed, 573 insertions, 10 deletions
diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs index 3521f315fd..05634ed2c8 100644 --- a/browser/components/backup/BackupService.sys.mjs +++ b/browser/components/backup/BackupService.sys.mjs @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as DefaultBackupResources from "resource:///modules/backup/BackupResources.sys.mjs"; +import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; @@ -15,12 +16,25 @@ ChromeUtils.defineLazyGetter(lazy, "logConsole", function () { }); }); +ChromeUtils.defineLazyGetter(lazy, "fxAccounts", () => { + return ChromeUtils.importESModule( + "resource://gre/modules/FxAccounts.sys.mjs" + ).getFxAccountsSingleton(); +}); + +ChromeUtils.defineESModuleGetters(lazy, { + ClientID: "resource://gre/modules/ClientID.sys.mjs", + JsonSchemaValidator: + "resource://gre/modules/components-utils/JsonSchemaValidator.sys.mjs", + UIState: "resource://services-sync/UIState.sys.mjs", +}); + /** * The BackupService class orchestrates the scheduling and creation of profile * backups. It also does most of the heavy lifting for the restoration of a * profile backup. */ -export class BackupService { +export class BackupService extends EventTarget { /** * The BackupService singleton instance. * @@ -37,11 +51,138 @@ export class BackupService { #resources = new Map(); /** + * Set to true if a backup is currently in progress. Causes stateUpdate() + * to be called. + * + * @see BackupService.stateUpdate() + * @param {boolean} val + * True if a backup is in progress. + */ + set #backupInProgress(val) { + if (this.#_state.backupInProgress != val) { + this.#_state.backupInProgress = val; + this.stateUpdate(); + } + } + + /** * True if a backup is currently in progress. * * @type {boolean} */ - #backupInProgress = false; + get #backupInProgress() { + return this.#_state.backupInProgress; + } + + /** + * Dispatches an event to let listeners know that the BackupService state + * object has been updated. + */ + stateUpdate() { + this.dispatchEvent(new CustomEvent("BackupService:StateUpdate")); + } + + /** + * An object holding the current state of the BackupService instance, for + * the purposes of representing it in the user interface. Ideally, this would + * be named #state instead of #_state, but sphinx-js seems to be fairly + * unhappy with that coupled with the ``state`` getter. + * + * @type {object} + */ + #_state = { backupInProgress: false }; + + /** + * A Promise that will resolve once the postRecovery steps are done. It will + * also resolve if postRecovery steps didn't need to run. + * + * @see BackupService.checkForPostRecovery() + * @type {Promise<undefined>} + */ + #postRecoveryPromise; + + /** + * The resolving function for #postRecoveryPromise, which should be called + * by checkForPostRecovery() before exiting. + * + * @type {Function} + */ + #postRecoveryResolver; + + /** + * The name of the backup manifest file. + * + * @type {string} + */ + static get MANIFEST_FILE_NAME() { + return "backup-manifest.json"; + } + + /** + * The current schema version of the backup manifest that this BackupService + * uses when creating a backup. + * + * @type {number} + */ + static get MANIFEST_SCHEMA_VERSION() { + return 1; + } + + /** + * A promise that resolves to the schema for the backup manifest that this + * BackupService uses when creating a backup. This should be accessed via + * the `MANIFEST_SCHEMA` static getter. + * + * @type {Promise<object>} + */ + static #manifestSchemaPromise = null; + + /** + * The current schema version of the backup manifest that this BackupService + * uses when creating a backup. + * + * @type {Promise<object>} + */ + static get MANIFEST_SCHEMA() { + if (!BackupService.#manifestSchemaPromise) { + BackupService.#manifestSchemaPromise = BackupService._getSchemaForVersion( + BackupService.MANIFEST_SCHEMA_VERSION + ); + } + + return BackupService.#manifestSchemaPromise; + } + + /** + * The name of the post recovery file written into the newly created profile + * directory just after a profile is recovered from a backup. + * + * @type {string} + */ + static get POST_RECOVERY_FILE_NAME() { + return "post-recovery.json"; + } + + /** + * Returns the schema for the backup manifest for a given version. + * + * This should really be #getSchemaForVersion, but for some reason, + * sphinx-js seems to choke on static async private methods (bug 1893362). + * We workaround this breakage by using the `_` prefix to indicate that this + * method should be _considered_ private, and ask that you not use this method + * outside of this class. The sphinx-js issue is tracked at + * https://github.com/mozilla/sphinx-js/issues/240. + * + * @private + * @param {number} version + * The version of the schema to return. + * @returns {Promise<object>} + */ + static async _getSchemaForVersion(version) { + let schemaURL = `chrome://browser/content/backup/BackupManifest.${version}.schema.json`; + let response = await fetch(schemaURL); + return response.json(); + } /** * Returns a reference to a BackupService singleton. If this is the first time @@ -56,7 +197,10 @@ export class BackupService { return this.#instance; } this.#instance = new BackupService(DefaultBackupResources); - this.#instance.takeMeasurements(); + + this.#instance.checkForPostRecovery().then(() => { + this.#instance.takeMeasurements(); + }); return this.#instance; } @@ -81,15 +225,49 @@ export class BackupService { * @param {object} [backupResources=DefaultBackupResources] - Object containing BackupResource classes to associate with this service. */ constructor(backupResources = DefaultBackupResources) { + super(); lazy.logConsole.debug("Instantiated"); for (const resourceName in backupResources) { let resource = backupResources[resourceName]; this.#resources.set(resource.key, resource); } + + let { promise, resolve } = Promise.withResolvers(); + this.#postRecoveryPromise = promise; + this.#postRecoveryResolver = resolve; + } + + /** + * Returns a reference to a Promise that will resolve with undefined once + * postRecovery steps have had a chance to run. This will also be resolved + * with undefined if no postRecovery steps needed to be run. + * + * @see BackupService.checkForPostRecovery() + * @returns {Promise<undefined>} + */ + get postRecoveryComplete() { + return this.#postRecoveryPromise; } /** + * Returns a state object describing the state of the BackupService for the + * purposes of representing it in the user interface. The returned state + * object is immutable. + * + * @type {object} + */ + get state() { + return Object.freeze(structuredClone(this.#_state)); + } + + /** + * @typedef {object} CreateBackupResult + * @property {string} stagingPath + * The staging path for where the backup was created. + */ + + /** * Create a backup of the user's profile. * * @param {object} [options] @@ -97,19 +275,22 @@ export class BackupService { * @param {string} [options.profilePath=PathUtils.profileDir] * The path to the profile to backup. By default, this is the current * profile. - * @returns {Promise<undefined>} + * @returns {Promise<CreateBackupResult|null>} + * A promise that resolves to an object containing the path to the staging + * folder where the backup was created, or null if the backup failed. */ 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; + return null; } this.#backupInProgress = true; try { lazy.logConsole.debug(`Creating backup for profile at ${profilePath}`); + let manifest = await this.#createBackupManifest(); // First, check to see if a `backups` directory already exists in the // profile. @@ -122,8 +303,15 @@ export class BackupService { let stagingPath = await this.#prepareStagingFolder(backupDirPath); + // Sort resources be priority. + let sortedResources = Array.from(this.#resources.values()).sort( + (a, b) => { + return b.priority - a.priority; + } + ); + // Perform the backup for each resource. - for (let resourceClass of this.#resources.values()) { + for (let resourceClass of sortedResources) { try { lazy.logConsole.debug( `Backing up resource with key ${resourceClass.key}. ` + @@ -139,10 +327,19 @@ export class BackupService { resourcePath, profilePath ); - lazy.logConsole.debug( - `Backup of resource with key ${resourceClass.key} completed`, - manifestEntry - ); + + if (manifestEntry === undefined) { + lazy.logConsole.error( + `Backup of resource with key ${resourceClass.key} returned undefined + as its ManifestEntry instead of null or an object` + ); + } else { + lazy.logConsole.debug( + `Backup of resource with key ${resourceClass.key} completed`, + manifestEntry + ); + manifest.resources[resourceClass.key] = manifestEntry; + } } catch (e) { lazy.logConsole.error( `Failed to backup resource: ${resourceClass.key}`, @@ -150,6 +347,42 @@ export class BackupService { ); } } + + // Ensure that the manifest abides by the current schema, and log + // an error if somehow it doesn't. We'll want to collect telemetry for + // this case to make sure it's not happening in the wild. We debated + // throwing an exception here too, but that's not meaningfully better + // than creating a backup that's not schema-compliant. At least in this + // case, a user so-inclined could theoretically repair the manifest + // to make it valid. + let manifestSchema = await BackupService.MANIFEST_SCHEMA; + let schemaValidationResult = lazy.JsonSchemaValidator.validate( + manifest, + manifestSchema + ); + if (!schemaValidationResult.valid) { + lazy.logConsole.error( + "Backup manifest does not conform to schema:", + manifest, + manifestSchema, + schemaValidationResult + ); + // TODO: Collect telemetry for this case. (bug 1891817) + } + + // Write the manifest to the staging folder. + let manifestPath = PathUtils.join( + stagingPath, + BackupService.MANIFEST_FILE_NAME + ); + await IOUtils.writeJSON(manifestPath, manifest); + + let renamedStagingPath = await this.#finalizeStagingFolder(stagingPath); + lazy.logConsole.log( + "Wrote backup to staging directory at ", + renamedStagingPath + ); + return { stagingPath: renamedStagingPath }; } finally { this.#backupInProgress = false; } @@ -179,6 +412,336 @@ export class BackupService { } /** + * Renames the staging folder to an ISO 8601 date string with dashes replacing colons and fractional seconds stripped off. + * The ISO date string should be formatted from YYYY-MM-DDTHH:mm:ss.sssZ to YYYY-MM-DDTHH-mm-ssZ + * + * @param {string} stagingPath + * The path to the populated staging folder. + * @returns {Promise<string|null>} + * The path to the renamed staging folder, or null if the stagingPath was + * not pointing to a valid folder. + */ + async #finalizeStagingFolder(stagingPath) { + if (!(await IOUtils.exists(stagingPath))) { + // If we somehow can't find the specified staging folder, cancel this step. + lazy.logConsole.error( + `Failed to finalize staging folder. Cannot find ${stagingPath}.` + ); + return null; + } + + try { + lazy.logConsole.debug("Finalizing and renaming staging folder"); + let currentDateISO = new Date().toISOString(); + // First strip the fractional seconds + let dateISOStripped = currentDateISO.replace(/\.\d+\Z$/, "Z"); + // Now replace all colons with dashes + let dateISOFormatted = dateISOStripped.replaceAll(":", "-"); + + let stagingPathParent = PathUtils.parent(stagingPath); + let renamedBackupPath = PathUtils.join( + stagingPathParent, + dateISOFormatted + ); + await IOUtils.move(stagingPath, renamedBackupPath); + + let existingBackups = await IOUtils.getChildren(stagingPathParent); + + /** + * Bug 1892532: for now, we only support a single backup file. + * If there are other pre-existing backup folders, delete them. + */ + for (let existingBackupPath of existingBackups) { + if (existingBackupPath !== renamedBackupPath) { + await IOUtils.remove(existingBackupPath, { + recursive: true, + }); + } + } + return renamedBackupPath; + } catch (e) { + lazy.logConsole.error( + `Something went wrong while finalizing the staging folder. ${e}` + ); + throw e; + } + } + + /** + * Creates and resolves with a backup manifest object with an empty resources + * property. + * + * @returns {Promise<object>} + */ + async #createBackupManifest() { + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let profileName; + if (!profileSvc.currentProfile) { + // We're probably running on a local build or in some special configuration. + // Let's pull in a profile name from the profile directory. + let profileFolder = PathUtils.split(PathUtils.profileDir).at(-1); + profileName = profileFolder.substring(profileFolder.indexOf(".") + 1); + } else { + profileName = profileSvc.currentProfile.name; + } + + let meta = { + date: new Date().toISOString(), + appName: AppConstants.MOZ_APP_NAME, + appVersion: AppConstants.MOZ_APP_VERSION, + buildID: AppConstants.MOZ_BUILDID, + profileName, + machineName: lazy.fxAccounts.device.getLocalName(), + osName: Services.sysinfo.getProperty("name"), + osVersion: Services.sysinfo.getProperty("version"), + legacyClientID: await lazy.ClientID.getClientID(), + }; + + let fxaState = lazy.UIState.get(); + if (fxaState.status == lazy.UIState.STATUS_SIGNED_IN) { + meta.accountID = fxaState.uid; + meta.accountEmail = fxaState.email; + } + + return { + version: BackupService.MANIFEST_SCHEMA_VERSION, + meta, + resources: {}, + }; + } + + /** + * Given a decompressed backup archive at recoveryPath, this method does the + * following: + * + * 1. Reads in the backup manifest from the archive and ensures that it is + * valid. + * 2. Creates a new named profile directory using the same name as the one + * found in the backup manifest, but with a different prefix. + * 3. Iterates over each resource in the manifest and calls the recover() + * method on each found BackupResource, passing in the associated + * ManifestEntry from the backup manifest, and collects any post-recovery + * data from those resources. + * 4. Writes a `post-recovery.json` file into the newly created profile + * directory. + * 5. Returns the name of the newly created profile directory. + * + * @param {string} recoveryPath + * The path to the decompressed backup archive on the file system. + * @param {boolean} [shouldLaunch=false] + * An optional argument that specifies whether an instance of the app + * should be launched with the newly recovered profile after recovery is + * complete. + * @param {string} [profileRootPath=null] + * An optional argument that specifies the root directory where the new + * profile directory should be created. If not provided, the default + * profile root directory will be used. This is primarily meant for + * testing. + * @returns {Promise<nsIToolkitProfile>} + * The nsIToolkitProfile that was created for the recovered profile. + * @throws {Exception} + * In the event that recovery somehow failed. + */ + async recoverFromBackup( + recoveryPath, + shouldLaunch = false, + profileRootPath = null + ) { + lazy.logConsole.debug("Recovering from backup at ", recoveryPath); + + try { + // Read in the backup manifest. + let manifestPath = PathUtils.join( + recoveryPath, + BackupService.MANIFEST_FILE_NAME + ); + let manifest = await IOUtils.readJSON(manifestPath); + if (!manifest.version) { + throw new Error("Backup manifest version not found"); + } + + if (manifest.version > BackupService.MANIFEST_SCHEMA_VERSION) { + throw new Error( + "Cannot recover from a manifest newer than the current schema version" + ); + } + + // Make sure that it conforms to the schema. + let manifestSchema = await BackupService._getSchemaForVersion( + manifest.version + ); + let schemaValidationResult = lazy.JsonSchemaValidator.validate( + manifest, + manifestSchema + ); + if (!schemaValidationResult.valid) { + lazy.logConsole.error( + "Backup manifest does not conform to schema:", + manifest, + manifestSchema, + schemaValidationResult + ); + // TODO: Collect telemetry for this case. (bug 1891817) + throw new Error("Cannot recover from an invalid backup manifest"); + } + + // In the future, if we ever bump the MANIFEST_SCHEMA_VERSION and need to + // do any special behaviours to interpret older schemas, this is where we + // can do that, and we can remove this comment. + + let meta = manifest.meta; + + // Okay, we have a valid backup-manifest.json. Let's create a new profile + // and start invoking the recover() method on each BackupResource. + let profileSvc = Cc["@mozilla.org/toolkit/profile-service;1"].getService( + Ci.nsIToolkitProfileService + ); + let profile = profileSvc.createUniqueProfile( + profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null, + meta.profileName + ); + + let postRecovery = {}; + + // Iterate over each resource in the manifest and call recover() on each + // associated BackupResource. + for (let resourceKey in manifest.resources) { + let manifestEntry = manifest.resources[resourceKey]; + let resourceClass = this.#resources.get(resourceKey); + if (!resourceClass) { + lazy.logConsole.error( + `No BackupResource found for key ${resourceKey}` + ); + continue; + } + + try { + lazy.logConsole.debug( + `Restoring resource with key ${resourceKey}. ` + + `Requires encryption: ${resourceClass.requiresEncryption}` + ); + let resourcePath = PathUtils.join(recoveryPath, resourceKey); + let postRecoveryEntry = await new resourceClass().recover( + manifestEntry, + resourcePath, + profile.rootDir.path + ); + postRecovery[resourceKey] = postRecoveryEntry; + } catch (e) { + lazy.logConsole.error( + `Failed to recover resource: ${resourceKey}`, + e + ); + } + } + + // Make sure that a legacy telemetry client ID exists and is written to + // disk. + let clientID = await lazy.ClientID.getClientID(); + lazy.logConsole.debug("Current client ID: ", clientID); + // Next, copy over the legacy telemetry client ID state from the currently + // running profile. The newly created profile that we're recovering into + // should inherit this client ID. + const TELEMETRY_STATE_FILENAME = "state.json"; + const TELEMETRY_STATE_FOLDER = "datareporting"; + await IOUtils.makeDirectory( + PathUtils.join(profile.rootDir.path, TELEMETRY_STATE_FOLDER) + ); + await IOUtils.copy( + /* source */ + PathUtils.join( + PathUtils.profileDir, + TELEMETRY_STATE_FOLDER, + TELEMETRY_STATE_FILENAME + ), + /* destination */ + PathUtils.join( + profile.rootDir.path, + TELEMETRY_STATE_FOLDER, + TELEMETRY_STATE_FILENAME + ) + ); + + let postRecoveryPath = PathUtils.join( + profile.rootDir.path, + BackupService.POST_RECOVERY_FILE_NAME + ); + await IOUtils.writeJSON(postRecoveryPath, postRecovery); + + profileSvc.flush(); + + if (shouldLaunch) { + Services.startup.createInstanceWithProfile(profile); + } + + return profile; + } catch (e) { + lazy.logConsole.error( + "Failed to recover from backup at ", + recoveryPath, + e + ); + throw e; + } + } + + /** + * Checks for the POST_RECOVERY_FILE_NAME in the current profile directory. + * If one exists, instantiates any relevant BackupResource's, and calls + * postRecovery() on them with the appropriate entry from the file. Once + * this is done, deletes the file. + * + * The file is deleted even if one of the postRecovery() steps rejects or + * fails. + * + * This function resolves silently if the POST_RECOVERY_FILE_NAME file does + * not exist, which should be the majority of cases. + * + * @param {string} [profilePath=PathUtils.profileDir] + * The profile path to look for the POST_RECOVERY_FILE_NAME file. Defaults + * to the current profile. + * @returns {Promise<undefined>} + */ + async checkForPostRecovery(profilePath = PathUtils.profileDir) { + lazy.logConsole.debug(`Checking for post-recovery file in ${profilePath}`); + let postRecoveryFile = PathUtils.join( + profilePath, + BackupService.POST_RECOVERY_FILE_NAME + ); + + if (!(await IOUtils.exists(postRecoveryFile))) { + lazy.logConsole.debug("Did not find post-recovery file."); + this.#postRecoveryResolver(); + return; + } + + lazy.logConsole.debug("Found post-recovery file. Loading..."); + + try { + let postRecovery = await IOUtils.readJSON(postRecoveryFile); + for (let resourceKey in postRecovery) { + let postRecoveryEntry = postRecovery[resourceKey]; + let resourceClass = this.#resources.get(resourceKey); + if (!resourceClass) { + lazy.logConsole.error( + `Invalid resource for post-recovery step: ${resourceKey}` + ); + continue; + } + + lazy.logConsole.debug(`Running post-recovery step for ${resourceKey}`); + await new resourceClass().postRecovery(postRecoveryEntry); + lazy.logConsole.debug(`Done post-recovery step for ${resourceKey}`); + } + } finally { + await IOUtils.remove(postRecoveryFile, { ignoreAbsent: true }); + this.#postRecoveryResolver(); + } + } + + /** * Take measurements of the current profile state for Telemetry. * * @returns {Promise<undefined>} |