commit b0d160151376e0a06de10d701ef01d17ea0bca87
parent 0bbe44dca1adbb9348458d1bb0fce2c4afdca5e6
Author: niklasbaumgardner <niklasbaumgardner@gmail.com>
Date: Thu, 23 Oct 2025 18:17:35 +0000
Bug 1992203 - Refactor BackupService so SelectableProfiles can be copied. r=profiles-reviewers,kpatenio,jhirsch,fluent-reviewers,bolsson
Differential Revision: https://phabricator.services.mozilla.com/D267812
Diffstat:
10 files changed, 714 insertions(+), 284 deletions(-)
diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs
@@ -75,6 +75,8 @@ ChromeUtils.defineESModuleGetters(lazy, {
JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ SelectableProfileService:
+ "resource:///modules/profiles/SelectableProfileService.sys.mjs",
UIState: "resource://services-sync/UIState.sys.mjs",
});
@@ -1271,6 +1273,172 @@ export class BackupService extends EventTarget {
}
/**
+ * Creates a backup for a given profile into a staging foler.
+ *
+ * @param {string} profilePath The path to the profile to backup.
+ * @returns {Promsie<object>} An object containing the results of this function.
+ * @property {STEPS} currentStep The current step of the backup process.
+ * @property {string} backupDirPath The path to the folder containing backups.
+ * Only included if this function completed successfully.
+ * @property {string} stagingPath The path to the staging folder.
+ * Only included if this function completed successfully.
+ * @property {object} manifest An object containing meta data for the backup.
+ * See the BackupManifest schema for the specific shape of the returned
+ * manifest object.
+ * @property {Error} error An error. Only included if an error was thrown.
+ */
+ async createAndPopulateStagingFolder(profilePath) {
+ let currentStep, backupDirPath, renamedStagingPath, manifest;
+ try {
+ currentStep = STEPS.CREATE_BACKUP_CREATE_MANIFEST;
+ manifest = await this.#createBackupManifest();
+
+ currentStep = STEPS.CREATE_BACKUP_CREATE_BACKUPS_FOLDER;
+ // First, check to see if a `backups` directory already exists in the
+ // profile.
+ backupDirPath = PathUtils.join(
+ profilePath,
+ BackupService.PROFILE_FOLDER_NAME,
+ BackupService.SNAPSHOTS_FOLDER_NAME
+ );
+ 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,
+ createAncestors: true,
+ });
+
+ currentStep = STEPS.CREATE_BACKUP_CREATE_STAGING_FOLDER;
+ 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;
+ }
+ );
+
+ currentStep = STEPS.CREATE_BACKUP_LOAD_ENCSTATE;
+ let encState = await this.loadEncryptionState(profilePath);
+ let encryptionEnabled = !!encState;
+ lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
+
+ currentStep = STEPS.CREATE_BACKUP_RUN_BACKUP;
+ // Perform the backup for each resource.
+ for (let resourceClass of sortedResources) {
+ try {
+ lazy.logConsole.debug(
+ `Backing up resource with key ${resourceClass.key}. ` +
+ `Requires encryption: ${resourceClass.requiresEncryption}`
+ );
+
+ if (resourceClass.requiresEncryption && !encryptionEnabled) {
+ lazy.logConsole.debug(
+ "Encryption is not currently enabled. Skipping."
+ );
+ continue;
+ }
+
+ 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,
+ encryptionEnabled
+ );
+
+ 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}`,
+ e
+ );
+ }
+ }
+
+ currentStep = STEPS.CREATE_BACKUP_VERIFY_MANIFEST;
+ // 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.JsonSchema.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)
+ }
+
+ currentStep = STEPS.CREATE_BACKUP_WRITE_MANIFEST;
+ // Write the manifest to the staging folder.
+ let manifestPath = PathUtils.join(
+ stagingPath,
+ BackupService.MANIFEST_FILE_NAME
+ );
+ await IOUtils.writeJSON(manifestPath, manifest);
+
+ currentStep = STEPS.CREATE_BACKUP_FINALIZE_STAGING;
+ renamedStagingPath = await this.#finalizeStagingFolder(stagingPath);
+ lazy.logConsole.log(
+ "Wrote backup to staging directory at ",
+ renamedStagingPath
+ );
+
+ // Record the total size of the backup staging directory
+ let totalSizeKilobytes =
+ await BackupResource.getDirectorySize(renamedStagingPath);
+ let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
+ totalSizeKilobytes * BYTES_IN_KILOBYTE,
+ 1 * BYTES_IN_MEBIBYTE
+ );
+ lazy.logConsole.debug(
+ "total staging directory size in bytes: " +
+ totalSizeBytesNearestMebibyte
+ );
+
+ Glean.browserBackup.totalBackupSize.accumulate(
+ totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
+ );
+ } catch (e) {
+ return { currentStep, error: e };
+ }
+
+ return {
+ currentStep,
+ backupDirPath,
+ stagingPath: renamedStagingPath,
+ manifest,
+ };
+ }
+
+ /**
* @typedef {object} CreateBackupResult
* @property {object} manifest
* The backup manifest data of the created backup. See BackupManifest
@@ -1329,152 +1497,24 @@ export class BackupService extends EventTarget {
`Destination for archive: ${archiveDestFolderPath}`
);
- currentStep = STEPS.CREATE_BACKUP_CREATE_MANIFEST;
- let manifest = await this.#createBackupManifest();
-
- currentStep = STEPS.CREATE_BACKUP_CREATE_BACKUPS_FOLDER;
- // First, check to see if a `backups` directory already exists in the
- // profile.
- let backupDirPath = PathUtils.join(
- profilePath,
- BackupService.PROFILE_FOLDER_NAME,
- BackupService.SNAPSHOTS_FOLDER_NAME
- );
- 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,
- createAncestors: true,
- });
-
- currentStep = STEPS.CREATE_BACKUP_CREATE_STAGING_FOLDER;
- 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;
- }
- );
-
- currentStep = STEPS.CREATE_BACKUP_LOAD_ENCSTATE;
- let encState = await this.loadEncryptionState(profilePath);
- let encryptionEnabled = !!encState;
- lazy.logConsole.debug("Encryption enabled: ", encryptionEnabled);
-
- currentStep = STEPS.CREATE_BACKUP_RUN_BACKUP;
- // Perform the backup for each resource.
- for (let resourceClass of sortedResources) {
- try {
- lazy.logConsole.debug(
- `Backing up resource with key ${resourceClass.key}. ` +
- `Requires encryption: ${resourceClass.requiresEncryption}`
- );
-
- if (resourceClass.requiresEncryption && !encryptionEnabled) {
- lazy.logConsole.debug(
- "Encryption is not currently enabled. Skipping."
- );
- continue;
- }
-
- 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,
- encryptionEnabled
- );
-
- 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}`,
- e
- );
- }
- }
-
- currentStep = STEPS.CREATE_BACKUP_VERIFY_MANIFEST;
- // 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.JsonSchema.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)
+ let result = await this.createAndPopulateStagingFolder(profilePath);
+ this.#backupInProgress = true;
+ currentStep = result.currentStep;
+ if (result.error) {
+ // Re-throw the error so we can catch it below for telemetry
+ throw result.error;
}
- currentStep = STEPS.CREATE_BACKUP_WRITE_MANIFEST;
- // Write the manifest to the staging folder.
- let manifestPath = PathUtils.join(
- stagingPath,
- BackupService.MANIFEST_FILE_NAME
- );
- await IOUtils.writeJSON(manifestPath, manifest);
-
- currentStep = STEPS.CREATE_BACKUP_FINALIZE_STAGING;
- let renamedStagingPath =
- await this.#finalizeStagingFolder(stagingPath);
- lazy.logConsole.log(
- "Wrote backup to staging directory at ",
- renamedStagingPath
- );
-
- // Record the total size of the backup staging directory
- let totalSizeKilobytes =
- await BackupResource.getDirectorySize(renamedStagingPath);
- let totalSizeBytesNearestMebibyte = MeasurementUtils.fuzzByteSize(
- totalSizeKilobytes * BYTES_IN_KILOBYTE,
- 1 * BYTES_IN_MEBIBYTE
- );
- lazy.logConsole.debug(
- "total staging directory size in bytes: " +
- totalSizeBytesNearestMebibyte
- );
-
- Glean.browserBackup.totalBackupSize.accumulate(
- totalSizeBytesNearestMebibyte / BYTES_IN_MEBIBYTE
- );
+ let { backupDirPath, stagingPath, manifest } = result;
currentStep = STEPS.CREATE_BACKUP_COMPRESS_STAGING;
let compressedStagingPath = await this.#compressStagingFolder(
- renamedStagingPath,
+ stagingPath,
backupDirPath
).finally(async () => {
// retryReadonly is needed in case there were read only files in
// the profile.
- await IOUtils.remove(renamedStagingPath, {
+ await IOUtils.remove(stagingPath, {
recursive: true,
retryReadonly: true,
});
@@ -2807,6 +2847,213 @@ export class BackupService extends EventTarget {
}
/**
+ * Given a recovery path, read in the backup manifest from the archive and
+ * ensures that it is valid. Will throw an error for an invalid manifest.
+ *
+ * @param {string} recoveryPath The path to the decompressed backup archive
+ * on the file system.
+ * @returns {object} See the BackupManifest schema for the specific shape of the
+ * returned manifest object.
+ */
+ async #readAndValidateManifest(recoveryPath) {
+ // 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 BackupError(
+ "Backup manifest version not found",
+ ERRORS.CORRUPTED_ARCHIVE
+ );
+ }
+
+ if (manifest.version > lazy.ArchiveUtils.SCHEMA_VERSION) {
+ throw new BackupError(
+ "Cannot recover from a manifest newer than the current schema version",
+ ERRORS.UNSUPPORTED_BACKUP_VERSION
+ );
+ }
+
+ // Make sure that it conforms to the schema.
+ let manifestSchema = await BackupService.getSchemaForVersion(
+ SCHEMAS.BACKUP_MANIFEST,
+ manifest.version
+ );
+ let schemaValidationResult = lazy.JsonSchema.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 BackupError(
+ "Cannot recover from an invalid backup manifest",
+ ERRORS.CORRUPTED_ARCHIVE
+ );
+ }
+
+ // In the future, if we ever bump the ArchiveUtils.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;
+
+ if (meta.appName != AppConstants.MOZ_APP_NAME) {
+ throw new BackupError(
+ `Cannot recover a backup from ${meta.appName} in ${AppConstants.MOZ_APP_NAME}`,
+ ERRORS.UNSUPPORTED_APPLICATION
+ );
+ }
+
+ if (
+ Services.vc.compare(AppConstants.MOZ_APP_VERSION, meta.appVersion) < 0
+ ) {
+ throw new BackupError(
+ `Cannot recover a backup created on version ${meta.appVersion} in ${AppConstants.MOZ_APP_VERSION}`,
+ ERRORS.UNSUPPORTED_BACKUP_VERSION
+ );
+ }
+
+ return manifest;
+ }
+
+ /**
+ * 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.
+ *
+ * @param {object} manifest See the BackupManifest schema for the specific
+ * shape of the returned manifest object.
+ * @param {string} recoveryPath The path to the decompressed backup archive
+ * on the file system.
+ * @param {string} profilePath The path of the newly recovered profile
+ * @returns {object}
+ * An object containing post recovery data for each resource.
+ */
+ async #recoverResources(manifest, recoveryPath, profilePath) {
+ 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,
+ profilePath
+ );
+ postRecovery[resourceKey] = postRecoveryEntry;
+ } catch (e) {
+ lazy.logConsole.error(`Failed to recover resource: ${resourceKey}`, e);
+ throw e;
+ }
+ }
+
+ return postRecovery;
+ }
+
+ /**
+ * Make sure the legacy telemetry client ID exists and write telemetry files
+ * to the profile we are recovering into.
+ *
+ * @param {string} profilePath The path of the newly recovered profile
+ */
+ async #writeTelemetryFiles(profilePath) {
+ // 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(profilePath, TELEMETRY_STATE_FOLDER)
+ );
+ await IOUtils.copy(
+ /* source */
+ PathUtils.join(
+ PathUtils.profileDir,
+ TELEMETRY_STATE_FOLDER,
+ TELEMETRY_STATE_FILENAME
+ ),
+ /* destination */
+ PathUtils.join(
+ profilePath,
+ TELEMETRY_STATE_FOLDER,
+ TELEMETRY_STATE_FILENAME
+ )
+ );
+ }
+
+ /**
+ * If the encState exists, write the encrypted state object to the
+ * ARCHIVE_ENCRYPTION_STATE_FILE.
+ *
+ * @param {ArchiveEncryptionState|null} encState Set if the backup being
+ * recovered was encrypted. This implies that the profile being recovered
+ * was configured to create encrypted backups. This ArchiveEncryptionState
+ * is therefore needed to generate the ARCHIVE_ENCRYPTION_STATE_FILE for
+ * the recovered profile (since the original ARCHIVE_ENCRYPTION_STATE_FILE
+ * was intentionally not backed up, as the recovery device might have a
+ * different OSKeyStore secret).
+ * @param {string} profilePath The path of the newly recovered profile
+ */
+ async #maybeWriteEncryptedStateObject(encState, profilePath) {
+ if (encState) {
+ // The backup we're recovering was originally encrypted, meaning that
+ // the recovered profile is configured to create encrypted backups. Our
+ // caller passed us a _new_ ArchiveEncryptionState generated for this
+ // device with the backup's recovery code so that we can serialize the
+ // ArchiveEncryptionState for the recovered profile.
+ let encStatePath = PathUtils.join(
+ profilePath,
+ BackupService.PROFILE_FOLDER_NAME,
+ BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
+ );
+ let encStateObject = await encState.serialize();
+ await IOUtils.writeJSON(encStatePath, encStateObject);
+ }
+ }
+
+ /**
+ * Write the post recovery data to the newly recovered profile.
+ *
+ * @param {object} postRecoveryData An object containing post recovery data
+ * from each resource recovered.
+ * @param {string} profilePath The path of the newly recovered profile
+ */
+ async #writePostRecoveryData(postRecoveryData, profilePath) {
+ let postRecoveryPath = PathUtils.join(
+ profilePath,
+ BackupService.POST_RECOVERY_FILE_NAME
+ );
+ await IOUtils.writeJSON(postRecoveryPath, postRecoveryData);
+ }
+
+ /**
* Given a decompressed backup archive at recoveryPath, this method does the
* following:
*
@@ -2857,70 +3104,7 @@ export class BackupService extends EventTarget {
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 BackupError(
- "Backup manifest version not found",
- ERRORS.CORRUPTED_ARCHIVE
- );
- }
-
- if (manifest.version > lazy.ArchiveUtils.SCHEMA_VERSION) {
- throw new BackupError(
- "Cannot recover from a manifest newer than the current schema version",
- ERRORS.UNSUPPORTED_BACKUP_VERSION
- );
- }
-
- // Make sure that it conforms to the schema.
- let manifestSchema = await BackupService.getSchemaForVersion(
- SCHEMAS.BACKUP_MANIFEST,
- manifest.version
- );
- let schemaValidationResult = lazy.JsonSchema.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 BackupError(
- "Cannot recover from an invalid backup manifest",
- ERRORS.CORRUPTED_ARCHIVE
- );
- }
-
- // In the future, if we ever bump the ArchiveUtils.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;
-
- if (meta.appName != AppConstants.MOZ_APP_NAME) {
- throw new BackupError(
- `Cannot recover a backup from ${meta.appName} in ${AppConstants.MOZ_APP_NAME}`,
- ERRORS.UNSUPPORTED_APPLICATION
- );
- }
-
- if (
- Services.vc.compare(AppConstants.MOZ_APP_VERSION, meta.appVersion) < 0
- ) {
- throw new BackupError(
- `Cannot recover a backup created on version ${meta.appVersion} in ${AppConstants.MOZ_APP_VERSION}`,
- ERRORS.UNSUPPORTED_BACKUP_VERSION
- );
- }
+ let manifest = await this.#readAndValidateManifest(recoveryPath);
// Okay, we have a valid backup-manifest.json. Let's create a new profile
// and start invoking the recover() method on each BackupResource.
@@ -2929,91 +3113,23 @@ export class BackupService extends EventTarget {
);
let profile = profileSvc.createUniqueProfile(
profileRootPath ? await IOUtils.getDirectory(profileRootPath) : null,
- meta.profileName
+ manifest.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
- );
- throw 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 postRecovery = await this.#recoverResources(
+ manifest,
+ recoveryPath,
+ profile.rootDir.path
);
- if (encState) {
- // The backup we're recovering was originally encrypted, meaning that
- // the recovered profile is configured to create encrypted backups. Our
- // caller passed us a _new_ ArchiveEncryptionState generated for this
- // device with the backup's recovery code so that we can serialize the
- // ArchiveEncryptionState for the recovered profile.
- let encStatePath = PathUtils.join(
- profile.rootDir.path,
- BackupService.PROFILE_FOLDER_NAME,
- BackupService.ARCHIVE_ENCRYPTION_STATE_FILE
- );
- let encStateObject = await encState.serialize();
- await IOUtils.writeJSON(encStatePath, encStateObject);
- }
+ await this.#writeTelemetryFiles(profile.rootDir.path);
- let postRecoveryPath = PathUtils.join(
- profile.rootDir.path,
- BackupService.POST_RECOVERY_FILE_NAME
+ await this.#maybeWriteEncryptedStateObject(
+ encState,
+ profile.rootDir.path
);
- await IOUtils.writeJSON(postRecoveryPath, postRecovery);
+
+ await this.#writePostRecoveryData(postRecovery, profile.rootDir.path);
// In a release scenario, this should always be true
// this makes it easier to get around setting up profiles for testing other functionality
@@ -3046,6 +3162,104 @@ export class BackupService extends EventTarget {
}
/**
+ * 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 SelectableProfile 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.
+ * 6. Regardless of whether or not recovery succeeded, clears the native
+ * OSKeyStore of any secret labeled with
+ * BackupService.RECOVERY_OSKEYSTORE_LABEL.
+ *
+ * @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 {ArchiveEncryptionState} [encState=null]
+ * Set if the backup being recovered was encrypted. This implies that the
+ * profile being recovered was configured to create encrypted backups. This
+ * ArchiveEncryptionState is therefore needed to generate the
+ * ARCHIVE_ENCRYPTION_STATE_FILE for the recovered profile (since the
+ * original ARCHIVE_ENCRYPTION_STATE_FILE was intentionally not backed up,
+ * as the recovery device might have a different OSKeyStore secret).
+ * @param {SelectableProfile} [copiedProfile=null]
+ * If the profile we are recovering is a "copied" profile, we don't want to
+ * inherit the client ID as this profile will be a new profile in the
+ * profile group. If we are copying a profile, we will use
+ * copiedProfile.name to show that the new profile is a copy of
+ * copiedProfile on about:editprofile.
+ * @returns {Promise<SelectableProfile>}
+ * The SelectableProfile that was created for the recovered profile.
+ * @throws {Exception}
+ * In the event that recovery somehow failed.
+ */
+ async recoverFromSnapshotFolderIntoSelectableProfile(
+ recoveryPath,
+ shouldLaunch = false,
+ encState = null,
+ copiedProfile = null
+ ) {
+ lazy.logConsole.debug(
+ "Recovering SelectableProfile from backup at ",
+ recoveryPath
+ );
+
+ try {
+ let manifest = await this.#readAndValidateManifest(recoveryPath);
+
+ // Okay, we have a valid backup-manifest.json. Let's create a new profile
+ // and start invoking the recover() method on each BackupResource.
+ let profile = await lazy.SelectableProfileService.createNewProfile(false);
+
+ let postRecovery = await this.#recoverResources(
+ manifest,
+ recoveryPath,
+ profile.path
+ );
+
+ // We don't want to copy the client ID if this is a copied profile
+ // because this profile will be a new profile in the profile group.
+ if (!copiedProfile) {
+ await this.#writeTelemetryFiles(profile.path);
+ }
+
+ await this.#maybeWriteEncryptedStateObject(encState, profile.path);
+
+ await this.#writePostRecoveryData(postRecovery, profile.path);
+
+ if (shouldLaunch) {
+ lazy.SelectableProfileService.launchInstance(
+ profile,
+ // Using URL Search Params on this about: page didn't work because
+ // the RPM communication so we use the hash and parse that instead.
+ "about:editprofile" +
+ (copiedProfile ? `#copiedProfileName=${copiedProfile.name}` : "")
+ );
+ }
+
+ return profile;
+ } catch (e) {
+ lazy.logConsole.error(
+ "Failed to recover SelectableProfile 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
diff --git a/browser/components/backup/resources/PreferencesBackupResource.sys.mjs b/browser/components/backup/resources/PreferencesBackupResource.sys.mjs
@@ -38,6 +38,7 @@ export class PreferencesBackupResource extends BackupResource {
// List of prefs we never backup.
let kIgnoredPrefs = [
"app.normandy.user_id",
+ "toolkit.telemetry.cachedClientID",
"toolkit.telemetry.cachedProfileGroupID",
PROFILE_RESTORATION_DATE_PREF,
];
diff --git a/browser/components/backup/tests/xpcshell/test_BackupService_recoverFromSnapshotFolderIntoSelectableProfile.js b/browser/components/backup/tests/xpcshell/test_BackupService_recoverFromSnapshotFolderIntoSelectableProfile.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { ArchiveUtils } = ChromeUtils.importESModule(
+ "resource:///modules/backup/ArchiveUtils.sys.mjs"
+);
+const { JsonSchema } = ChromeUtils.importESModule(
+ "resource://gre/modules/JsonSchema.sys.mjs"
+);
+
+/**
+ * Tests that if the backup-manifest.json provides an appName different from
+ * AppConstants.MOZ_APP_NAME of the currently running application, then
+ * recoverFromSnapshotFolderIntoSelectableProfile should throw an exception.
+ */
+add_task(async function test_different_appName() {
+ let testRecoveryPath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "testDifferentAppName"
+ );
+
+ let meta = Object.assign({}, FAKE_METADATA);
+ meta.appName = "Some other application";
+ Assert.notEqual(
+ meta.appName,
+ AppConstants.MOZ_APP_NAME,
+ "Set up a different appName in the manifest correctly."
+ );
+
+ let manifest = {
+ version: ArchiveUtils.SCHEMA_VERSION,
+ meta,
+ resources: {},
+ };
+ let schema = await BackupService.MANIFEST_SCHEMA;
+ let validationResult = JsonSchema.validate(manifest, schema);
+ Assert.ok(validationResult.valid, "Schema matches manifest");
+
+ await IOUtils.writeJSON(
+ PathUtils.join(testRecoveryPath, BackupService.MANIFEST_FILE_NAME),
+ manifest
+ );
+
+ let bs = new BackupService();
+ // This should reject and mention the invalid appName from the manifest.
+ await Assert.rejects(
+ bs.recoverFromSnapshotFolderIntoSelectableProfile(testRecoveryPath),
+ new RegExp(`${meta.appName}`)
+ );
+
+ await IOUtils.remove(testRecoveryPath, { recursive: true });
+});
+
+/**
+ * Tests that if the backup-manifest.json provides an appVersion greater than
+ * AppConstants.MOZ_APP_VERSION of the currently running application, then
+ * recoverFromSnapshotFolderIntoSelectableProfile should throw an exception.
+ */
+add_task(async function test_newer_appVersion() {
+ let testRecoveryPath = await IOUtils.createUniqueDirectory(
+ PathUtils.tempDir,
+ "testNewerAppVersion"
+ );
+
+ let meta = Object.assign({}, FAKE_METADATA);
+ // Hopefully this static version number will do for now.
+ meta.appVersion = "999.0.0";
+ Assert.equal(
+ Services.vc.compare(AppConstants.MOZ_APP_VERSION, meta.appVersion),
+ -1,
+ "The current application version is less than 999.0.0."
+ );
+
+ let manifest = {
+ version: ArchiveUtils.SCHEMA_VERSION,
+ meta,
+ resources: {},
+ };
+ let schema = await BackupService.MANIFEST_SCHEMA;
+ let validationResult = JsonSchema.validate(manifest, schema);
+ Assert.ok(validationResult.valid, "Schema matches manifest");
+
+ await IOUtils.writeJSON(
+ PathUtils.join(testRecoveryPath, BackupService.MANIFEST_FILE_NAME),
+ manifest
+ );
+
+ let bs = new BackupService();
+ // This should reject and mention the invalid appVersion from the manifest.
+ await Assert.rejects(
+ bs.recoverFromSnapshotFolderIntoSelectableProfile(testRecoveryPath),
+ new RegExp(`${meta.appVersion}`)
+ );
+
+ await IOUtils.remove(testRecoveryPath, { recursive: true });
+});
diff --git a/browser/components/backup/tests/xpcshell/xpcshell.toml b/browser/components/backup/tests/xpcshell/xpcshell.toml
@@ -48,6 +48,8 @@ run-sequentially = ["true"] # Mock Windows registry interferes with normal opera
["test_BackupService_recoverFromSnapshotFolder.js"]
+["test_BackupService_recoverFromSnapshotFolderIntoSelectableProfile.js"]
+
["test_BackupService_regeneration.js"]
["test_BackupService_renderTemplate.js"]
diff --git a/browser/components/profiles/SelectableProfile.sys.mjs b/browser/components/profiles/SelectableProfile.sys.mjs
@@ -7,6 +7,7 @@ import { DownloadPaths } from "resource://gre/modules/DownloadPaths.sys.mjs";
import { FileUtils } from "resource://gre/modules/FileUtils.sys.mjs";
import { ProfilesDatastoreService } from "moz-src:///toolkit/profile/ProfilesDatastoreService.sys.mjs";
import { SelectableProfileService } from "resource:///modules/profiles/SelectableProfileService.sys.mjs";
+import { BackupService } from "resource:///modules/backup/BackupService.sys.mjs";
const lazy = {};
@@ -472,6 +473,30 @@ export class SelectableProfile {
return profileObj;
}
+ async copyProfile() {
+ const backupServiceInstance = BackupService.init();
+ let result = await backupServiceInstance.createAndPopulateStagingFolder(
+ this.path
+ );
+
+ if (result.error) {
+ throw result.error;
+ }
+
+ let copiedProfile =
+ await backupServiceInstance.recoverFromSnapshotFolderIntoSelectableProfile(
+ result.stagingPath,
+ true, // shouldLaunch
+ null, // encState
+ this // copiedProfile
+ );
+
+ copiedProfile.theme = this.theme;
+ await copiedProfile.setAvatar(this.avatar);
+
+ return copiedProfile;
+ }
+
// Desktop shortcut-related methods, currently Windows-only.
/**
diff --git a/browser/components/profiles/content/edit-profile-card.css b/browser/components/profiles/content/edit-profile-card.css
@@ -4,10 +4,10 @@
@import url("chrome://browser/content/profiles/profiles-pages.css");
-#profile-content {
- display: flex;
- flex-direction: column;
- gap: var(--space-xlarge);
+:host(:not([iscopy])) {
+ #profile-content {
+ gap: var(--space-xlarge);
+ }
}
#profile-content h1[data-l10n-id="edit-profile-page-header"] {
diff --git a/browser/components/profiles/content/edit-profile-card.mjs b/browser/components/profiles/content/edit-profile-card.mjs
@@ -77,6 +77,7 @@ export class EditProfileCard extends MozLitElement {
profile: { type: Object },
profiles: { type: Array },
themes: { type: Array },
+ isCopy: { type: Boolean, reflect: true },
};
static queries = {
@@ -131,6 +132,12 @@ export class EditProfileCard extends MozLitElement {
return;
}
+ this.isCopy = document.location.hash.includes("#copiedProfileName");
+ let fakeParams = new URLSearchParams(
+ document.location.hash.replace("#", "")
+ );
+ this.copiedProfileName = fakeParams.get("copiedProfileName");
+
let {
currentProfile,
hasDesktopShortcut,
@@ -149,6 +156,18 @@ export class EditProfileCard extends MozLitElement {
this.profiles = profiles;
this.setProfile(currentProfile);
this.themes = themes;
+
+ await this.setInitialInput();
+ }
+
+ async setInitialInput() {
+ if (!this.isCopy) {
+ return;
+ }
+
+ await this.getUpdateComplete();
+
+ this.nameInput.value = "";
}
createAvatarURL() {
@@ -330,6 +349,18 @@ export class EditProfileCard extends MozLitElement {
}
headerTemplate() {
+ if (this.isCopy) {
+ return html`<div>
+ <h1
+ data-l10n-id="copied-profile-page-header"
+ data-l10n-args=${JSON.stringify({
+ profilename: this.copiedProfileName,
+ })}
+ ></h1>
+ <p data-l10n-id="copied-profile-page-header-description"></p>
+ </div>`;
+ }
+
return html`<h1
id="profile-header"
data-l10n-id="edit-profile-page-header"
diff --git a/browser/components/profiles/tests/unit/test_copy_profile.js b/browser/components/profiles/tests/unit/test_copy_profile.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const execProcess = sinon.fake();
+
+add_setup(async () => {
+ await initSelectableProfileService();
+
+ sinon.replace(getSelectableProfileService(), "execProcess", execProcess);
+});
+
+add_task(async function test_create_profile() {
+ startProfileService();
+
+ const SelectableProfileService = getSelectableProfileService();
+ const ProfilesDatastoreService = getProfilesDatastoreService();
+
+ await ProfilesDatastoreService.init();
+ await SelectableProfileService.init();
+ Assert.ok(SelectableProfileService.isEnabled, "Service should be enabled");
+
+ let profiles = await SelectableProfileService.getAllProfiles();
+
+ Assert.equal(profiles.length, 1, "Only one selectable profile exist");
+
+ let copiedProfile =
+ await SelectableProfileService.currentProfile.copyProfile();
+
+ profiles = await SelectableProfileService.getAllProfiles();
+ Assert.equal(profiles.length, 2, "Two selectable profiles exist");
+
+ Assert.equal(
+ copiedProfile.avatar,
+ SelectableProfileService.currentProfile.avatar,
+ "Copied profile has the same avatar"
+ );
+
+ Assert.equal(
+ copiedProfile.theme.themeId,
+ SelectableProfileService.currentProfile.theme.themeId,
+ "Copied profile has the same theme"
+ );
+});
diff --git a/browser/components/profiles/tests/unit/xpcshell.toml b/browser/components/profiles/tests/unit/xpcshell.toml
@@ -13,6 +13,8 @@ prefs = [
["test_command_line_handler.js"]
run-sequentially = ["os == 'mac'"] # frequent fail in parallel on Mac
+["test_copy_profile.js"]
+
["test_create_profile.js"]
["test_delete_last_profile.js"]
diff --git a/browser/locales/en-US/browser/profiles.ftl b/browser/locales/en-US/browser/profiles.ftl
@@ -74,6 +74,11 @@ new-profile-page-input-placeholder =
new-profile-page-done-button =
.label = Done editing
+# Variables
+# $profilename (String) - The name of the copied profile.
+copied-profile-page-header = Your copy of { $profilename } is ready to customize
+copied-profile-page-header-description = We copied your data and settings into a new profile. Now give it a name, pick a look, and make it your own.
+
## Delete profile dialogue that allows users to review what they will lose if they choose to delete their profile. Each item (open windows, etc.) is displayed in a table, followed by a column with the number of items.
# Variables