summaryrefslogtreecommitdiffstats
path: root/browser/components/backup/BackupService.sys.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'browser/components/backup/BackupService.sys.mjs')
-rw-r--r--browser/components/backup/BackupService.sys.mjs583
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>}