# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at http://mozilla.org/MPL/2.0/. import json import sys from pathlib import Path from urllib.parse import urlparse import jsonschema import yaml from jsonschema.validators import validator_for HEADER_LINE = ( "// This file was generated by generate_feature_manifest.py from FeatureManifest.yaml." " DO NOT EDIT.\n" ) FEATURE_SCHEMA = Path("schemas", "ExperimentFeature.schema.json") NIMBUS_FALLBACK_PREFS = ( "constinit std::array NIMBUS_FALLBACK_PREFS = " "std::to_array>({{\n" "{entries}\n" "}});" ) NIMBUS_PLATFORM_FEATURES = ( "#ifdef MOZ_DIAGNOSTIC_ASSERT_ENABLED\n" "constinit static std::array NIMBUS_PLATFORM_FEATURES = " "std::to_array>({{\n" "{entries}\n" "}});\n" "#endif // MOZ_DIAGNOSTIC_ASSERT_ENABLED\n" ) # Do not add new feature IDs to this list! isEarlyStartup is being deprecated. # # In most cases new features should use setPref to read feature values from # platform code. # # However, if the platform feature API is required, then the feature can be # added to this list. # # See https://bugzilla.mozilla.org/show_bug.cgi?id=1875331 for details. ALLOWED_ISEARLYSTARTUP_FEATURE_IDS = { "aboutwelcome", "bounceTrackingProtection", "newtab", "opaqueResponseBlocking", # Requires C++ API for exposure events "pocketNewtab", "preonboarding", "testFeature", # Test-only feature "testPlatformExposureFeature", # Test-only feature "upgradeDialog", } DISALLOWED_PREFS = { # Disabling either of these prefs will cause unenrollment from all active # enrollments, which will then cause the pref to reset. "app.shield.optoutstudies.enabled": ( "disabling Nimbus causes immediate unenrollment" ), "datareporting.healthreport.uploadEnabled": ( "disabling telemetry causes immediate unenrollment" ), # Changing the Remote Settings endpoint will cause unenrollment from the recipe. "services.settings.server": ( "changing the Remote Settings endpoint will break clients" ), "messaging-system.rsexperimentloader.collection": ( "changing the Nimbus collection will break clients" ), "nimbus.debug": "internal Nimbus preference for QA", # This pref controls the return value of xpc::IsInAutomation(), which is # used by code to check if we are in a test. "security.turn_off_all_security_so_that_viruses_can_take_over_this_computer": ( "this pref is automation-only and is unsafe to enable outside tests" ), "security.sandbox.content.level": ( "changing this value can lower the security of clients" ), } def validate_feature_manifest(schema_path, manifest_path, manifest): TOPSRCDIR = Path(__file__).parent.parent.parent.parent.parent with open(schema_path) as f: schema = json.load(f) set_prefs = {} fallback_prefs = {} for feature_id, feature in manifest.items(): try: jsonschema.validate(feature, schema) is_early_startup = feature.get("isEarlyStartup", False) allowed_is_early_startup = feature_id in ALLOWED_ISEARLYSTARTUP_FEATURE_IDS if is_early_startup != allowed_is_early_startup: if is_early_startup: print(f"Feature {feature_id} is marked isEarlyStartup: true") print( "isEarlyStartup is deprecated and new isEarlyStartup features should not be added" ) print( "Either remove isEarlyStartup: true or add to the override list in " "generate_feature_manifest.py" ) print( "See https://bugzilla.mozilla.org/show_bug.cgi?id=1875331 for details" ) raise Exception("isEarlyStartup is deprecated") else: print( f"Feature {feature_id} is not early startup but is in the allow list." ) print("Please remove it from generate_feature_manifest.py") raise Exception("isEarlyStartup is deprecated") if is_early_startup and feature.get("allowCoenrollment", False): raise Exception( "A feature cannot use isEarlyStartup and allowCoenrollment" ) for variable, variable_def in feature.get("variables", {}).items(): set_pref = variable_def.get("setPref", {}).get("pref") if set_pref is None: continue if reason := DISALLOWED_PREFS.get(set_pref): raise Exception( f"Pref {set_pref} cannot be controlled by Nimbus: {reason}" ) if feature.get("allowCoenrollment", False): raise Exception("A feature cannot use co-enrollment and setPref") if set_pref in set_prefs: other_feature = set_prefs[set_pref][0] other_variable = set_prefs[set_pref][1] print("Multiple variables cannot declare the same setPref") print( f"{feature_id} variable {variable} wants to set pref {set_pref}" ) print( f"{other_feature} variable {other_variable} wants to set pref " f"{set_pref}" ) raise Exception("Set prefs are exclusive") set_prefs[set_pref] = (feature_id, variable) fallback_pref = variable_def.get("fallbackPref") if fallback_pref is not None: fallback_prefs[fallback_pref] = (feature_id, variable) conflicts = [ ( "setPref", fallback_pref, "fallbackPref", set_prefs.get(fallback_pref), ), ("fallbackPref", set_pref, "setPref", fallback_prefs.get(set_pref)), ] for kind, pref, other_kind, conflict in conflicts: if conflict is not None: print( "The same pref cannot be specified in setPref and fallbackPref" ) print( f"{feature_id} variable {variable} has specified {kind} {pref}" ) print( f"{conflict[0]} variable {conflict[1]} has specified {other_kind} " f"{pref}" ) raise Exception("Set prefs and fallback prefs cannot overlap") if "schema" in feature: schema_path = TOPSRCDIR / feature["schema"]["path"] if not schema_path.exists(): raise Exception(f"Schema does not exist at {schema_path}") with schema_path.open() as f: try: schema_contents = json.load(f) except Exception as e: raise Exception(f"Cannot parse schema for {feature_id}") from e try: validator_for(schema_contents).check_schema(schema_contents) except Exception as e: raise Exception(f"Invalid schema for {feature_id}") from e uri = urlparse(feature["schema"]["uri"]) if uri.scheme not in ("resource", "chrome"): raise Exception( "Only resource:// and chrome:// URIs are supported for schemas" ) except Exception as e: print("Error while validating FeatureManifest.yaml") print(f"On key: {feature_id}") print(f"Input file: {manifest_path}") raise e def generate_feature_manifest(fd, input_file): try: with open(input_file, encoding="utf-8") as f: manifest = yaml.safe_load(f) except OSError as e: print(f"{input_file}: error:\n {e}\n") sys.exit(1) validate_feature_manifest( Path(input_file).parent / FEATURE_SCHEMA, input_file, manifest ) contents = ( f"{HEADER_LINE}\n" f"export const FeatureManifest = {json.dumps(manifest, indent=2)}" ) try: fd.write(contents) except OSError as e: print(f"{input_file}: error:\n {e}\n") sys.exit(1) def generate_platform_feature_array(features): def render_bool(b): if b: return "true" return "false" # Features have to be tagged isEarlyStartup to be accessible # to Nimbus platform API entries = [ f' std::make_tuple("{feature_id}"_ns, {render_bool(feature_data["hasExposure"])})' for (feature_id, feature_data) in features.items() if feature_data.get("isEarlyStartup", False) ] return NIMBUS_PLATFORM_FEATURES.format(entries=",\n".join(entries)) def generate_platform_fallback_pref_array(features): entries = [ f' {{ "{feature_id}_{variable}"_ns, "{variable_data["fallbackPref"]}"_ns }}' for (feature_id, feature_data) in features.items() if feature_data.get("isEarlyStartup", False) for (variable, variable_data) in feature_data.get("variables", {}).items() if variable_data.get("fallbackPref", False) ] return NIMBUS_FALLBACK_PREFS.format(entries=",\n".join(entries)) def generate_platform_feature_manifest(fd, input_file): try: with open(input_file, encoding="utf-8") as yaml_input: data = yaml.safe_load(yaml_input) except OSError as e: print(f"{input_file}: error:\n {e}\n") sys.exit(1) contents = "\n".join([ HEADER_LINE, "#ifndef mozilla_NimbusFeatureManifest_h", "#define mozilla_NimbusFeatureManifest_h", "", "#include ", "#include ", "", '#include "mozilla/Maybe.h"', '#include "nsStringFwd.h"', "", "namespace mozilla::nimbus {", "", f"{generate_platform_feature_array(data)}", "", f"{generate_platform_fallback_pref_array(data)}", "", "} // namespace mozilla::nimbus", "", "#endif // mozilla_NimbusFeatureManifest_h\n", ]) try: fd.write(contents) except OSError as e: print(f"{input_file}: error:\n {e}\n") sys.exit(1)