commit 0439d9bfbab4c699b602fdb1ff5b54eabda98887
parent c3697d43c4b4a996f816bd27d2b90d8bb163ff1e
Author: Nicholas Rishel <nrishel@mozilla.com>
Date: Thu, 9 Oct 2025 16:32:03 +0000
Bug 1991724 - Add ability to force disable profile backup and restore. r=cdupuis,sthompson
Differential Revision: https://phabricator.services.mozilla.com/D267327
Diffstat:
4 files changed, 223 insertions(+), 0 deletions(-)
diff --git a/browser/components/backup/BackupService.sys.mjs b/browser/components/backup/BackupService.sys.mjs
@@ -72,6 +72,7 @@ ChromeUtils.defineESModuleGetters(lazy, {
FileUtils: "resource://gre/modules/FileUtils.sys.mjs",
JsonSchema: "resource://gre/modules/JsonSchema.sys.mjs",
NetUtil: "resource://gre/modules/NetUtil.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
UIState: "resource://services-sync/UIState.sys.mjs",
});
@@ -594,6 +595,52 @@ export class BackupService extends EventTarget {
static #errorRetries = 0;
/**
+ * @typedef {object} EnabledStatus
+ * @property {boolean} enabled
+ * True if the feature is enabled.
+ * @property {string} [reason]
+ * Reason the feature is disabled if `enabled` is false.
+ */
+
+ /**
+ * Context for whether creating a backup archive is enabled.
+ *
+ * @type {EnabledStatus}
+ */
+ get archiveEnabledStatus() {
+ // Check if disabled by Nimbus killswitch.
+ const archiveKillswitchTriggered =
+ lazy.NimbusFeatures.backupService.getVariable("archiveKillswitch");
+ if (archiveKillswitchTriggered) {
+ return {
+ enabled: false,
+ reason: "Archiving a profile disabled remotely.",
+ };
+ }
+
+ return { enabled: true };
+ }
+
+ /**
+ * Context for whether restore from backup is enabled.
+ *
+ * @type {EnabledStatus}
+ */
+ get restoreEnabledStatus() {
+ // Check if disabled by Nimbus killswitch.
+ const restoreKillswitchTriggered =
+ lazy.NimbusFeatures.backupService.getVariable("restoreKillswitch");
+ if (restoreKillswitchTriggered) {
+ return {
+ enabled: false,
+ reason: "Restore from backup disabled remotely.",
+ };
+ }
+
+ return { enabled: true };
+ }
+
+ /**
* Set to true if a backup is currently in progress. Causes stateUpdate()
* to be called.
*
@@ -1228,6 +1275,12 @@ export class BackupService extends EventTarget {
* created, or null if the backup failed.
*/
async createBackup({ profilePath = PathUtils.profileDir } = {}) {
+ const status = this.archiveEnabledStatus;
+ if (!status.enabled) {
+ lazy.logConsole.debug(status.reason);
+ return null;
+ }
+
// createBackup does not allow re-entry or concurrent backups.
if (this.#backupInProgress) {
lazy.logConsole.warn("Backup attempt already in progress");
@@ -2636,6 +2689,11 @@ export class BackupService extends EventTarget {
profilePath = PathUtils.profileDir,
profileRootPath = null
) {
+ const status = this.restoreEnabledStatus;
+ if (!status.enabled) {
+ throw new Error(status.reason);
+ }
+
// No concurrent recoveries.
if (this.#_state.recoveryInProgress) {
lazy.logConsole.warn("Recovery attempt already in progress");
diff --git a/browser/components/backup/tests/xpcshell/test_BackupService_enabled.js b/browser/components/backup/tests/xpcshell/test_BackupService_enabled.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { NimbusTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+add_setup(async () => {
+ setupProfile();
+
+ NimbusTestUtils.init(this);
+
+ const { cleanup: nimbusCleanup } = await NimbusTestUtils.setupTest();
+
+ await ExperimentAPI.ready();
+
+ let backupDir = await IOUtils.createUniqueDirectory(
+ PathUtils.profileDir,
+ "backup"
+ );
+
+ // Use temporary directory for backups.
+ Services.prefs.setStringPref("browser.backup.location", backupDir);
+
+ registerCleanupFunction(async () => {
+ const nimbusCleanupPromise = nimbusCleanup();
+ const backupDirCleanupPromise = IOUtils.remove(backupDir, {
+ recursive: true,
+ });
+
+ await Promise.all([nimbusCleanupPromise, backupDirCleanupPromise]);
+
+ Services.prefs.clearUserPref("browser.backup.location");
+ });
+
+ BackupService.init();
+});
+
+add_task(async function test_archive_killswitch_enrollment() {
+ const bs = BackupService.get();
+
+ const cleanupExperiment = await NimbusTestUtils.enrollWithFeatureConfig({
+ featureId: "backupService",
+ value: { archiveKillswitch: true },
+ });
+
+ Assert.ok(
+ !bs.archiveEnabledStatus.enabled,
+ "The backup service should report that archiving is disabled when the archive killswitch is active."
+ );
+
+ Assert.equal(
+ bs.archiveEnabledStatus.reason,
+ "Archiving a profile disabled remotely.",
+ "`archiveEnabledStatus` should report that it is disabled by the archive killswitch."
+ );
+
+ let backup = await bs.createBackup();
+ Assert.ok(
+ !backup,
+ "Creating a backup should fail when the archive killswitch is active."
+ );
+
+ // End the experiment.
+ await cleanupExperiment();
+
+ Assert.ok(
+ bs.archiveEnabledStatus.enabled,
+ "The backup service should report that archiving is enabled once the archive killswitch experiment ends."
+ );
+
+ backup = await bs.createBackup();
+ ok(
+ backup,
+ "Creating a backup should succeed once the archive killswitch experiment ends."
+ );
+ ok(
+ await IOUtils.exists(backup.archivePath),
+ "Archive file should exist on disk."
+ );
+});
+
+add_task(async function test_restore_killswitch_enrollment() {
+ const bs = BackupService.get();
+ const backup = await bs.createBackup();
+
+ Assert.ok(
+ backup && backup.archivePath,
+ "Archive should have been created on disk."
+ );
+
+ let cleanupExperiment = await NimbusTestUtils.enrollWithFeatureConfig({
+ featureId: "backupService",
+ value: { restoreKillswitch: true },
+ });
+
+ const recoveryDir = await IOUtils.createUniqueDirectory(
+ PathUtils.profileDir,
+ "recovered-profiles"
+ );
+
+ Assert.ok(
+ !bs.restoreEnabledStatus.enabled,
+ "The backup service should report that restoring is disabled when the restore killswitch is active."
+ );
+
+ Assert.equal(
+ bs.restoreEnabledStatus.reason,
+ "Restore from backup disabled remotely.",
+ "`restoreEnabledStatus` should report that it is disabled by the restore killswitch."
+ );
+
+ await Assert.rejects(
+ bs.recoverFromBackupArchive(
+ backup.archivePath,
+ null,
+ false,
+ PathUtils.profileDir,
+ recoveryDir
+ ),
+ /Restore from backup disabled remotely\./,
+ "Recovery should throw when the restore killswitch is active."
+ );
+
+ // End the experiment.
+ await cleanupExperiment();
+
+ Assert.ok(
+ bs.restoreEnabledStatus.enabled,
+ "The backup service should report that restoring is enabled once the restore killswitch experiment ends."
+ );
+
+ let recoveredProfile = await bs.recoverFromBackupArchive(
+ backup.archivePath,
+ null,
+ false,
+ PathUtils.profileDir,
+ recoveryDir
+ );
+ Assert.ok(
+ recoveredProfile,
+ "Recovery should succeed once the restore killswitch experiment ends."
+ );
+ Assert.ok(
+ await IOUtils.exists(recoveredProfile.rootDir.path),
+ "Recovered profile directory should exist on disk."
+ );
+});
diff --git a/browser/components/backup/tests/xpcshell/xpcshell.toml b/browser/components/backup/tests/xpcshell/xpcshell.toml
@@ -35,6 +35,8 @@ support-files = [
["test_BackupService_enable_disable_encryption.js"]
+["test_BackupService_enabled.js"]
+
["test_BackupService_finalizeSingleFileArchive.js"]
["test_BackupService_onedrive.js"]
diff --git a/toolkit/components/nimbus/FeatureManifest.yaml b/toolkit/components/nimbus/FeatureManifest.yaml
@@ -4811,6 +4811,16 @@ backupService:
setPref:
branch: user
pref: browser.backup.scheduled.minimum-time-between-backups-seconds
+ archiveKillswitch:
+ type: boolean
+ default: false
+ description: >-
+ Disables backup archiving, overrides all other configs when true.
+ restoreKillswitch:
+ type: boolean
+ default: false
+ description: >-
+ Disables backup restoration, overrides all other configs when true.
pqcrypto:
description: Prefs that control the use of post-quantum cryptography.