tor-browser

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

commit eb3f9ea0ff5ee79d4998a032d2e460d011a43c4d
parent 626cbe672edf7b0323fbd49c38ac0a599774a811
Author: Beth Rennie <beth@brennie.ca>
Date:   Fri, 31 Oct 2025 03:39:49 +0000

Bug 1972647 - Allow Firefox Labs to be enabled even if studies or telemetry are disabled r=mkaply,nimbus-reviewers,relud

Both Firefox Labs and one of either studies or telemetry must now be
disabled to fully disable Nimbus.

Differential Revision: https://phabricator.services.mozilla.com/D269920

Diffstat:
Mbrowser/components/enterprisepolicies/Policies.sys.mjs | 4++++
Mtoolkit/components/nimbus/ExperimentAPI.sys.mjs | 81+++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mtoolkit/components/nimbus/lib/ExperimentManager.sys.mjs | 40++++++++++++++++++++++------------------
Mtoolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs | 89+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mtoolkit/components/nimbus/lib/Telemetry.sys.mjs | 1+
Mtoolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js | 6+++++-
Mtoolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js | 24++++++++++++++++++++++--
Mtoolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js | 16++++++++++------
Mtoolkit/components/nimbus/test/unit/test_policy.js | 138++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
9 files changed, 282 insertions(+), 117 deletions(-)

diff --git a/browser/components/enterprisepolicies/Policies.sys.mjs b/browser/components/enterprisepolicies/Policies.sys.mjs @@ -2924,6 +2924,10 @@ export var Policies = { ); } if ("FirefoxLabs" in param) { + if (!param.FirefoxLabs) { + manager.disallowFeature("FirefoxLabs"); + } + PoliciesUtils.setDefaultPref( "browser.preferences.experimental", param.FirefoxLabs, diff --git a/toolkit/components/nimbus/ExperimentAPI.sys.mjs b/toolkit/components/nimbus/ExperimentAPI.sys.mjs @@ -162,6 +162,8 @@ export const ExperimentAPI = new (class { telemetryEnabled: false, }; + #studiesEnabled = false; + constructor() { if (IS_MAIN_PROCESS) { // Ensure that the profile ID is cached in a pref. @@ -181,24 +183,7 @@ export const ExperimentAPI = new (class { } } - const onStudiesEnabledChanged = this.#onStudiesEnabledChanged.bind(this); - - XPCOMUtils.defineLazyPreferenceGetter( - this.#prefValues, - "studiesEnabled", - STUDIES_OPT_OUT_PREF, - false, - onStudiesEnabledChanged - ); - - XPCOMUtils.defineLazyPreferenceGetter( - this.#prefValues, - "telemetryEnabled", - UPLOAD_ENABLED_PREF, - false, - onStudiesEnabledChanged - ); - + this._onStudiesEnabledChanged = this._onStudiesEnabledChanged.bind(this); this._annotateCrashReport = this._annotateCrashReport.bind(this); this._removeCrashReportAnnotator = this._removeCrashReportAnnotator.bind(this); @@ -244,6 +229,10 @@ export const ExperimentAPI = new (class { this.#initialized = true; + // Compute the enabled state and cache it. It is possible for the enabled + // state to change during ExperimentAPI initialization, but we do not + // register our observers until the end of this function. + this.#computeEnabled(); const studiesEnabled = this.studiesEnabled; try { @@ -310,10 +299,13 @@ export const ExperimentAPI = new (class { ); } + Services.prefs.addObserver(STUDIES_OPT_OUT_PREF, this._onStudiesEnabledChanged); + Services.prefs.addObserver(UPLOAD_ENABLED_PREF, this._onStudiesEnabledChanged); + // If Nimbus was disabled between the start of this function and registering // the pref observers we have not handled it yet. if (studiesEnabled !== this.studiesEnabled) { - await this.#onStudiesEnabledChanged(); + await this._onStudiesEnabledChanged(); } return true; @@ -369,17 +361,35 @@ export const ExperimentAPI = new (class { this.#experimentManager?.store.off("update", this._annotateCrashReport); this.#experimentManager = null; + Services.prefs.removeObserver(STUDIES_OPT_OUT_PREF, this._onStudiesEnabledChanged); + Services.prefs.removeObserver(UPLOAD_ENABLED_PREF, this._onStudiesEnabledChanged); + this.#initialized = false; } - get studiesEnabled() { - return ( + #computeEnabled() { + this.#prefValues.studiesEnabled = Services.prefs.getBoolPref(STUDIES_OPT_OUT_PREF, false); + this.#prefValues.telemetryEnabled = Services.prefs.getBoolPref(UPLOAD_ENABLED_PREF, false) + + this.#studiesEnabled = ( this.#prefValues.studiesEnabled && this.#prefValues.telemetryEnabled && Services.policies.isAllowed("Shield") ); } + get enabled() { + return this.studiesEnabled || this.labsEnabled; + } + + get labsEnabled() { + return Services.policies.isAllowed("FirefoxLabs"); + } + + get studiesEnabled() { + return this.#studiesEnabled; + } + /** * Return the profile ID. * @@ -445,18 +455,35 @@ export const ExperimentAPI = new (class { } } - async #onStudiesEnabledChanged() { + async _onStudiesEnabledChanged(_topic, _subject, prefName) { + const studiesPreviouslyEnabled = this.studiesEnabled; + + switch (prefName) { + case STUDIES_OPT_OUT_PREF: + case UPLOAD_ENABLED_PREF: + this.#computeEnabled(); + break; + + default: + return; + } + if (!this.#initialized) { return; } - if (!this.studiesEnabled) { - await this.manager._handleStudiesOptOut(); - } + if (studiesPreviouslyEnabled !== this.studiesEnabled) { + if (!this.studiesEnabled) { + this.manager._handleStudiesOptOut(); + } - await this._rsLoader.onEnabledPrefChange(); + // Labs is disabled only by policy, so it cannot be disabled at runtime. + // Thus we only need to notify the RemoteSettingsExperimentLoader when + // studies become enabled or disabled. + await this._rsLoader.onEnabledPrefChange(); - Services.obs.notifyObservers(null, this.STUDIES_ENABLED_CHANGED); + Services.obs.notifyObservers(null, this.STUDIES_ENABLED_CHANGED); + } } /** diff --git a/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs b/toolkit/components/nimbus/lib/ExperimentManager.sys.mjs @@ -308,10 +308,6 @@ export class ExperimentManager { return; } - if (result.ok && recipe.isFirefoxLabsOptIn) { - this.optInRecipes.push(recipe); - } - if (!result.ok) { lazy.NimbusTelemetry.recordEnrollmentStatus({ slug: recipe.slug, @@ -322,8 +318,15 @@ export class ExperimentManager { return; } + // Unenrollment due to studies becoming disabled is handled in + // `_handleStudiesOptOut`. + if (result.status === lazy.MatchStatus.DISABLED) { + return; + } + if (recipe.isFirefoxLabsOptIn) { // We do not enroll directly into Firefox Labs opt-ins. + this.optInRecipes.push(recipe); return; } @@ -749,8 +752,17 @@ export class ExperimentManager { const { EnrollmentStatus, EnrollmentStatusReason, UnenrollReason } = lazy.NimbusTelemetry; - if (result.ok && recipe?.isFirefoxLabsOptIn) { - this.optInRecipes.push(recipe); + if (result.ok) { + // Unenrollment due to studies becoming disabled is handled in + // `_handleStudiesOptOut`. Firefox Labs can only be disabled by policy and + // thus its enabled state cannot change at runtime. + if (result.status === lazy.MatchStatus.DISABLED) { + return false; + } + + if (recipe?.isFirefoxLabsOptIn) { + this.optInRecipes.push(recipe); + } } if (enrollment.active) { @@ -925,8 +937,10 @@ export class ExperimentManager { /** * Unenroll from all active studies if user opts out. */ - async _handleStudiesOptOut() { - for (const enrollment of this.store.getAllActiveExperiments()) { + _handleStudiesOptOut() { + for (const enrollment of this.store + .getAll() + .filter(e => e.active && !e.isFirefoxLabsOptIn)) { this._unenroll( enrollment, UnenrollmentCause.fromReason( @@ -934,16 +948,6 @@ export class ExperimentManager { ) ); } - for (const enrollment of this.store.getAllActiveRollouts()) { - this._unenroll( - enrollment, - UnenrollmentCause.fromReason( - lazy.NimbusTelemetry.UnenrollReason.STUDIES_OPT_OUT - ) - ); - } - - this.optInRecipes = []; } /** diff --git a/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs b/toolkit/components/nimbus/lib/RemoteSettingsExperimentLoader.sys.mjs @@ -114,6 +114,7 @@ export const MatchStatus = Object.freeze({ TARGETING_ONLY: "TARGETING_ONLY", TARGETING_AND_BUCKETING: "TARGETING_AND_BUCKETING", UNENROLLED_IN_ANOTHER_PROFILE: "UNENROLLED_IN_ANOTHER_PROFILE", + DISABLED: "DISABLED", }); export const CheckRecipeResult = { @@ -257,40 +258,39 @@ export class RemoteSettingsExperimentLoader { ); } - if (this._enabled) { - return; - } + if (!this._enabled) { + if (!lazy.ExperimentAPI.enabled) { + lazy.log.debug( + "Not enabling RemoteSettingsExperimentLoader: Nimbus disabled" + ); + return; + } - if (!lazy.ExperimentAPI.studiesEnabled) { - lazy.log.debug( - "Not enabling RemoteSettingsExperimentLoader: studies disabled" - ); - return; - } + if ( + Services.startup.isInOrBeyondShutdownPhase( + Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED + ) + ) { + lazy.log.debug( + "Not enabling RemoteSettingsExperimentLoader: shutting down" + ); + return; + } - if ( - Services.startup.isInOrBeyondShutdownPhase( - Ci.nsIAppStartup.SHUTDOWN_PHASE_APPSHUTDOWNCONFIRMED - ) - ) { - lazy.log.debug( - "Not enabling RemoteSettingsExperimentLoader: shutting down" - ); - return; - } + this.#shutdownBlocker = async () => { + await this.finishedUpdating(); + this.disable(); + }; - this.#shutdownBlocker = async () => { - await this.finishedUpdating(); - this.disable(); - }; + lazy.AsyncShutdown.appShutdownConfirmed.addBlocker( + "RemoteSettingsExperimentLoader: disabling", + this.#shutdownBlocker + ); - lazy.AsyncShutdown.appShutdownConfirmed.addBlocker( - "RemoteSettingsExperimentLoader: disabling", - this.#shutdownBlocker - ); + this.setTimer(); - this.setTimer(); - this._enabled = true; + this._enabled = true; + } await this.updateRecipes("enabled", { forceSync }); } @@ -431,6 +431,8 @@ export class RemoteSettingsExperimentLoader { recipeValidator, { validationEnabled, + labsEnabled: lazy.ExperimentAPI.labsEnabled, + studiesEnabled: lazy.ExperimentAPI.studiesEnabled, shouldCheckTargeting: true, unenrolledExperimentSlugs, } @@ -768,19 +770,14 @@ export class RemoteSettingsExperimentLoader { } /** - * Handles feature status based on STUDIES_OPT_OUT_PREF. - * - * Changing this pref to false will turn off any recipe fetching and - * processing. + * Disable the RemoteSettingsExperimentLoader if Nimbus has become disabled + * and vice versa. */ async onEnabledPrefChange() { - if (this._enabled && !lazy.ExperimentAPI.studiesEnabled) { - this.disable(); - } else if (!this._enabled && lazy.ExperimentAPI.studiesEnabled) { - // If the feature pref is turned on then turn on recipe processing. - // If the opt in pref is turned on then turn on recipe processing only if - // the feature pref is also enabled. + if (lazy.ExperimentAPI.enabled) { await this.enable(); + } else { + this.disable(); } } @@ -823,7 +820,7 @@ export class RemoteSettingsExperimentLoader { * immediately. */ finishedUpdating() { - if (!lazy.ExperimentAPI.studiesEnabled || !this._enabled) { + if (!lazy.ExperimentAPI.enabled || !this._enabled) { return Promise.resolve(); } @@ -912,12 +909,17 @@ export class EnrollmentsContext { validationEnabled = true, shouldCheckTargeting = true, unenrolledExperimentSlugs, + studiesEnabled = true, + labsEnabled = true, } = {} ) { this.manager = manager; this.recipeValidator = recipeValidator; this.validationEnabled = validationEnabled; + this.studiesEnabled = studiesEnabled; + this.labsEnabled = labsEnabled; + this.validatorCache = {}; this.shouldCheckTargeting = shouldCheckTargeting; this.unenrolledExperimentSlugs = unenrolledExperimentSlugs; @@ -951,6 +953,13 @@ export class EnrollmentsContext { } } + if ( + (recipe.isFirefoxLabsOptIn && !this.labsEnabled) || + (!recipe.isFirefoxLabsOptIn && !this.studiesEnabled) + ) { + return CheckRecipeResult.Ok(MatchStatus.DISABLED); + } + // We don't include missing features here because if validation is enabled we report those errors later. const unsupportedFeatureIds = recipe.featureIds.filter( featureId => diff --git a/toolkit/components/nimbus/lib/Telemetry.sys.mjs b/toolkit/components/nimbus/lib/Telemetry.sys.mjs @@ -81,6 +81,7 @@ const UnenrollReason = Object.freeze({ CHANGED_PREF: "changed-pref", FORCE_ENROLLMENT: "force-enrollment", INDIVIDUAL_OPT_OUT: "individual-opt-out", + LABS_DIABLED: "labs-disabled", LABS_OPT_OUT: "labs-opt-out", PREF_FLIPS_CONFLICT: "prefFlips-conflict", PREF_FLIPS_FAILED: "prefFlips-failed", diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js b/toolkit/components/nimbus/test/browser/browser_remotesettings_experiment_enroll.js @@ -45,7 +45,11 @@ add_task(async function test_experimentEnrollment_startup() { set: [["app.shield.optoutstudies.enabled", false]], }); - Assert.ok(!ExperimentAPI._rsLoader._enabled, "Should be disabled"); + Assert.ok(ExperimentAPI.enabled, "ExperimentAPI is still enabled"); + Assert.ok(ExperimentAPI._rsLoader._enabled, "RemoteSettingsExperimentLoader is still enabled"); + + Assert.ok(!ExperimentAPI.studiesEnabled, "Studies disabled"); + Assert.ok(ExperimentAPI.labsEnabled, "Firefox Labs enabled"); await SpecialPowers.pushPrefEnv({ set: [["app.shield.optoutstudies.enabled", true]], diff --git a/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js b/toolkit/components/nimbus/test/browser/browser_remotesettingsexperimentloader_remote_defaults.js @@ -87,14 +87,21 @@ add_task(async function test_remote_fetch_and_ready() { "This prop does not exist before we sync" ); + await SpecialPowers.pushPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], + ["app.shield.optoutstudies.enabled", true] + ] + }); + await ExperimentAPI.ready(); + await ExperimentAPI._rsLoader.finishedUpdating(); const { cleanup } = await setup(); // Fake being initialized so we can update recipes // we don't need to start any timers - ExperimentAPI._rsLoader._enabled = true; - await ExperimentAPI._rsLoader.updateRecipes("browser_rsel_remote_defaults"); + await ExperimentAPI._rsLoader.updateRecipes("test"); Assert.equal( NimbusFeatures.foo.getVariable("remoteValue"), @@ -210,6 +217,8 @@ add_task(async function test_remote_fetch_and_ready() { await cleanup(); cleanupTestFeatures(); + + await SpecialPowers.popPrefEnv(); }); add_task(async function test_remote_fetch_on_updateRecipes() { @@ -239,6 +248,7 @@ add_task(async function test_remote_fetch_on_updateRecipes() { Assert.ok(updateRecipesStub.calledOnce, "Timer calls function"); Assert.equal(updateRecipesStub.firstCall.args[0], "timer", "Called by timer"); sandbox.restore(); + await SpecialPowers.popPrefEnv(); // This will un-register the timer ExperimentAPI._rsLoader.disable(); Services.prefs.clearUserPref( @@ -435,6 +445,14 @@ add_task(async function remote_defaults_active_remote_defaults() { ); const { cleanup } = await setup([rollout1, rollout2]); + + SpecialPowers.pushPrefEnv({ + set: [ + ["datareporting.healthreport.uploadEnabled", true], + ["app.shield.optoutstudies.enabled", true], + ] + }); + await ExperimentAPI._rsLoader.updateRecipes("mochitest"); Assert.ok(barFeature.getVariable("enabled"), "Enabled on first sync"); @@ -451,6 +469,8 @@ add_task(async function remote_defaults_active_remote_defaults() { ExperimentAPI.manager.store._deleteForTests("bar"); await NimbusTestUtils.flushStore(); + await SpecialPowers.popPrefEnv(); + await cleanup(); cleanupTestFeatures(); }); diff --git a/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js b/toolkit/components/nimbus/test/unit/test_RemoteSettingsExperimentLoader.js @@ -63,17 +63,21 @@ add_task(async function test_init_with_opt_in() { await initExperimentAPI(); - equal( + Assert.equal( loader.setTimer.callCount, - 0, - `should not initialize if ${STUDIES_OPT_OUT_PREF} pref is false` + 1, + `should initialize even if ${STUDIES_OPT_OUT_PREF} pref is false` ); - Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); - Assert.ok(loader.setTimer.calledOnce, "should call .setTimer"); - Assert.ok(loader.updateRecipes.calledOnce, "should call .updateRecipes"); + Assert.equal( + loader.updateRecipes.callCount, + 1, + "should call updateRecipes()" + ); await cleanup(); + + Services.prefs.setBoolPref(STUDIES_OPT_OUT_PREF, true); }); add_task(async function test_updateRecipes() { diff --git a/toolkit/components/nimbus/test/unit/test_policy.js b/toolkit/components/nimbus/test/unit/test_policy.js @@ -8,18 +8,43 @@ const { EnterprisePolicyTesting } = ChromeUtils.importESModule( "resource://testing-common/EnterprisePolicyTesting.sys.mjs" ); +const RECIPES = [ + NimbusTestUtils.factories.recipe.withFeatureConfig("experiment", { + featureId: "no-feature-firefox-desktop", + }), + NimbusTestUtils.factories.recipe.withFeatureConfig( + "rollout", + { featureId: "no-feature-firefox-desktop" }, + { isRollout: true } + ), + NimbusTestUtils.factories.recipe.withFeatureConfig( + "optin", + { featureId: "no-feature-firefox-desktop" }, + { + isRollout: true, + isFirefoxLabsOptIn: true, + firefoxLabsTitle: "title", + firefoxLabsDescription: "description", + firefoxLabsGroup: "group", + requiresRestart: false, + } + ), +]; + add_setup(function setup() { // Instantiate the enterprise policy service. void Cc["@mozilla.org/enterprisepolicies;1"].getService(Ci.nsIObserver); }); -add_task(async function testPolicyDisablesNimbus() { +async function doTest({ + policies, + labsEnabled, + studiesEnabled, + expectedEnrollments, + expectedOptIns, +}) { info("Enabling policy"); - await EnterprisePolicyTesting.setupPolicyEngineWithJson({ - policies: { - DisableFirefoxStudies: true, - }, - }); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ policies }); info("Is policy engine active?"); Assert.equal( @@ -28,28 +53,95 @@ add_task(async function testPolicyDisablesNimbus() { "Policy engine is active" ); - const loader = NimbusTestUtils.stubs.rsLoader(); - const manager = loader.manager; - await manager.store.init(); - await manager.onStartup(); + const { initExperimentAPI, cleanup, loader } = + await NimbusTestUtils.setupTest({ + init: false, + experiments: RECIPES, + }); - Assert.ok(!manager.studiesEnabled, "ExperimentManager is disabled"); + sinon.spy(loader, "updateRecipes"); + sinon.spy(loader, "setTimer"); - const setTimerStub = sinon.stub(loader, "setTimer"); - const updateRecipes = sinon.stub(loader, "updateRecipes"); + await initExperimentAPI(); - await loader.enable(); + Assert.equal( + ExperimentAPI.studiesEnabled, + studiesEnabled, + "Studies are enabled" + ); + Assert.equal( + ExperimentAPI.labsEnabled, + labsEnabled, + "FirefoxLabs is enabled" + ); + + Assert.equal( + loader._enabled, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader initialized" + ); + + Assert.equal( + loader.setTimer.called, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader polling for recipes" + ); - Assert.ok( - !loader._initialized, - "RemoteSettingsExperimentLoader not initailized" + Assert.equal( + loader.updateRecipes.called, + studiesEnabled || labsEnabled, + "RemoteSettingsExperimentLoader polling for recipes" ); - Assert.ok( - setTimerStub.notCalled, - "RemoteSettingsExperimentLoader not polling for recipes" + + Assert.deepEqual( + ExperimentAPI.manager.store + .getAll() + .filter(e => e.active) + .map(e => e.slug) + .sort(), + expectedEnrollments.sort(), + "Should have expected enrollments" ); - Assert.ok( - updateRecipes.notCalled, - "RemoteSettingsExperimentLoader not updating recipes after startup" + + Assert.deepEqual( + ExperimentAPI.manager.optInRecipes.map(e => e.slug).sort(), + expectedOptIns, + "Should have expected available opt-ins" ); + + await NimbusTestUtils.cleanupManager(expectedEnrollments); + await cleanup(); +} + +add_task(async function testDisableStudiesPolicy() { + await doTest({ + policies: { DisableFirefoxStudies: true }, + labsEnabled: true, + studiesEnabled: false, + expectedEnrollments: [], + expectedOptIns: ["optin"], + }); +}); + +add_task(async function testDisableLabsPolicy() { + await doTest({ + policies: { UserMessaging: { FirefoxLabs: false } }, + labsEnabled: false, + studiesEnabled: true, + expectedEnrollments: ["experiment", "rollout"], + expectedOptIns: [], + }); +}); + +add_task(async function testNimbusDisabled() { + await doTest({ + policies: { + DisableFirefoxStudies: true, + UserMessaging: { FirefoxLabs: false }, + }, + labsEnabled: false, + studiesEnabled: false, + expectedEnrollments: [], + expectedOptIns: [], + }); });