tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/components/backup/BackupService.sys.mjs | 774++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mbrowser/components/backup/resources/PreferencesBackupResource.sys.mjs | 1+
Abrowser/components/backup/tests/xpcshell/test_BackupService_recoverFromSnapshotFolderIntoSelectableProfile.js | 101+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/backup/tests/xpcshell/xpcshell.toml | 2++
Mbrowser/components/profiles/SelectableProfile.sys.mjs | 25+++++++++++++++++++++++++
Mbrowser/components/profiles/content/edit-profile-card.css | 8++++----
Mbrowser/components/profiles/content/edit-profile-card.mjs | 31+++++++++++++++++++++++++++++++
Abrowser/components/profiles/tests/unit/test_copy_profile.js | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/profiles/tests/unit/xpcshell.toml | 2++
Mbrowser/locales/en-US/browser/profiles.ftl | 5+++++
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