Source code

Revision control

Copy as Markdown

Other Tools

# 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 https://mozilla.org/MPL/2.0/.
import json
import os
import re
import shutil
import unicodedata
from functools import cache
import jsonschema
import mozpack.path as mozpath
from mozpack.files import FileFinder
@cache
def interventions_schema():
schema_path = mozpath.join(
os.path.dirname(__file__),
"intervention_schema.json",
)
with open(schema_path) as schema_fd:
return json.load(schema_fd)
def load_intervention_json(json_fd):
try:
config = json.load(json_fd)
except json.decoder.JSONDecodeError as e:
raise ValueError(f"{mozpath.basename(json_fd.path)} is invalid JSON: {e}")
try:
jsonschema.validate(instance=config, schema=interventions_schema())
except jsonschema.exceptions.ValidationError as e:
raise ValueError(
f"{mozpath.basename(json_fd.path)} is invalid intervention JSON: {e}"
)
return config
def clear_dir(path):
if not os.path.isdir(path) and not os.path.islink(path):
raise Exception(f"{path} is not a directory")
for filename in os.listdir(path):
file_path = mozpath.join(path, filename)
try:
if os.path.isfile(file_path) or os.path.islink(file_path):
os.unlink(file_path)
elif os.path.isdir(file_path):
shutil.rmtree(file_path)
except Exception as e:
print(f"Failed to delete {file_path}. Reason: {e}")
def safe_filename(raw):
subbed = re.sub(
"_+",
"_",
raw
.replace(" ", "_")
.replace("\\", "_")
.replace("/", "_")
.replace(os.path.sep, "_")
.replace("(", "")
.replace(")", ""),
)
normalized = unicodedata.normalize("NFD", subbed)
return "".join([c for c in normalized if not unicodedata.combining(c)])
def maybe_lstrip(str, value):
if str.startswith(str):
return str[len(value) :].lstrip()
return str
def clean_script_template(script, filename):
# drop any license header, linter globals line, and "use strict".
script = script.rstrip()
while True:
script = script.lstrip()
if script.startswith("/*"):
script = script.partition("*/")[2]
elif script.startswith("//"):
script = script.partition("\n")[2]
elif script.startswith('"use strict";'):
script = script[13:]
else:
break
if not script:
raise ValueError(f"{filename} template does not seem to be a proper template")
if (
not script.startswith("{")
and not script.startswith("try")
and not script.startswith("if")
):
script = "\n ".join(script.splitlines())
script = f"{{\n {script}\n}}"
return script
SPECIAL_META_KEYS = ["all_frames", "match_origin_as_fallback", "user_styles"]
class special_js_script_checker:
def check(self, intervention, src_json_filename):
data = intervention.pop(self.section, None)
if data is None:
return None, None
metas = {name: False for name in SPECIAL_META_KEYS}
if type(data) is dict:
for key in SPECIAL_META_KEYS:
metas[key] = data.get(key, False)
return metas, self._get_params_from_data(data, src_json_filename)
class check_hide_alerts_section(special_js_script_checker):
def __init__(self):
self.section = "hide_alerts"
self.json_key = "alerts"
self.source = "hide_alerts.js"
def _get_params_from_data(self, data, src_json_filename):
if type(data) is list:
alertsToHide = data
elif type(data) is dict and "alerts" in data:
alertsToHide = data["alerts"]
else:
raise ValueError(
f"Unexpected data in {self.section} in {src_json_filename}: {str(data)}"
)
for alert in alertsToHide:
if alert.lower() != alert:
raise ValueError(
f"Please use lowercase values for {self.section} values (not `{alert}`) in {src_json_filename}"
)
return {"alertsToHide": alertsToHide}
class check_hide_messages_section(special_js_script_checker):
def __init__(self):
self.section = "hide_messages"
self.json_key = "messages"
self.source = "hide_messages.js"
def _get_params_from_data(self, data, src_json_filename):
if type(data) is list:
messagesToHide = data
elif type(data) is dict and "message" in data:
messagesToHide = [data]
elif type(data) is dict and "messages" in data:
messagesToHide = data["messages"]
else:
raise ValueError(
f"Unexpected data in {self.section} in {src_json_filename}: {str(data)}"
)
return {"messagesToHide": messagesToHide}
class check_modify_meta_viewport_section(special_js_script_checker):
def __init__(self):
self.section = "modify_meta_viewport"
self.json_key = "modify"
self.source = "modify_meta_viewport.js"
def _get_params_from_data(self, data, src_json_filename):
if type(data) is dict and "modify" in data:
metaViewportChanges = data["modify"]
elif type(data) is dict:
metaViewportChanges = data
else:
raise ValueError(
f"Unexpected data in {self.section} in {src_json_filename}: {str(data)}"
)
return {"metaViewportChanges": metaViewportChanges}
def bake_params_into_script_template(template, params={}):
for name, value in params.items():
template = template.replace(
f'"param:{name}"', json.dumps(value, sort_keys=True)
)
return template
@cache
def get_script_template(filename, *dirs_to_try):
for dir in dirs_to_try:
path_to_try = mozpath.join(dir, filename)
if os.path.isfile(path_to_try) and os.access(path_to_try, os.R_OK):
with open(path_to_try) as template_fd:
return clean_script_template(template_fd.read(), filename)
raise ValueError(
f"Could not access expected template {filename} in {' or '.join(dirs_to_try)}"
)
def build_logger_script(config, templates_dir):
domain_to_bug_numbers = {}
for bug_number, data in config.get("bugs", {}).items():
for match in data.get("matches", []):
domain = match.partition("://")[2].split("/")[0].replace("*.", "")
domain_to_bug_numbers.setdefault(domain, set())
domain_to_bug_numbers[domain].add(bug_number)
bugInfo = [[k, sorted(list(v))] for k, v in domain_to_bug_numbers.items()]
return bake_params_into_script_template(
get_script_template("log_console_message.js", templates_dir),
{"bugInfo": bugInfo},
)
def determine_generated_content_scripts_for_intervention(
config, bug_number, src_json_filename, interventions_dir
):
injections_dir = mozpath.normpath(
mozpath.join(
interventions_dir,
"..",
"..",
"injections",
)
)
return determine_generated_css_content_scripts_for_intervention(
config, bug_number, src_json_filename
) | determine_generated_js_content_scripts_for_intervention(
config, bug_number, src_json_filename, injections_dir
)
def determine_generated_js_content_scripts_for_intervention(
config, bug_number, src_json_filename, injections_dir
):
# The JSON files for interventions may used generic JS scripts, including
# special ones with special info in the JSON, like this:
#
# "interventions": [
# {
# "hide_messages": [{ "container": ".header.caution", "message": "unsupported browser"}],
# "hide_alerts": { "all_frames": true, "alerts: ["Chrome"] },
# "modify_meta_viewport": {
# "interactive-widget": "resizes-content",
# },
# "content_scripts": {
# "js": ["use_chrome_useragent.js"]
# }
# },
#
# We want to combine these into one final content-scripts:
#
# {
# "content_scripts": {
# "js": ["injections/generated/bug12345_whatever.com.js"]
# }
# },
files_to_generate = {}
generated_filenames_cache = {}
special_checkers = [
check_hide_alerts_section(),
check_hide_messages_section(),
check_modify_meta_viewport_section(),
]
label = safe_filename(config["label"])
for intervention in config["interventions"]:
final_metas = None
content_scripts = intervention.get("content_scripts", {})
if content_scripts:
final_metas = {}
for key in SPECIAL_META_KEYS:
final_metas[key] = content_scripts.get(key, False)
js = content_scripts.get("js", [])
generate_from_sources = []
if js and any(not script_filename.startswith("bug") for script_filename in js):
generate_from_sources = [
{
"params": {},
"source": script_filename,
}
for script_filename in js
]
for checker in special_checkers:
metas, params = checker.check(intervention, src_json_filename)
if not metas and not params:
continue
if final_metas is None:
final_metas = metas
elif final_metas != metas:
raise ValueError(
f"cannot mix true/false values of {SPECIAL_META_KEYS} in the same intervention in {src_json_filename}"
)
generate_from_sources.append({
"params": params,
"source": checker.source,
})
if generate_from_sources:
cache_key = json.dumps(generate_from_sources, sort_keys=True)
generated_filename = generated_filenames_cache.get(cache_key, None)
if not generated_filename:
suffix = ""
next_generated_script_num = len(generated_filenames_cache)
if next_generated_script_num:
suffix = f"-{next_generated_script_num}"
generated_filename = safe_filename(
f"bug{bug_number}-{label}{suffix}.js"
)
generated_filenames_cache[cache_key] = generated_filename
content_scripts = intervention.setdefault("content_scripts", {})
js = content_scripts.setdefault("js", [])
content_scripts["js"] = [f"injections/generated/{generated_filename}"]
for name, value in final_metas.items():
if value:
content_scripts[name] = True
files_to_generate[generated_filename] = generate_from_sources
return files_to_generate
def determine_generated_css_content_scripts_for_intervention(
config, bug_number, src_json_filename
):
# The JSON files for interventions may contain css sections like this:
#
# "label": "whatever.com",
# "css": {
# "fix_broken_slider": "css text 1",
# "remove_extra_scrollbars": "css text 2"
# },
# "interventions": [
# {
# "css": ["fix_broken_slider"]
# },
# {
# "css": {
# "all_frames": true,
# "match_origin_as_fallback": true,
# "user_styles": true, // cssOrigin = "user" (otherwise is default of "author")
# "which": ["fix_broken_slider", "remove_extra_scrollbars"]
# }
# }
# ]
#
# These must be replaced with corresponding content_scripts sections while building the
# final run.js (and the files it references must also be generated):
#
# "label": "whatever.com",
# "interventions": [
# {
# "content_scripts": {
# "css": ["injections/generated/bug12345_whatever.com_fix_broken_slider.css"]
# }
# },
# {
# "content_scripts": {
# "all_frames": true,
# "match_origin_as_fallback": true,
# "css": ["injections/generated/bug12345_whatever.com_fix_broken_slider.css",
# "injections/generated/bug12345_whatever.com_remove_extra_scrollbars.css"]
# }
# }
# ]
files_to_generate = {}
if "css" in config and (type(config["css"]) is not dict or not config["css"]):
raise ValueError(
f"css section should be a non-empty object or be removed from {src_json_filename}"
)
css_files = config.pop("css", None)
if not css_files:
for intervention in config["interventions"]:
if intervention.get("css"):
raise ValueError(
f"css wanted, but none specified for {src_json_filename}"
)
return files_to_generate
actually_used_files = set()
for cssText in css_files.values():
if type(cssText) is not str or not cssText:
raise ValueError(
f"css text should be a non-empty string in {src_json_filename}"
)
label = safe_filename(config["label"])
for intervention in config["interventions"]:
css = intervention.pop("css", None)
if css is None:
continue
if type(css) is list:
css = {"which": css}
elif type(css) is not dict:
raise ValueError(
f"css sections should be a non-empty object or list or be removed from interventions in {src_json_filename}"
)
which_css_files_to_add = css.pop("which", None)
if not which_css_files_to_add or type(which_css_files_to_add) is not list:
raise ValueError(
f"intervention with missing `which` key or invalid array of desired css files in {src_json_filename}"
)
for file in which_css_files_to_add:
if type(file) is not str or not file:
raise ValueError(
f"Empty or non-string filename not listed in intervention css section of {src_json_filename}"
)
if not css_files.get(file):
raise ValueError(
f"{file} is not listed in css section of {src_json_filename}"
)
actually_used_files.add(file)
metas = {}
for key in SPECIAL_META_KEYS:
metas[key] = css.pop(key, False)
if not isinstance(metas[key], bool):
raise ValueError(
f"{key} must be `true` or `false` in {src_json_filename}"
)
unknown_keys = "','".join(css.keys())
if unknown_keys:
raise ValueError(
f"unknown key(s) '{unknown_keys}' in css section of {src_json_filename}"
)
content_scripts = intervention.get("content_scripts", None)
if content_scripts:
for key in SPECIAL_META_KEYS:
if content_scripts.get(key, False) != metas[key]:
raise ValueError(
f"cannot mix value of {key} in css and content_scripts sections in {src_json_filename}"
)
content_scripts = intervention.setdefault("content_scripts", {})
css = content_scripts.setdefault("css", [])
for filename in which_css_files_to_add:
final_filename = safe_filename(f"bug{bug_number}-{label}-{filename}.css")
css.append(f"injections/generated/{final_filename}")
files_to_generate[final_filename] = [{"contents": css_files[filename]}]
for key in SPECIAL_META_KEYS:
if metas[key]:
content_scripts[key] = True
extras = set(css_files.keys()).difference(actually_used_files)
if extras:
raise ValueError(
f"Extra css fragments specified which aren't used in {src_json_filename}: "
+ ", ".join(list(extras))
)
return files_to_generate
def generate_run_js(
output_fd,
template_path,
interventions_dir,
*_preprocessed_intervention_files_mozbuild,
):
preprocessed_intervention_files_mozbuild = [
f
for f in _preprocessed_intervention_files_mozbuild
if not f.endswith("/codegen.py")
]
with open(template_path) as template_fd:
input_files = list(FileFinder(interventions_dir).find("*.json"))
filenames_json_files_expect_to_generate = set()
final_interventions = {}
actually_referenced_non_generated_files = set()
for json_filename, json_fd in input_files:
bug_number = mozpath.splitext(mozpath.basename(json_filename))[0].split(
"-"
)[0]
config = load_intervention_json(json_fd)
final_interventions[bug_number] = config
# Do some sanity checks first
listed_bugs = config.get("bugs", {}).keys()
if len(listed_bugs) < 2 and bug_number.split("_")[0] not in listed_bugs:
raise ValueError(
f"Bug number in the filename ({bug_number}) does not match bugs section of {json_filename}"
)
for intervention in config["interventions"]:
content_scripts = intervention.get("content_scripts", {})
listed_files = set()
for type in ["css", "js"]:
for non_generated_filename in content_scripts.get(type, []):
actually_referenced_non_generated_files.add(
non_generated_filename
)
if f"injections/{type}/" in non_generated_filename:
raise ValueError(
f"Please remove the unneeded 'injections/{type}/' from '{non_generated_filename}' intervention in {json_filename}"
)
if not non_generated_filename.endswith(f".{type}"):
raise ValueError(
f"{non_generated_filename} does not end in .{type} in {json_filename}"
)
if non_generated_filename in listed_files:
raise ValueError(
f"{non_generated_filename} is listed twice in same intervention in {json_filename}"
)
listed_files.add(non_generated_filename)
actual_path = mozpath.normpath(
mozpath.join(
interventions_dir,
"..",
"..",
"injections",
type,
non_generated_filename,
)
)
if not os.path.isfile(actual_path) or not os.access(
actual_path, os.R_OK
):
raise ValueError(
f"{non_generated_filename} is not an accessible file in {json_filename} (expected at {actual_path})"
)
if os.path.splitext(actual_path)[1] != "." + type:
raise ValueError(
f"File extension for {actual_path} should be .{type} in {json_filename}"
)
# Now remove each css section in this JSON and ensure that a corresponding
# content_scripts section exists, with the files named as they will be after
# they are generated by generate_css_intervention. Also double-check that we
# will not be stomping over any already-existing non-generated files.
generated_files = determine_generated_content_scripts_for_intervention(
config, bug_number, json_filename, interventions_dir
)
for filename in generated_files:
filenames_json_files_expect_to_generate.add(filename)
# Halt if preprocessed_intervention_files.mozbuild needs to be updated
filenames_listed_in_mozbuild = set(preprocessed_intervention_files_mozbuild)
extra_generated_files = sorted(
filenames_json_files_expect_to_generate.difference(
filenames_listed_in_mozbuild
)
)
missing_generated_files = sorted(
filenames_listed_in_mozbuild.difference(
filenames_json_files_expect_to_generate
)
)
if extra_generated_files or missing_generated_files:
msg = ""
if missing_generated_files:
msg += "\nPlease remove: " + ", ".join(missing_generated_files)
if extra_generated_files:
msg += "\nPlease add: " + ", ".join(extra_generated_files)
raise ValueError(
"preprocessed_intervention_files.mozbuild is out of date:" + msg
)
# Check if any non-generated css/js files aren't being used anymore.
non_generated_files_path = mozpath.normpath(
mozpath.join(interventions_dir, "..", "..", "injections")
)
non_generated_files = set(
name
for name, _ in FileFinder(
mozpath.join(non_generated_files_path, "css")
).find("*")
)
non_generated_files.update(
set(
name
for name, _ in FileFinder(
mozpath.join(non_generated_files_path, "js")
).find("*")
)
)
extra_non_generated_files = sorted(
non_generated_files.difference(actually_referenced_non_generated_files)
)
# Generic css/js files are bundled in case we need them for remote updates,
# so don't ask for them to be removed even if they seem unreferenced.
extra_non_generated_files = [
filename
for filename in extra_non_generated_files
if filename.startswith("bug")
]
if extra_non_generated_files:
raise ValueError(
"Please remove these files which are not referenced in any intervention JSON file: "
+ ", ".join(extra_non_generated_files)
)
# Emit the final run.json
interventions_json = json.dumps(
dict(sorted(final_interventions.items())), indent=2, sort_keys=True
)
raw = template_fd.read()
subbed = raw.replace(
"// Note that this variable is expanded during build-time. See bz2019069 for details.\n",
"",
)
subbed = raw.replace(
"AVAILABLE_INTERVENTIONS = {}",
f"AVAILABLE_INTERVENTIONS = {interventions_json}",
)
output_fd.write(subbed)
def generate_file(outfile, interventions_dir, *ignored):
desired_filename = mozpath.basename(outfile.name)
is_css = outfile.name.endswith(".css")
is_js = outfile.name.endswith(".js")
if not is_css and not is_js:
raise ValueError(f"Do not know how to generate {outfile.name}")
bug_number = (
mozpath
.splitext(mozpath.basename(desired_filename))[0]
.split("-")[0]
.lstrip("bug")
)
expected_json_filename = "-".join(
mozpath.splitext(desired_filename.lstrip("bug"))[0].split("-")[0:2]
)
json_files = list(
FileFinder(interventions_dir).find(f"{expected_json_filename}*.json")
)
if not json_files:
raise ValueError(
f"no json intervention file starting with {expected_json_filename}"
)
if len(json_files) > 1:
json_files = " or ".join(f[0] for f in json_files)
raise ValueError(
f"multiple json intervention files starting with {expected_json_filename}.. not sure which to use to generate {desired_filename} from {json_files}"
)
json_filename, json_fd = json_files[0]
config = load_intervention_json(json_fd)
generated_files = determine_generated_content_scripts_for_intervention(
config, bug_number, json_filename, interventions_dir
)
needed_parts = generated_files.get(desired_filename, None)
if needed_parts is None or not needed_parts:
raise ValueError(
f"No needed parts found to generate {desired_filename} for {json_filename}"
)
generated_parts = []
templates_dir = mozpath.normpath(
mozpath.join(
interventions_dir,
"..",
"..",
"templates",
)
)
js_injections_dir = mozpath.normpath(
mozpath.join(templates_dir, "..", "injections", "js")
)
for part_info in needed_parts:
if part_info.get("contents", None):
# css files go here
generated_parts.append(part_info["contents"])
continue
params = part_info["params"]
source_filename = part_info["source"]
template = get_script_template(
source_filename, templates_dir, js_injections_dir
)
generated_parts.append(bake_params_into_script_template(template, params))
if not generated_parts:
raise ValueError(
f"Failed to generate anything for {outfile.name} in {json_filename}"
)
all_parts = "\n\n".join(generated_parts)
if is_js:
if "__webcompat_spoof_platform" in all_parts:
all_parts += "\n\ndelete window.__webcompat_spoof_platform;"
if "window.__webcompat" in all_parts.replace("__webcompat_spoof_platform", ""):
all_parts += "\n\n" + build_logger_script(config, templates_dir)
outfile.write(
"/* THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. */\n\n"
)
outfile.write(all_parts)
def main(*args): # mach requires this
pass