summaryrefslogtreecommitdiffstats
path: root/testing/mozbase/mozproxy
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 14:29:10 +0000
commit2aa4a82499d4becd2284cdb482213d541b8804dd (patch)
treeb80bf8bf13c3766139fbacc530efd0dd9d54394c /testing/mozbase/mozproxy
parentInitial commit. (diff)
downloadfirefox-2aa4a82499d4becd2284cdb482213d541b8804dd.tar.xz
firefox-2aa4a82499d4becd2284cdb482213d541b8804dd.zip
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'testing/mozbase/mozproxy')
-rw-r--r--testing/mozbase/mozproxy/MANIFEST.in1
-rw-r--r--testing/mozbase/mozproxy/mozproxy/__init__.py45
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/__init__.py3
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/base.py37
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py8
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py228
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py162
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest10
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py490
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt35
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py4
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py308
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE27
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js71
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py85
-rw-r--r--testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py208
-rw-r--r--testing/mozbase/mozproxy/mozproxy/driver.py117
-rw-r--r--testing/mozbase/mozproxy/mozproxy/recordings.py163
-rw-r--r--testing/mozbase/mozproxy/mozproxy/server.py17
-rw-r--r--testing/mozbase/mozproxy/mozproxy/utils.py247
-rw-r--r--testing/mozbase/mozproxy/setup.py39
-rw-r--r--testing/mozbase/mozproxy/tests/__init__.py1
-rw-r--r--testing/mozbase/mozproxy/tests/archive.tar.gzbin0 -> 184 bytes
-rw-r--r--testing/mozbase/mozproxy/tests/example.dumpbin0 -> 494196 bytes
-rw-r--r--testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest10
-rw-r--r--testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zipbin0 -> 6588776 bytes
-rw-r--r--testing/mozbase/mozproxy/tests/files/recording.zipbin0 -> 384 bytes
-rw-r--r--testing/mozbase/mozproxy/tests/firefox1
-rw-r--r--testing/mozbase/mozproxy/tests/manifest.ini10
-rw-r--r--testing/mozbase/mozproxy/tests/paypal.mp1
-rw-r--r--testing/mozbase/mozproxy/tests/support.py15
-rw-r--r--testing/mozbase/mozproxy/tests/test_command_line.py137
-rw-r--r--testing/mozbase/mozproxy/tests/test_mitm_addons.py91
-rw-r--r--testing/mozbase/mozproxy/tests/test_proxy.py197
-rw-r--r--testing/mozbase/mozproxy/tests/test_recording.py69
-rw-r--r--testing/mozbase/mozproxy/tests/test_recordings.py35
-rw-r--r--testing/mozbase/mozproxy/tests/test_utils.py34
45 files changed, 2986 insertions, 0 deletions
diff --git a/testing/mozbase/mozproxy/MANIFEST.in b/testing/mozbase/mozproxy/MANIFEST.in
new file mode 100644
index 0000000000..c3527dc755
--- /dev/null
+++ b/testing/mozbase/mozproxy/MANIFEST.in
@@ -0,0 +1 @@
+recursive-include mozproxy *
diff --git a/testing/mozbase/mozproxy/mozproxy/__init__.py b/testing/mozbase/mozproxy/mozproxy/__init__.py
new file mode 100644
index 0000000000..4790eb73bf
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/__init__.py
@@ -0,0 +1,45 @@
+# 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/.
+
+from __future__ import absolute_import
+import sys
+import os
+
+
+def path_join(*args):
+ path = os.path.join(*args)
+ return os.path.abspath(path)
+
+
+mozproxy_src_dir = os.path.dirname(os.path.realpath(__file__))
+mozproxy_dir = path_join(mozproxy_src_dir, "..")
+mozbase_dir = path_join(mozproxy_dir, "..")
+
+# needed so unit tests can find their imports
+if os.environ.get("SCRIPTSPATH", None) is not None:
+ # in production it is env SCRIPTS_PATH
+ mozharness_dir = os.environ["SCRIPTSPATH"]
+else:
+ # locally it's in source tree
+ mozharness_dir = path_join(mozbase_dir, "..", "mozharness")
+
+
+def get_playback(config):
+ """Returns an instance of the right Playback class"""
+ sys.path.insert(0, mozharness_dir)
+ sys.path.insert(0, mozproxy_dir)
+ sys.path.insert(0, mozproxy_src_dir)
+
+ from .server import get_backend
+ from .utils import LOG
+
+ tool_name = config.get("playback_tool", None)
+ if tool_name is None:
+ LOG.critical("playback_tool name not found in config")
+ return None
+ try:
+ return get_backend(tool_name, config)
+ except KeyError:
+ LOG.critical("specified playback tool is unsupported: %s" % tool_name)
+ return None
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/__init__.py
new file mode 100644
index 0000000000..6fbe8159b2
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/__init__.py
@@ -0,0 +1,3 @@
+# 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/.
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/base.py b/testing/mozbase/mozproxy/mozproxy/backends/base.py
new file mode 100644
index 0000000000..dad6383e0d
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/base.py
@@ -0,0 +1,37 @@
+# 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/.
+
+from __future__ import absolute_import
+import six
+
+from abc import ABCMeta, abstractmethod
+
+
+# abstract class for all playback tools
+@six.add_metaclass(ABCMeta)
+class Playback(object):
+ def __init__(self, config):
+ self.config = config
+ self.host = None
+ self.port = None
+
+ @abstractmethod
+ def download(self):
+ pass
+
+ @abstractmethod
+ def setup(self):
+ pass
+
+ @abstractmethod
+ def start(self):
+ pass
+
+ @abstractmethod
+ def stop(self):
+ pass
+
+ @abstractmethod
+ def confidence(self):
+ pass
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py
new file mode 100644
index 0000000000..e82b658292
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/__init__.py
@@ -0,0 +1,8 @@
+# flake8: noqa
+# 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/.
+
+from __future__ import absolute_import
+
+from .mitm import *
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py
new file mode 100644
index 0000000000..bdd2921728
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/android.py
@@ -0,0 +1,228 @@
+"""Functions to download, install, setup, and use the mitmproxy playback tool"""
+# 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/.
+from __future__ import absolute_import
+
+import glob
+import os
+import subprocess
+import sys
+from subprocess import PIPE
+
+import mozinfo
+from mozproxy.backends.mitm.mitm import Mitmproxy
+from mozproxy.utils import download_file_from_url, tooltool_download, LOG
+
+# path for mitmproxy certificate, generated auto after mitmdump is started
+# on local machine it is 'HOME', however it is different on production machines
+try:
+ DEFAULT_CERT_PATH = os.path.join(
+ os.getenv("HOME"), ".mitmproxy", "mitmproxy-ca-cert.cer"
+ )
+except Exception:
+ DEFAULT_CERT_PATH = os.path.join(
+ os.getenv("HOMEDRIVE"),
+ os.getenv("HOMEPATH"),
+ ".mitmproxy",
+ "mitmproxy-ca-cert.cer",
+ )
+
+# On Windows, deal with mozilla-build having forward slashes in $HOME:
+if os.name == "nt" and "/" in DEFAULT_CERT_PATH:
+ DEFAULT_CERT_PATH = DEFAULT_CERT_PATH.replace("/", "\\")
+
+
+class MitmproxyAndroid(Mitmproxy):
+ def setup(self):
+ self.download_and_install_host_utils()
+ self.install_mitmproxy_cert(self.browser_path)
+
+ def download_and_install_host_utils(self):
+ """
+ If running locally:
+ 1. Will use the `certutil` tool from the local Firefox desktop build
+
+ If running in production:
+ 1. Get the tooltools manifest file for downloading hostutils (contains certutil)
+ 2. Get the `certutil` tool by downloading hostutils using the tooltool manifest
+ """
+ if self.config["run_local"]:
+ # when running locally, it is found in the Firefox desktop build (..obj../dist/bin)
+ self.certutil_path = os.path.join(os.environ["MOZ_HOST_BIN"], "certutil")
+ if not (
+ os.path.isfile(self.certutil_path)
+ and os.access(self.certutil_path, os.X_OK)
+ ):
+ raise Exception(
+ "Abort: unable to execute certutil: {}".format(self.certutil_path)
+ )
+ self.certutil_path = os.environ["MOZ_HOST_BIN"]
+ os.environ["LD_LIBRARY_PATH"] = self.certutil_path
+ else:
+ # must download certutil inside hostutils via tooltool; use this manifest:
+ # mozilla-central/testing/config/tooltool-manifests/linux64/hostutils.manifest
+ # after it will be found here inside the worker/bitbar container:
+ # /builds/worker/workspace/build/hostutils/host-utils-66.0a1.en-US.linux-x86_64
+ LOG.info("downloading certutil binary (hostutils)")
+
+ # get path to the hostutils tooltool manifest; was set earlier in
+ # mozharness/configs/raptor/android_hw_config.py, to the path i.e.
+ # mozilla-central/testing/config/tooltool-manifests/linux64/hostutils.manifest
+ # the bitbar container is always linux64
+ if os.environ.get("GECKO_HEAD_REPOSITORY", None) is None:
+ raise Exception("Abort: unable to get GECKO_HEAD_REPOSITORY")
+
+ if os.environ.get("GECKO_HEAD_REV", None) is None:
+ raise Exception("Abort: unable to get GECKO_HEAD_REV")
+
+ if os.environ.get("HOSTUTILS_MANIFEST_PATH", None) is not None:
+ manifest_url = os.path.join(
+ os.environ["GECKO_HEAD_REPOSITORY"],
+ "raw-file",
+ os.environ["GECKO_HEAD_REV"],
+ os.environ["HOSTUTILS_MANIFEST_PATH"],
+ )
+ else:
+ raise Exception("Abort: unable to get HOSTUTILS_MANIFEST_PATH!")
+
+ # first need to download the hostutils tooltool manifest file itself
+ _dest = os.path.join(self.mozproxy_dir, "hostutils.manifest")
+ have_manifest = download_file_from_url(manifest_url, _dest)
+ if not have_manifest:
+ raise Exception("failed to download the hostutils tooltool manifest")
+
+ # now use the manifest to download hostutils so we can get certutil
+ tooltool_download(_dest, self.config["run_local"], self.mozproxy_dir)
+
+ # the production bitbar container host is always linux
+ self.certutil_path = glob.glob(
+ os.path.join(self.mozproxy_dir, "host-utils*[!z|checksum]")
+ )[0]
+
+ # must add hostutils/certutil to the path
+ os.environ["LD_LIBRARY_PATH"] = self.certutil_path
+
+ bin_suffix = mozinfo.info.get("bin_suffix", "")
+ self.certutil_path = os.path.join(self.certutil_path, "certutil" + bin_suffix)
+ if os.path.isfile(self.certutil_path):
+ LOG.info("certutil is found at: %s" % self.certutil_path)
+ else:
+ raise Exception("unable to find certutil at %s" % self.certutil_path)
+
+ def install_mitmproxy_cert(self, browser_path):
+ """Install the CA certificate generated by mitmproxy, into geckoview android
+
+ 1. Create an NSS certificate database in the geckoview browser profile dir, only
+ if it doesn't already exist. Use this certutil command:
+ `certutil -N -d sql:<path to profile> --empty-password`
+ 2. Import the mitmproxy certificate into the database, i.e.:
+ `certutil -A -d sql:<path to profile> -n "some nickname" -t TC,, -a -i <path to CA.pem>`
+ """
+
+ cert_db_location = "sql:%s/" % self.config["local_profile_dir"]
+
+ if not self.cert_db_exists(cert_db_location):
+ self.create_cert_db(cert_db_location)
+
+ # DEFAULT_CERT_PATH has local path and name of mitmproxy cert i.e.
+ # /home/cltbld/.mitmproxy/mitmproxy-ca-cert.cer
+ self.import_certificate_in_cert_db(cert_db_location, DEFAULT_CERT_PATH)
+
+ # cannot continue if failed to add CA cert to Firefox, need to check
+ if not self.is_mitmproxy_cert_installed(cert_db_location):
+ LOG.error("Aborting: failed to install mitmproxy CA cert into Firefox")
+ self.stop_mitmproxy_playback()
+ sys.exit()
+
+ def import_certificate_in_cert_db(self, cert_db_location, local_cert_path):
+ # import mitmproxy cert into the db
+ args = [
+ "-A",
+ "-d",
+ cert_db_location,
+ "-n",
+ "mitmproxy-cert",
+ "-t",
+ "TC,,",
+ "-a",
+ "-i",
+ local_cert_path,
+ ]
+ LOG.info("importing mitmproxy cert into db using command")
+ self.certutil(args)
+
+ def create_cert_db(self, cert_db_location):
+ # create cert db if it doesn't already exist; it may exist already
+ # if a previous pageload test ran in the same test suite
+ args = ["-d", cert_db_location, "-N", "--empty-password"]
+
+ LOG.info("creating nss cert database")
+
+ self.certutil(args)
+
+ if not self.cert_db_exists(cert_db_location):
+ raise Exception("nss cert db creation command failed. Cert db not created.")
+
+ def cert_db_exists(self, cert_db_location):
+ # check if the nss ca cert db already exists in the device profile
+ LOG.info(
+ "checking if the nss cert db already exists in the android browser profile"
+ )
+
+ args = ["-d", cert_db_location, "-L"]
+ cert_db_exists = self.certutil(args, raise_exception=False)
+
+ if cert_db_exists:
+ LOG.info("the nss cert db exists")
+ return True
+ else:
+ LOG.info("nss cert db doesn't exist yet.")
+ return False
+
+ def is_mitmproxy_cert_installed(self, cert_db_location):
+ """Verify mitmxproy CA cert was added to Firefox on android"""
+ LOG.info("verifying that the mitmproxy ca cert is installed on android")
+
+ # list the certifcates that are in the nss cert db (inside the browser profile dir)
+ LOG.info(
+ "getting the list of certs in the nss cert db in the android browser profile"
+ )
+ args = ["-d", cert_db_location, "-L"]
+
+ cmd_output = self.certutil(args)
+
+ if "mitmproxy-cert" in cmd_output:
+ LOG.info(
+ "verfied the mitmproxy-cert is installed in the nss cert db on android"
+ )
+ return True
+ return False
+
+ def certutil(self, args, raise_exception=True):
+ cmd = [self.certutil_path] + list(args)
+ LOG.info("Certutil: Running command: %s" % " ".join(cmd))
+ try:
+ cmd_proc = subprocess.Popen(
+ cmd, stdout=PIPE, stderr=PIPE, env=os.environ.copy()
+ )
+
+ cmd_output, errs = cmd_proc.communicate()
+ except subprocess.SubprocessError:
+ LOG.critical("could not run the certutil command")
+ raise
+
+ if cmd_proc.returncode == 0:
+ # Debug purpose only remove if stable
+ LOG.info("Certutil returncode: %s" % cmd_proc.returncode)
+ LOG.info("Certutil output: %s" % cmd_output)
+ return cmd_output
+ else:
+ if raise_exception:
+ LOG.critical("Certutil command failed!!")
+ LOG.info("Certutil returncode: %s" % cmd_proc.returncode)
+ LOG.info("Certutil output: %s" % cmd_output)
+ LOG.info("Certutil error: %s" % errs)
+ raise Exception("Certutil command failed!!")
+ else:
+ return False
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py
new file mode 100644
index 0000000000..b8011edf2d
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/desktop.py
@@ -0,0 +1,162 @@
+"""Functions to download, install, setup, and use the mitmproxy playback tool"""
+# 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/.
+from __future__ import absolute_import
+
+import os
+import sys
+
+import mozinfo
+from mozproxy.backends.mitm.mitm import Mitmproxy
+from mozproxy.utils import LOG
+
+# to install mitmproxy certificate into Firefox and turn on/off proxy
+POLICIES_CONTENT_ON = """{
+ "policies": {
+ "Certificates": {
+ "Install": ["%(cert)s"]
+ },
+ "Proxy": {
+ "Mode": "manual",
+ "HTTPProxy": "%(host)s:%(port)d",
+ "SSLProxy": "%(host)s:%(port)d",
+ "Passthrough": "%(host)s",
+ "Locked": true
+ }
+ }
+}"""
+
+POLICIES_CONTENT_OFF = """{
+ "policies": {
+ "Proxy": {
+ "Mode": "none",
+ "Locked": false
+ }
+ }
+}"""
+
+# path for mitmproxy certificate, generated auto after mitmdump is started
+# on local machine it is 'HOME', however it is different on production machines
+try:
+ DEFAULT_CERT_PATH = os.path.join(
+ os.getenv("HOME"), ".mitmproxy", "mitmproxy-ca-cert.cer"
+ )
+except Exception:
+ DEFAULT_CERT_PATH = os.path.join(
+ os.getenv("HOMEDRIVE"),
+ os.getenv("HOMEPATH"),
+ ".mitmproxy",
+ "mitmproxy-ca-cert.cer",
+ )
+
+# On Windows, deal with mozilla-build having forward slashes in $HOME:
+if os.name == "nt" and "/" in DEFAULT_CERT_PATH:
+ DEFAULT_CERT_PATH = DEFAULT_CERT_PATH.replace("/", "\\")
+
+
+class MitmproxyDesktop(Mitmproxy):
+ def setup(self):
+ """
+ Installs certificates.
+
+ For Firefox we need to install the generated mitmproxy CA cert. For
+ Chromium this is not necessary as it will be started with the
+ --ignore-certificate-errors cmd line arg.
+ """
+ if not self.config["app"] == "firefox":
+ return
+ # install the generated CA certificate into Firefox desktop
+ self.install_mitmproxy_cert(self.browser_path)
+
+ def install_mitmproxy_cert(self, browser_path):
+ """Install the CA certificate generated by mitmproxy, into Firefox
+ 1. Create a dir called 'distribution' in the same directory as the Firefox executable
+ 2. Create the policies.json file inside that folder; which points to the certificate
+ location, and turns on the the browser proxy settings
+ """
+ LOG.info("Installing mitmproxy CA certficate into Firefox")
+
+ # browser_path is the exe, we want the folder
+ self.policies_dir = os.path.dirname(browser_path)
+ # on macosx we need to remove the last folders 'MacOS'
+ # and the policies json needs to go in ../Content/Resources/
+ if "mac" in self.config["platform"]:
+ self.policies_dir = os.path.join(self.policies_dir[:-6], "Resources")
+ # for all platforms the policies json goes in a 'distribution' dir
+ self.policies_dir = os.path.join(self.policies_dir, "distribution")
+
+ self.cert_path = DEFAULT_CERT_PATH
+ # for windows only
+ if mozinfo.os == "win":
+ self.cert_path = self.cert_path.replace("\\", "\\\\")
+
+ if not os.path.exists(self.policies_dir):
+ LOG.info("creating folder: %s" % self.policies_dir)
+ os.makedirs(self.policies_dir)
+ else:
+ LOG.info("folder already exists: %s" % self.policies_dir)
+
+ self.write_policies_json(
+ self.policies_dir,
+ policies_content=POLICIES_CONTENT_ON
+ % {"cert": self.cert_path, "host": self.host, "port": self.port},
+ )
+
+ # cannot continue if failed to add CA cert to Firefox, need to check
+ if not self.is_mitmproxy_cert_installed():
+ LOG.error(
+ "Aborting: failed to install mitmproxy CA cert into Firefox desktop"
+ )
+ self.stop_mitmproxy_playback()
+ sys.exit()
+
+ def write_policies_json(self, location, policies_content):
+ policies_file = os.path.join(location, "policies.json")
+ LOG.info("writing: %s" % policies_file)
+
+ with open(policies_file, "w") as fd:
+ fd.write(policies_content)
+
+ def read_policies_json(self, location):
+ policies_file = os.path.join(location, "policies.json")
+ LOG.info("reading: %s" % policies_file)
+
+ with open(policies_file, "r") as fd:
+ return fd.read()
+
+ def is_mitmproxy_cert_installed(self):
+ """Verify mitmxproy CA cert was added to Firefox"""
+ try:
+ # read autoconfig file, confirm mitmproxy cert is in there
+ contents = self.read_policies_json(self.policies_dir)
+ LOG.info("Firefox policies file contents:")
+ LOG.info(contents)
+ if (
+ POLICIES_CONTENT_ON
+ % {"cert": self.cert_path, "host": self.host, "port": self.port}
+ ) in contents:
+ LOG.info("Verified mitmproxy CA certificate is installed in Firefox")
+ else:
+
+ return False
+ except Exception as e:
+ LOG.info("failed to read Firefox policies file, exeption: %s" % e)
+ return False
+ return True
+
+ def stop(self):
+ LOG.info("MitmproxyDesktop stop!!")
+ super(MitmproxyDesktop, self).stop()
+ self.turn_off_browser_proxy()
+
+ def turn_off_browser_proxy(self):
+ """Turn off the browser proxy that was used for mitmproxy playback. In Firefox
+ we need to change the autoconfig files to revert the proxy; for Chromium the proxy
+ was setup on the cmd line, so nothing is required here."""
+ if self.config["app"] == "firefox" and self.policies_dir is not None:
+ LOG.info("Turning off the browser proxy")
+
+ self.write_policies_json(
+ self.policies_dir, policies_content=POLICIES_CONTENT_OFF
+ )
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest
new file mode 100644
index 0000000000..24419f001d
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-linux64.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 21805767,
+ "visibility": "public",
+ "digest": "69b314f9189b1c09e5412680869c03d07510b98b34e309f0f1b6961e5d8963f935994fe2f5ca86827f08631cbb271292a72aba2f0973b144832c2e7049a464a8",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-4.0.4-linux.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest
new file mode 100644
index 0000000000..b809e5bbdb
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-osx.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 36939029,
+ "visibility": "public",
+ "digest": "3f933b142d7afb7cd409f436bc1151cc44c6f207368644ebe9fc9015607448b2c2d52dad4e5c71595bb6aa8accf4869e534f8db0ff6144725fad8f98daf50b40",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-4.0.4-osx.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest
new file mode 100644
index 0000000000..f090ca3a6f
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-4.0.4-win.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 35903223,
+ "visibility": "public",
+ "digest": "cf5a2bd056ae655d4db0a953ec1bf80229990f449672ae9b636568540fae192a1c361c7742f1e2a8b560426a11e69955358b6a37445f37117dfcac154ef84713",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-4.0.4-windows.zip",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest
new file mode 100644
index 0000000000..cfbbd667fd
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-linux64.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 76345093,
+ "visibility": "public",
+ "digest": "4d653c0c74a8677e8e78cd72d109b1b54c75ef57b2e2ce980c5cfd602966c310065cf0e95c35f4fbfb1fe817f062ac6cf9cc129d72d42184b0237fb9b0bde081",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.0.1-linux.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest
new file mode 100644
index 0000000000..2a97c84eed
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-osx.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 41341221,
+ "visibility": "public",
+ "digest": "4624bc26638cd7f3ab8c8d2ee8ab44ab81018114714a2b82614883adbfad540de73a97455fb228a3003006b4d03a8a9a597d70f8b27ccf1718f78380f911bdd8",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.0.1-osx.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest
new file mode 100644
index 0000000000..2d7d60715a
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.0.1-win.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 37875625,
+ "visibility": "public",
+ "digest": "d66234c9ca692d03412dd194b3f47c098e8ee1b17b178034fc86e0d8ada4d4f6cd1fbcfb62b7e7016539a878b1274ef83451a0acaca7011efaacb291fa52918d",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.0.1-windows.zip",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest
new file mode 100644
index 0000000000..3022d190bb
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-linux64.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 24989250,
+ "visibility": "public",
+ "digest": "75ea9d024cc9138e5bc993c292ec65c2a40ce4e382f01c41ef865ddfac6f1553ee0e73e3ff2003234d6ecab2d3cdbbd38809cc7e62bbcabaed521a623575e2b8",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.1.1-linux.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest
new file mode 100644
index 0000000000..a039c71d27
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-osx.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 13178683,
+ "visibility": "public",
+ "digest": "efbc90674a85165e9065e70a8f0c2d4e42688a045130546c2d5721cdb4c54637f7d2b9f4e6a6953a37f94d0edb5bf128c25d7a628246a75d4ad7ba6c884589a9",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.1.1-osx.tar.gz",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest
new file mode 100644
index 0000000000..d0937a1bed
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/manifests/mitmproxy-rel-bin-5.1.1-win.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "size": 18019253,
+ "visibility": "public",
+ "digest": "69470f6a58c50912072ab6a911ac3e266d5ec8c9410c8aa4bad76cd7f61bca640e748fd682379ef1523f12da2a8c7a9c67d5bbae5f6d6fa164c2c6b9765b79c1",
+ "algorithm": "sha512",
+ "filename": "mitmproxy-5.1.1-windows.zip",
+ "unpack": true
+ }
+]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py
new file mode 100644
index 0000000000..991b4ebced
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitm.py
@@ -0,0 +1,490 @@
+# 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/.
+from __future__ import absolute_import
+
+import json
+import os
+import signal
+import socket
+import sys
+import time
+
+import mozinfo
+import six
+from mozprocess import ProcessHandler
+from mozproxy.backends.base import Playback
+from mozproxy.recordings import RecordingFile
+from mozproxy.utils import (
+ download_file_from_url,
+ transform_platform,
+ tooltool_download,
+ get_available_port,
+ LOG,
+)
+
+here = os.path.dirname(__file__)
+mitm_folder = os.path.dirname(os.path.realpath(__file__))
+
+
+def normalize_path(path):
+ path = os.path.normpath(path)
+ if mozinfo.os == "win":
+ return path.replace("\\", "\\\\\\")
+ return path
+
+
+# maximal allowed runtime of a mitmproxy command
+MITMDUMP_COMMAND_TIMEOUT = 30
+
+
+class Mitmproxy(Playback):
+ def __init__(self, config):
+ self.config = config
+
+ self.host = (
+ "127.0.0.1" if "localhost" in self.config["host"] else self.config["host"]
+ )
+ self.port = None
+ self.mitmproxy_proc = None
+ self.mitmdump_path = None
+ self.record_mode = config.get("record", False)
+ self.recording = None
+ self.playback_files = []
+
+ self.browser_path = ""
+ if config.get("binary", None):
+ self.browser_path = os.path.normpath(config.get("binary"))
+
+ self.policies_dir = None
+ self.ignore_mitmdump_exit_failure = config.get(
+ "ignore_mitmdump_exit_failure", False
+ )
+
+ if self.record_mode:
+ if "recording_file" not in self.config:
+ LOG.error(
+ "recording_file value was not provided. Proxy service wont' start "
+ )
+ raise Exception("Please provide a playback_files list.")
+
+ if not isinstance(self.config.get("recording_file"), six.string_types):
+ LOG.error("recording_file argument type is not str!")
+ raise Exception("recording_file argument type invalid!")
+
+ if not os.path.splitext(self.config.get("recording_file"))[1] == ".zip":
+ LOG.error(
+ "Recording file type (%s) should be a zip. "
+ "Please provide a valid file type!"
+ % self.config.get("recording_file")
+ )
+ raise Exception("Recording file type should be a zip")
+
+ if os.path.exists(self.config.get("recording_file")):
+ LOG.error(
+ "Recording file (%s) already exists."
+ "Please provide a valid file path!"
+ % self.config.get("recording_file")
+ )
+ raise Exception("Recording file already exists.")
+
+ if self.config.get("playback_files", False):
+ LOG.error("Record mode is True and playback_files where provided!")
+ raise Exception("playback_files specified during record!")
+
+ if self.config.get("playback_version") is None:
+ LOG.error(
+ "mitmproxy was not provided with a 'playback_version' "
+ "Please provide a valid playback version"
+ )
+ raise Exception("playback_version not specified!")
+
+ # mozproxy_dir is where we will download all mitmproxy required files
+ # when running locally it comes from obj_path via mozharness/mach
+ if self.config.get("obj_path") is not None:
+ self.mozproxy_dir = self.config.get("obj_path")
+ else:
+ # in production it is ../tasks/task_N/build/, in production that dir
+ # is not available as an envvar, however MOZ_UPLOAD_DIR is set as
+ # ../tasks/task_N/build/blobber_upload_dir so take that and go up 1 level
+ self.mozproxy_dir = os.path.dirname(
+ os.path.dirname(os.environ["MOZ_UPLOAD_DIR"])
+ )
+
+ self.mozproxy_dir = os.path.join(self.mozproxy_dir, "testing", "mozproxy")
+ self.upload_dir = os.environ.get("MOZ_UPLOAD_DIR", self.mozproxy_dir)
+
+ LOG.info(
+ "mozproxy_dir used for mitmproxy downloads and exe files: %s"
+ % self.mozproxy_dir
+ )
+ # setting up the MOZPROXY_DIR env variable so custom scripts know
+ # where to get the data
+ os.environ["MOZPROXY_DIR"] = self.mozproxy_dir
+
+ LOG.info("Playback tool: %s" % self.config["playback_tool"])
+ LOG.info("Playback tool version: %s" % self.config["playback_version"])
+
+ def download_mitm_bin(self):
+ # Download and setup mitm binaries
+
+ manifest = os.path.join(
+ here,
+ "manifests",
+ "mitmproxy-rel-bin-%s-{platform}.manifest"
+ % self.config["playback_version"],
+ )
+ transformed_manifest = transform_platform(manifest, self.config["platform"])
+
+ # generate the mitmdump_path
+ self.mitmdump_path = os.path.normpath(
+ os.path.join(
+ self.mozproxy_dir,
+ "mitmdump-%s" % self.config["playback_version"],
+ "mitmdump",
+ )
+ )
+
+ # Check if mitmproxy bin exists
+ if os.path.exists(self.mitmdump_path):
+ LOG.info("mitmproxy binary already exists. Skipping download")
+ else:
+ # Download and unpack mitmproxy binary
+ download_path = os.path.dirname(self.mitmdump_path)
+ LOG.info("create mitmproxy %s dir" % self.config["playback_version"])
+ if not os.path.exists(download_path):
+ os.makedirs(download_path)
+
+ LOG.info("downloading mitmproxy binary")
+ tooltool_download(
+ transformed_manifest, self.config["run_local"], download_path
+ )
+
+ def download_manifest_file(self, manifest_path):
+ # Manifest File
+ # we use one pageset for all platforms
+ LOG.info("downloading mitmproxy pageset")
+
+ tooltool_download(manifest_path, self.config["run_local"], self.mozproxy_dir)
+
+ with open(manifest_path) as manifest_file:
+ manifest = json.load(manifest_file)
+ for file in manifest:
+ zip_path = os.path.join(self.mozproxy_dir, file["filename"])
+ LOG.info("Adding %s to recording list" % zip_path)
+ self.playback_files.append(RecordingFile(zip_path))
+
+ def download_playback_files(self):
+ # Detect type of file from playback_files and download accordingly
+ if "playback_files" not in self.config:
+ LOG.error(
+ "playback_files value was not provided. Proxy service wont' start "
+ )
+ raise Exception("Please provide a playback_files list.")
+
+ if not isinstance(self.config["playback_files"], list):
+ LOG.error("playback_files should be a list")
+ raise Exception("playback_files should be a list")
+
+ for playback_file in self.config["playback_files"]:
+
+ if playback_file.startswith("https://") and "mozilla.com" in playback_file:
+ # URL provided
+ dest = os.path.join(self.mozproxy_dir, os.path.basename(playback_file))
+ download_file_from_url(playback_file, self.mozproxy_dir, extract=False)
+ # Add Downloaded file to playback_files list
+ LOG.info("Adding %s to recording list" % dest)
+ self.playback_files.append(RecordingFile(dest))
+ continue
+
+ if not os.path.exists(playback_file):
+ LOG.error(
+ "Zip or manifest file path (%s) does not exist. Please provide a valid path!"
+ % playback_file
+ )
+ raise Exception("Zip or manifest file path does not exist")
+
+ if os.path.splitext(playback_file)[1] == ".zip":
+ # zip file path provided
+ LOG.info("Adding %s to recording list" % playback_file)
+ self.playback_files.append(RecordingFile(playback_file))
+ elif os.path.splitext(playback_file)[1] == ".manifest":
+ # manifest file path provided
+ self.download_manifest_file(playback_file)
+
+ def download(self):
+ """Download and unpack mitmproxy binary and pageset using tooltool"""
+ if not os.path.exists(self.mozproxy_dir):
+ os.makedirs(self.mozproxy_dir)
+
+ self.download_mitm_bin()
+
+ if self.record_mode:
+ self.recording = RecordingFile(self.config["recording_file"])
+ else:
+ self.download_playback_files()
+
+ def stop(self):
+ LOG.info("Mitmproxy stop!!")
+ self.stop_mitmproxy_playback()
+ if self.record_mode:
+ LOG.info("Record mode ON. Generating zip file ")
+ self.recording.generate_zip_file()
+
+ def wait(self, timeout=1):
+ """Wait until the mitmproxy process has terminated."""
+ # We wait using this method to allow Windows to respond to the Ctrl+Break
+ # signal so that we can exit cleanly from the command-line driver.
+ while True:
+ returncode = self.mitmproxy_proc.wait(timeout)
+ if returncode is not None:
+ return returncode
+
+ def start(self):
+ # go ahead and download and setup mitmproxy
+ self.download()
+
+ # mitmproxy must be started before setup, so that the CA cert is available
+ self.start_mitmproxy_playback(self.mitmdump_path, self.browser_path)
+
+ # In case the setup fails, we want to stop the process before raising.
+ try:
+ self.setup()
+ except Exception:
+ try:
+ self.stop()
+ except Exception:
+ LOG.error("MitmProxy failed to STOP.", exc_info=True)
+ LOG.error("Setup of MitmProxy failed.", exc_info=True)
+ raise
+
+ def start_mitmproxy_playback(self, mitmdump_path, browser_path):
+ """Startup mitmproxy and replay the specified flow file"""
+ if self.mitmproxy_proc is not None:
+ raise Exception("Proxy already started.")
+ self.port = get_available_port()
+
+ LOG.info("mitmdump path: %s" % mitmdump_path)
+ LOG.info("browser path: %s" % browser_path)
+
+ # mitmproxy needs some DLL's that are a part of Firefox itself, so add to path
+ env = os.environ.copy()
+ env["PATH"] = os.path.dirname(browser_path) + os.pathsep + env["PATH"]
+ command = [mitmdump_path]
+
+ # add proxy host and port options
+ command.extend(["--listen-host", self.host, "--listen-port", str(self.port)])
+
+ # record mode
+ if self.record_mode:
+
+ # generate recording script paths
+ inject_deterministic = os.path.join(
+ mitm_folder,
+ "scripts",
+ "inject-deterministic.py",
+ )
+ http_protocol_extractor = os.path.join(
+ mitm_folder,
+ "scripts",
+ "http_protocol_extractor.py",
+ )
+
+ args = [
+ "--save-stream-file",
+ normalize_path(self.recording.recording_path),
+ "--set",
+ "websocket=false",
+ "--scripts",
+ inject_deterministic,
+ "--scripts",
+ http_protocol_extractor,
+ ]
+ command.extend(args)
+ else:
+ # playback mode
+ if len(self.playback_files) > 0:
+ script = os.path.join(
+ mitm_folder,
+ "scripts",
+ "alternate-server-replay.py",
+ )
+
+ if self.config["playback_version"] in ["4.0.4", "5.1.1"]:
+ args = [
+ "-v", # Verbose mode
+ "--set",
+ "upstream_cert=false",
+ "--set",
+ "upload_dir=" + normalize_path(self.upload_dir),
+ "--set",
+ "websocket=false",
+ "--set",
+ "server_replay_files={}".format(
+ ",".join(
+ [
+ normalize_path(playback_file.recording_path)
+ for playback_file in self.playback_files
+ ]
+ )
+ ),
+ "--scripts",
+ normalize_path(script),
+ ]
+ command.extend(args)
+ else:
+ raise Exception("Mitmproxy version is unknown!")
+
+ else:
+ raise Exception(
+ "Mitmproxy can't start playback! Playback settings missing."
+ )
+
+ # mitmproxy needs some DLL's that are a part of Firefox itself, so add to path
+ env = os.environ.copy()
+ if not os.path.dirname(self.browser_path) in env["PATH"]:
+ env["PATH"] = os.path.dirname(self.browser_path) + os.pathsep + env["PATH"]
+
+ LOG.info("Starting mitmproxy playback using env path: %s" % env["PATH"])
+ LOG.info("Starting mitmproxy playback using command: %s" % " ".join(command))
+ # to turn off mitmproxy log output, use these params for Popen:
+ # Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
+ self.mitmproxy_proc = ProcessHandler(
+ command,
+ logfile=os.path.join(self.upload_dir, "mitmproxy.log"),
+ env=env,
+ processStderrLine=LOG.error,
+ storeOutput=False,
+ )
+ self.mitmproxy_proc.run()
+ end_time = time.time() + MITMDUMP_COMMAND_TIMEOUT
+
+ ready = False
+ while time.time() < end_time:
+ ready = self.check_proxy(host=self.host, port=self.port)
+ if ready:
+ LOG.info(
+ "Mitmproxy playback successfully started on %s:%d as pid %d"
+ % (self.host, self.port, self.mitmproxy_proc.pid)
+ )
+ return
+ time.sleep(0.25)
+
+ # cannot continue as we won't be able to playback the pages
+ LOG.error("Aborting: Mitmproxy process did not startup")
+ self.stop_mitmproxy_playback()
+ sys.exit(1) # XXX why do we need to do that? a raise is not enough?
+
+ def stop_mitmproxy_playback(self):
+ """Stop the mitproxy server playback"""
+ if self.mitmproxy_proc is None or self.mitmproxy_proc.poll() is not None:
+ return
+ LOG.info(
+ "Stopping mitmproxy playback, killing process %d" % self.mitmproxy_proc.pid
+ )
+ # On Windows, mozprocess brutally kills mitmproxy with TerminateJobObject
+ # The process has no chance to gracefully shutdown.
+ # Here, we send the process a break event to give it a chance to wrapup.
+ # See the signal handler in the alternate-server-replay-4.0.4.py script
+ if mozinfo.os == "win":
+ LOG.info("Sending CTRL_BREAK_EVENT to mitmproxy")
+ os.kill(self.mitmproxy_proc.pid, signal.CTRL_BREAK_EVENT)
+ time.sleep(2)
+
+ exit_code = self.mitmproxy_proc.kill()
+ self.mitmproxy_proc = None
+
+ if exit_code != 0:
+ if exit_code is None:
+ LOG.error("Failed to kill the mitmproxy playback process")
+ return
+
+ if mozinfo.os == "win":
+ from mozprocess.winprocess import ERROR_CONTROL_C_EXIT # noqa
+
+ if exit_code == ERROR_CONTROL_C_EXIT:
+ LOG.info(
+ "Successfully killed the mitmproxy playback process"
+ " with exit code %d" % exit_code
+ )
+ return
+ log_func = LOG.error
+ if self.ignore_mitmdump_exit_failure:
+ log_func = LOG.info
+ log_func("Mitmproxy exited with error code %d" % exit_code)
+ else:
+ LOG.info("Successfully killed the mitmproxy playback process")
+
+ def check_proxy(self, host, port):
+ """Check that mitmproxy process is working by doing a socket call using the proxy settings
+ :param host: Host of the proxy server
+ :param port: Port of the proxy server
+ :return: True if the proxy service is working
+ """
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ s.connect((host, port))
+ s.shutdown(socket.SHUT_RDWR)
+ s.close()
+ return True
+ except socket.error:
+ return False
+
+ def confidence(self):
+ """Extract confidence metrics from the netlocs file
+ and convert them to perftest results
+ """
+ if len(self.playback_files) == 0:
+ LOG.warning(
+ "Proxy service did not load a recording file. "
+ "Confidence metrics will nt be generated"
+ )
+ return
+
+ file_name = (
+ "mitm_netlocs_%s.json"
+ % os.path.splitext(os.path.basename(self.playback_files[0].recording_path))[
+ 0
+ ]
+ )
+ path = os.path.normpath(os.path.join(self.upload_dir, file_name))
+ if os.path.exists(path):
+ try:
+ LOG.info("Reading confidence values from: %s" % path)
+ with open(path, "r") as f:
+ data = json.load(f)
+ return {
+ "replay-confidence": {
+ "values": data["replay-confidence"],
+ "subtest-prefix-type": False,
+ "unit": "%",
+ "shouldAlert": False,
+ "lowerIsBetter": False,
+ },
+ "recording-proportion-used": {
+ "values": data["recording-proportion-used"],
+ "subtest-prefix-type": False,
+ "unit": "%",
+ "shouldAlert": False,
+ "lowerIsBetter": False,
+ },
+ "not-replayed": {
+ "values": data["not-replayed"],
+ "subtest-prefix-type": False,
+ "shouldAlert": False,
+ "unit": "a.u.",
+ },
+ "replayed": {
+ "values": data["replayed"],
+ "subtest-prefix-type": False,
+ "unit": "a.u.",
+ "shouldAlert": False,
+ "lowerIsBetter": False,
+ },
+ }
+ except Exception:
+ LOG.info("Can't read netlocs file!", exc_info=True)
+ return None
+ else:
+ LOG.info("Netlocs file is not available! Cant find %s" % path)
+ return None
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt
new file mode 100644
index 0000000000..7d9842b7d6
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/mitmproxy_requirements.txt
@@ -0,0 +1,35 @@
+argh==0.26.2
+asn1crypto==0.22.0
+blinker==1.4
+pycparser==2.17
+cffi==1.10.0
+brotlipy==0.6.0
+certifi==2017.4.17
+click==6.7
+construct==2.8.12
+cryptography==1.8.2
+cssutils==1.0.2
+EditorConfig==0.12.1
+h2==2.6.2
+hpack==3.0.0
+html2text==2016.9.19
+hyperframe==4.0.2
+idna==2.5
+jsbeautifier==1.6.12
+kaitaistruct==0.6
+mitmproxy==4.0.4
+packaging==16.8
+passlib==1.7.1
+pathtools==0.1.2
+pyasn1==0.2.3
+pyOpenSSL==16.2.0
+pyparsing==2.2.0
+pyperclip==1.5.27
+PyYAML==3.12
+requests==2.13.0
+ruamel.yaml==0.13.14
+six==1.13.0
+sortedcontainers==1.5.7
+tornado==4.4.3
+urwid==1.3.1
+watchdog==0.8.3
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py
new file mode 100644
index 0000000000..0499f3a93f
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/__init__.py
@@ -0,0 +1,4 @@
+#!/usr/bin/env python
+# 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/.
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py
new file mode 100644
index 0000000000..2fe64a66a7
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/alternate-server-replay.py
@@ -0,0 +1,308 @@
+# 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/.
+
+# This file was copied from mitmproxy/mitmproxy/addons/serverplayback.py release tag 4.0.4
+# and modified by Florin Strugariu
+
+# Altered features:
+# * returns 404 rather than dropping the whole HTTP/2 connection on the floor
+# * remove the replay packages that don't have any content in their response package
+from __future__ import absolute_import, print_function
+
+import os
+import json
+import hashlib
+from collections import defaultdict
+import time
+import signal
+
+import typing
+
+from six.moves import urllib
+from mitmproxy import ctx, http
+from mitmproxy import exceptions
+from mitmproxy import io
+
+# PATCHING AREA - ALLOWS HTTP/2 WITH NO CERT SNIFFING
+from mitmproxy.proxy.protocol import tls
+from mitmproxy.proxy.protocol.http2 import Http2Layer, SafeH2Connection
+
+_PROTO = {}
+
+
+@property
+def _alpn(self):
+ proto = _PROTO.get(self.server_sni)
+ if proto is None:
+ return self.server_conn.get_alpn_proto_negotiated()
+ if proto.startswith("HTTP/2"):
+ return b"h2"
+ elif proto.startswith("HTTP/1"):
+ return b"h1"
+ return b""
+
+
+tls.TlsLayer.alpn_for_client_connection = _alpn
+
+
+def _server_conn(self):
+ if not self.server_conn.connected() and self.server_conn not in self.connections:
+ # we can't use ctx.log in this layer
+ print("Ignored CONNECT call on upstream server")
+ return
+ if self.server_conn.connected():
+ import h2.config
+
+ config = h2.config.H2Configuration(
+ client_side=True,
+ header_encoding=False,
+ validate_outbound_headers=False,
+ validate_inbound_headers=False,
+ )
+ self.connections[self.server_conn] = SafeH2Connection(
+ self.server_conn, config=config
+ )
+ self.connections[self.server_conn].initiate_connection()
+ self.server_conn.send(self.connections[self.server_conn].data_to_send())
+
+
+Http2Layer._initiate_server_conn = _server_conn
+
+
+def _remote_settings_changed(self, event, other_conn):
+ if other_conn not in self.connections:
+ # we can't use ctx.log in this layer
+ print("Ignored remote settings upstream")
+ return True
+ new_settings = dict(
+ [(key, cs.new_value) for (key, cs) in event.changed_settings.items()]
+ )
+ self.connections[other_conn].safe_update_settings(new_settings)
+ return True
+
+
+Http2Layer._handle_remote_settings_changed = _remote_settings_changed
+# END OF PATCHING
+
+
+class AlternateServerPlayback:
+ def __init__(self):
+ ctx.master.addons.remove(ctx.master.addons.get("serverplayback"))
+ self.flowmap = {}
+ self.configured = False
+ self.netlocs = defaultdict(lambda: defaultdict(int))
+ self.calls = []
+ self._done = False
+ self._replayed = 0
+ self._not_replayed = 0
+ self._recordings_used = 0
+ self.mitm_version = ctx.mitmproxy.version.VERSION
+
+ ctx.log.info("MitmProxy version: %s" % self.mitm_version)
+
+ def load(self, loader):
+ loader.add_option(
+ "server_replay_files",
+ typing.Sequence[str],
+ [],
+ "Replay server responses from a saved file.",
+ )
+ loader.add_option(
+ "upload_dir",
+ str,
+ "",
+ "Upload directory",
+ )
+
+ def load_flows(self, flows):
+ """
+ Replay server responses from flows.
+ """
+ for i in flows:
+ if i.type == "websocket":
+ # Mitmproxy can't replay WebSocket packages.
+ ctx.log.info(
+ "Recorded response is a WebSocketFlow. Removing from recording list as"
+ " WebSockets are disabled"
+ )
+ elif i.response and self.mitm_version in ("4.0.2", "4.0.4", "5.1.1"):
+ # see: https://github.com/mitmproxy/mitmproxy/issues/3856
+ f = self.flowmap.setdefault(
+ self._hash(i), {"flow": None, "reply_count": 0}
+ )
+ # overwrite with new flow if already hashed
+ f["flow"] = i
+ else:
+ ctx.log.info(
+ "Recorded request %s has no response. Removing from recording list"
+ % i.request.url
+ )
+ ctx.master.addons.trigger("update", [])
+
+ def load_files(self, paths):
+ try:
+ if "," in paths[0]:
+ paths = paths[0].split(",")
+ for path in paths:
+ ctx.log.info("Loading flows from %s" % path)
+ if not os.path.exists(path):
+ raise Exception("File does not exist!")
+ try:
+ flows = io.read_flows_from_paths([path])
+ except exceptions.FlowReadException as e:
+ raise exceptions.CommandError(str(e))
+ self.load_flows(flows)
+ proto = os.path.join(os.path.dirname(path), "metadata.json")
+ if os.path.exists(proto):
+ ctx.log.info("Loading proto info from %s" % proto)
+ with open(proto) as f:
+ recording_info = json.loads(f.read())
+ if recording_info.get("http_protocol", False):
+ ctx.log.info(
+ "Replaying file {} recorded on {}".format(
+ path, recording_info["recording_date"]
+ )
+ )
+ _PROTO.update(recording_info["http_protocol"])
+ else:
+ ctx.log.warn(
+ "Replaying file {} has no http_protocol info.".format(proto)
+ )
+ except Exception as e:
+ ctx.log.error("Could not load recording file! Stopping playback process!")
+ ctx.log.error(str(e))
+ ctx.master.shutdown()
+
+ def _hash(self, flow):
+ """
+ Calculates a loose hash of the flow request.
+ """
+ r = flow.request
+
+ # unquote url
+ # See Bug 1509835
+ _, _, path, _, query, _ = urllib.parse.urlparse(urllib.parse.unquote(r.url))
+ queriesArray = urllib.parse.parse_qsl(query, keep_blank_values=True)
+
+ key = [str(r.port), str(r.scheme), str(r.method), str(path)]
+ key.append(str(r.raw_content))
+ key.append(r.host)
+
+ for p in queriesArray:
+ key.append(p[0])
+ key.append(p[1])
+
+ return hashlib.sha256(repr(key).encode("utf8", "surrogateescape")).digest()
+
+ def next_flow(self, request):
+ """
+ Returns the next flow object, or None if no matching flow was
+ found.
+ """
+ hsh = self._hash(request)
+ if hsh in self.flowmap:
+ if self.flowmap[hsh]["reply_count"] == 0:
+ self._recordings_used += 1
+ self.flowmap[hsh]["reply_count"] += 1
+ # return the most recently added flow with this hash
+ return self.flowmap[hsh]["flow"]
+
+ def configure(self, updated):
+ if not self.configured and ctx.options.server_replay_files:
+ self.configured = True
+ self.load_files(ctx.options.server_replay_files)
+
+ def done(self):
+ if self._done or not ctx.options.upload_dir:
+ return
+
+ replay_confidence = float(self._replayed) / (
+ self._replayed + self._not_replayed
+ )
+ recording_proportion_used = (
+ 0
+ if self._recordings_used == 0
+ else float(self._recordings_used) / len(self.flowmap)
+ )
+ stats = {
+ "totals": dict(self.netlocs),
+ "calls": self.calls,
+ "replayed": self._replayed,
+ "not-replayed": self._not_replayed,
+ "replay-confidence": int(replay_confidence * 100),
+ "recording-proportion-used": int(recording_proportion_used * 100),
+ }
+ file_name = (
+ "mitm_netlocs_%s.json"
+ % os.path.splitext(os.path.basename(ctx.options.server_replay_files[0]))[0]
+ )
+ path = os.path.normpath(os.path.join(ctx.options.upload_dir, file_name))
+ try:
+ with open(path, "w") as f:
+ f.write(json.dumps(stats, indent=2, sort_keys=True))
+ finally:
+ self._done = True
+
+ def request(self, f):
+ if self.flowmap:
+ try:
+ rflow = self.next_flow(f)
+ if rflow:
+ response = rflow.response.copy()
+ response.is_replay = True
+ # Refresh server replay responses by adjusting date, expires and
+ # last-modified headers, as well as adjusting cookie expiration.
+ response.refresh()
+
+ f.response = response
+ self._replayed += 1
+ else:
+ # returns 404 rather than dropping the whole HTTP/2 connection
+ ctx.log.warn(
+ "server_playback: killed non-replay request {}".format(
+ f.request.url
+ )
+ )
+ f.response = http.HTTPResponse.make(
+ 404, b"", {"content-type": "text/plain"}
+ )
+ self._not_replayed += 1
+
+ # collecting stats only if we can dump them (see .done())
+ if ctx.options.upload_dir:
+ parsed_url = urllib.parse.urlparse(
+ urllib.parse.unquote(f.request.url)
+ )
+ self.netlocs[parsed_url.netloc][f.response.status_code] += 1
+ self.calls.append(
+ {
+ "time": str(time.time()),
+ "url": f.request.url,
+ "response_status": f.response.status_code,
+ }
+ )
+ except Exception as e:
+ ctx.log.error("Could not generate response! Stopping playback process!")
+ ctx.log.info(e)
+ ctx.master.shutdown()
+
+ else:
+ ctx.log.error("Playback library is empty! Stopping playback process!")
+ ctx.master.shutdown()
+ return
+
+
+playback = AlternateServerPlayback()
+
+if hasattr(signal, "SIGBREAK"):
+ # allows the addon to dump the stats even if mitmproxy
+ # does not call done() like on windows termination
+ # for this, the parent process sends CTRL_BREAK_EVENT which
+ # is received as an SIGBREAK event
+ def _shutdown(sig, frame):
+ ctx.master.shutdown()
+
+ signal.signal(signal.SIGBREAK, _shutdown)
+
+addons = [playback]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE
new file mode 100644
index 0000000000..c992fe483c
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2015 The Chromium Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+* Neither the name of catapult nor the names of its
+ contributors may be used to endorse or promote products derived from
+ this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js
new file mode 100644
index 0000000000..d2e818ba00
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/catapult/deterministic.js
@@ -0,0 +1,71 @@
+// Copyright 2017 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+
+'use strict';
+
+(function () {
+ var random_count = 0;
+ var random_count_threshold = 25;
+ var random_seed = 0.462;
+ Math.random = function() {
+ random_count++;
+ if (random_count > random_count_threshold){
+ random_seed += 0.1;
+ random_count = 1;
+ }
+ return (random_seed % 1);
+ };
+ if (typeof(crypto) == 'object' &&
+ typeof(crypto.getRandomValues) == 'function') {
+ crypto.getRandomValues = function(arr) {
+ var scale = Math.pow(256, arr.BYTES_PER_ELEMENT);
+ for (var i = 0; i < arr.length; i++) {
+ arr[i] = Math.floor(Math.random() * scale);
+ }
+ return arr;
+ };
+ }
+})();
+(function () {
+ var date_count = 0;
+ var date_count_threshold = 25;
+ var orig_date = Date;
+ // Time since epoch in milliseconds. This is replaced by script injector with
+ // the date when the recording is done.
+ var time_seed = REPLACE_LOAD_TIMESTAMP;
+ Date = function() {
+ if (this instanceof Date) {
+ date_count++;
+ if (date_count > date_count_threshold){
+ time_seed += 50;
+ date_count = 1;
+ }
+ switch (arguments.length) {
+ case 0: return new orig_date(time_seed);
+ case 1: return new orig_date(arguments[0]);
+ default: return new orig_date(arguments[0], arguments[1],
+ arguments.length >= 3 ? arguments[2] : 1,
+ arguments.length >= 4 ? arguments[3] : 0,
+ arguments.length >= 5 ? arguments[4] : 0,
+ arguments.length >= 6 ? arguments[5] : 0,
+ arguments.length >= 7 ? arguments[6] : 0);
+ }
+ }
+ return new Date().toString();
+ };
+ Date.__proto__ = orig_date;
+ Date.prototype = orig_date.prototype;
+ Date.prototype.constructor = Date;
+ orig_date.now = function() {
+ return new Date().getTime();
+ };
+ orig_date.prototype.getTimezoneOffset = function() {
+ var dst2010Start = 1268560800000;
+ var dst2010End = 1289120400000;
+ if (this.getTime() >= dst2010Start && this.getTime() < dst2010End)
+ return 420;
+ return 480;
+ };
+})();
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py
new file mode 100644
index 0000000000..b2bc308bd8
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/http_protocol_extractor.py
@@ -0,0 +1,85 @@
+# 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/.
+from __future__ import absolute_import, print_function
+
+import datetime
+import hashlib
+import json
+import os
+import urllib
+from urllib import parse
+
+
+class HttpProtocolExtractor:
+ def get_ctx(self):
+ from mitmproxy import ctx
+
+ return ctx
+
+ def load(self, loader):
+ self.ctx = self.get_ctx()
+
+ self.request_protocol = {}
+ self.hashes = []
+ self.request_count = 0
+
+ self.ctx.log.info("Init Http Protocol extractor JS")
+
+ def _hash(self, flow):
+ """
+ Calculates a loose hash of the flow request.
+ """
+ r = flow.request
+
+ # unquote url
+ # See Bug 1509835
+ _, _, path, _, query, _ = urllib.parse.urlparse(parse.unquote(r.url))
+ queriesArray = urllib.parse.parse_qsl(query, keep_blank_values=True)
+
+ key = [str(r.port), str(r.scheme), str(r.method), str(path)]
+ key.append(str(r.raw_content))
+ key.append(r.host)
+
+ for p in queriesArray:
+ key.append(p[0])
+ key.append(p[1])
+
+ return hashlib.sha256(repr(key).encode("utf8", "surrogateescape")).digest()
+
+ def response(self, flow):
+ self.request_count += 1
+ hash = self._hash(flow)
+ if hash not in self.hashes:
+ self.hashes.append(hash)
+
+ if flow.type == "websocket":
+ self.ctx.log.info("Response is a WebSocketFlow. Bug 1559117")
+ else:
+ self.ctx.log.info(
+ "Response using protocol: %s" % flow.response.data.http_version
+ )
+ self.request_protocol[
+ urllib.parse.urlparse(flow.request.url).netloc
+ ] = flow.response.data.http_version.decode("utf-8")
+
+ def done(self):
+ output_json = {}
+
+ output_json["recording_date"] = str(datetime.datetime.now())
+ output_json["http_protocol"] = self.request_protocol
+ output_json["recorded_requests"] = self.request_count
+ output_json["recorded_requests_unique"] = len(self.hashes)
+
+ recording_file_name = self.ctx.options.save_stream_file
+
+ json_file_name = os.path.join(
+ os.path.dirname(recording_file_name),
+ "%s.json" % os.path.basename(recording_file_name).split(".")[0],
+ )
+ print("Saving response protocol data to %s" % json_file_name)
+ with open(json_file_name, "w") as file:
+ file.write(json.dumps(output_json))
+
+
+addons = [HttpProtocolExtractor()]
diff --git a/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py
new file mode 100644
index 0000000000..06bc017b1f
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/backends/mitm/scripts/inject-deterministic.py
@@ -0,0 +1,208 @@
+# 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/.
+from __future__ import absolute_import, print_function
+import base64
+import hashlib
+import re
+import time
+from os import path
+
+from mitmproxy import ctx
+
+
+class AddDeterministic:
+ def __init__(self):
+ self.millis = int(round(time.time() * 1000))
+ ctx.log.info("Init Deterministic JS")
+
+ def load(self, loader):
+ ctx.log.info("Load Deterministic JS")
+
+ def get_csp_directives(self, test_header, headers):
+ csp = headers.get(test_header, "")
+ return [d.strip() for d in csp.split(";")]
+
+ def get_csp_script_sources(self, test_header, headers):
+ sources = []
+ for directive in self.get_csp_directives(test_header, headers):
+ if directive.startswith("script-src "):
+ sources = directive.split()[1:]
+ return sources
+
+ def get_nonce_from_headers(self, test_header, headers):
+ """
+ get_nonce_from_headers returns the nonce token from a
+ Content-Security-Policy (CSP) header's script source directive.
+
+ Note:
+ For more background information on CSP and nonce, please refer to
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/
+ Content-Security-Policy/script-src
+ https://developers.google.com/web/fundamentals/security/csp/
+ """
+
+ for source in self.get_csp_script_sources(test_header, headers) or []:
+ if source.startswith("'nonce-"):
+ return source.partition("'nonce-")[-1][:-1]
+
+ def get_script_with_nonce(self, script, nonce=None):
+ """
+ Given a nonce, get_script_with_nonce returns the injected script text with the nonce.
+
+ If nonce None, get_script_with_nonce returns the script block
+ without attaching a nonce attribute.
+
+ Note:
+ Some responses may specify a nonce inside their Content-Security-Policy,
+ script-src directive.
+ The script injector needs to set the injected script's nonce attribute to
+ open execute permission for the injected script.
+ """
+
+ if nonce:
+ return '<script nonce="{}">{}</script>'.format(nonce, script)
+ return "<script>{}</script>".format(script)
+
+ def update_csp_script_src(self, test_header, headers, sha256):
+ """
+ Update the CSP script directives with appropriate information
+
+ Without this permissions a page with a
+ restrictive CSP will not execute injected scripts.
+
+ Note:
+ For more background information on CSP, please refer to
+ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/
+ Content-Security-Policy/script-src
+ https://developers.google.com/web/fundamentals/security/csp/
+ """
+
+ sources = self.get_csp_script_sources(test_header, headers)
+ add_unsafe = True
+
+ for token in sources:
+ if token == "'unsafe-inline'":
+ add_unsafe = False
+ ctx.log.info("Contains unsafe-inline")
+ elif token.startswith("'sha"):
+ sources.append("'sha256-{}'".format(sha256))
+ add_unsafe = False
+ ctx.log.info("Add sha hash directive")
+ break
+
+ if add_unsafe:
+ ctx.log.info("Add unsafe")
+ sources.append("'unsafe-inline'")
+
+ return "script-src {}".format(" ".join(sources))
+
+ def get_new_csp_header(self, test_header, headers, updated_csp_script):
+ """
+ get_new_csp_header generates a new header object containing
+ the updated elements from new_csp_script_directives
+ """
+
+ if updated_csp_script:
+ directives = self.get_csp_directives(test_header, headers)
+ for index, directive in enumerate(directives):
+ if directive.startswith("script-src "):
+ directives[index] = updated_csp_script
+
+ ctx.log.info("Original Header %s \n" % headers["Content-Security-Policy"])
+ headers["Content-Security-Policy"] = "; ".join(directives)
+ ctx.log.info("Updated Header %s \n" % headers["Content-Security-Policy"])
+
+ return headers
+
+ def response(self, flow):
+ # pylint: disable=W1633
+ if "content-type" in flow.response.headers:
+ if "text/html" in flow.response.headers["content-type"]:
+ ctx.log.info(
+ "Working on {}".format(flow.response.headers["content-type"])
+ )
+
+ flow.response.decode()
+ html = flow.response.text
+
+ with open(
+ path.join(path.dirname(__file__), "catapult/deterministic.js"), "r"
+ ) as jsfile:
+
+ js = jsfile.read().replace(
+ "REPLACE_LOAD_TIMESTAMP", str(self.millis)
+ )
+
+ if js not in html:
+ script_index = re.search("(?i).*?<head.*?>", html)
+ if script_index is None:
+ script_index = re.search("(?i).*?<html.*?>", html)
+ if script_index is None:
+ script_index = re.search("(?i).*?<!doctype html>", html)
+ if script_index is None:
+ ctx.log.info(
+ "No start tags found in request {}. Skip injecting".format(
+ flow.request.url
+ )
+ )
+ return
+ script_index = script_index.end()
+
+ nonce = None
+ for test_header in [
+ "Content-Security-Policy",
+ "Content-Security-Policy-Report-Only",
+ ]:
+ if flow.response.headers.get(test_header, False):
+ nonce = self.get_nonce_from_headers(
+ test_header, flow.response.headers
+ )
+ ctx.log.info("nonce : %s" % nonce)
+
+ if (
+ self.get_csp_script_sources(
+ test_header, flow.response.headers
+ )
+ and not nonce
+ ):
+ # generate sha256 for the script
+ hash_object = hashlib.sha256(js.encode("utf-8"))
+ script_sha256 = base64.b64encode(
+ hash_object.digest()
+ ).decode("utf-8")
+
+ # generate the new response headers
+ updated_script_sources = self.update_csp_script_src(
+ test_header,
+ flow.response.headers,
+ script_sha256,
+ )
+ flow.response.headers = self.get_new_csp_header(
+ test_header,
+ flow.response.headers,
+ updated_script_sources,
+ )
+
+ # generate new html file
+ new_html = (
+ html[:script_index]
+ + self.get_script_with_nonce(js, nonce)
+ + html[script_index:]
+ )
+ flow.response.text = new_html
+
+ ctx.log.info(
+ "In request {} injected deterministic JS".format(
+ flow.request.url
+ )
+ )
+ else:
+ ctx.log.info(
+ "Script already injected in request {}".format(
+ flow.request.url
+ )
+ )
+
+
+addons = [AddDeterministic()]
diff --git a/testing/mozbase/mozproxy/mozproxy/driver.py b/testing/mozbase/mozproxy/mozproxy/driver.py
new file mode 100644
index 0000000000..aa56c5142a
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/driver.py
@@ -0,0 +1,117 @@
+# 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/.
+from __future__ import absolute_import
+
+import argparse
+import os
+import signal
+import sys
+
+import mozinfo
+import mozlog.commandline
+
+from . import get_playback
+from .utils import LOG, TOOLTOOL_PATHS
+
+EXIT_SUCCESS = 0
+EXIT_EARLY_TERMINATE = 3
+EXIT_EXCEPTION = 4
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument(
+ "--local", action="store_true", help="run this locally (i.e. not in production)"
+ )
+ parser.add_argument("--record", default=False, help="generate a proxy recording")
+ parser.add_argument(
+ "--tool",
+ default="mitmproxy",
+ help="the playback tool to use (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--tool-version",
+ default="4.0.4",
+ help="the playback tool version to use (default: %(default)s)",
+ )
+ parser.add_argument(
+ "--host", default="localhost", help="the host to use for the proxy server"
+ )
+ parser.add_argument(
+ "--binary",
+ required=True,
+ help=("the path to the binary being tested (typically " "firefox)"),
+ )
+ parser.add_argument(
+ "--topsrcdir",
+ required=True,
+ help="the top of the source directory for this project",
+ )
+ parser.add_argument(
+ "--objdir", required=True, help="the object directory for this build"
+ )
+ parser.add_argument(
+ "--app", default="firefox", help="the app being tested (default: %(default)s)"
+ )
+ parser.add_argument(
+ "playback",
+ nargs="*",
+ help="The playback files to use. "
+ "It can be any combination of the following: zip file, manifest file,"
+ "or a URL to zip/manifest file.",
+ )
+
+ mozlog.commandline.add_logging_group(parser)
+
+ args = parser.parse_args()
+ mozlog.commandline.setup_logging("mozproxy", args, {"raw": sys.stdout})
+
+ TOOLTOOL_PATHS.append(
+ os.path.join(
+ args.topsrcdir, "python", "mozbuild", "mozbuild", "action", "tooltool.py"
+ )
+ )
+
+ if hasattr(signal, "SIGBREAK"):
+ # Terminating on windows is slightly different than other platforms.
+ # On POSIX, we just let Python's default SIGINT handler raise a
+ # KeyboardInterrupt. This doesn't work on Windows, so instead we wait
+ # for a Ctrl+Break event and raise our own KeyboardInterrupt.
+ def handle_sigbreak(sig, frame):
+ raise KeyboardInterrupt()
+
+ signal.signal(signal.SIGBREAK, handle_sigbreak)
+
+ try:
+ playback = get_playback(
+ {
+ "run_local": args.local,
+ "host": args.host,
+ "binary": args.binary,
+ "obj_path": args.objdir,
+ "platform": mozinfo.os,
+ "playback_tool": args.tool,
+ "playback_version": args.tool_version,
+ "playback_files": args.playback,
+ "record": args.record,
+ "app": args.app,
+ }
+ )
+ playback.start()
+ LOG.info("Proxy running on port %d" % playback.port)
+ # Wait for a keyboard interrupt from the caller so we know when to
+ # terminate.
+ playback.wait()
+ return EXIT_EARLY_TERMINATE
+ except KeyboardInterrupt:
+ LOG.info("Terminating mozproxy")
+ playback.stop()
+ return EXIT_SUCCESS
+ except Exception as e:
+ LOG.error(str(e), exc_info=True)
+ return EXIT_EXCEPTION
+
+
+if __name__ == "__main__":
+ main()
diff --git a/testing/mozbase/mozproxy/mozproxy/recordings.py b/testing/mozbase/mozproxy/mozproxy/recordings.py
new file mode 100644
index 0000000000..1f36d8d6fb
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/recordings.py
@@ -0,0 +1,163 @@
+# 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/.
+from __future__ import absolute_import
+
+import json
+import os
+import shutil
+from datetime import datetime
+from shutil import copyfile
+from zipfile import ZipFile
+
+from .utils import LOG
+
+
+class RecordingFile:
+ def __init__(self, path_to_zip_file):
+ self._recording_zip_path = path_to_zip_file
+
+ self._base_name = os.path.splitext(os.path.basename(self._recording_zip_path))[
+ 0
+ ]
+ if not os.path.splitext(path_to_zip_file)[1] == ".zip":
+ LOG.error(
+ "Wrong file type! The provided recording should be a zip file. %s"
+ % path_to_zip_file
+ )
+ raise Exception(
+ "Wrong file type! The provided recording should be a zip file."
+ )
+
+ # create a temp dir
+ self._mozproxy_dir = os.environ["MOZPROXY_DIR"]
+ self._tempdir = os.path.join(self._mozproxy_dir, self._base_name)
+
+ if os.path.exists(self._tempdir):
+ LOG.info(
+ "The recording dir already exists! Resetting the existing dir and data."
+ )
+ shutil.rmtree(self._tempdir)
+ os.mkdir(self._tempdir)
+
+ self._metadata_path = self._get_temp_path("metadata.json")
+ self._recording = self._get_temp_path("dump.mp")
+
+ if os.path.exists(path_to_zip_file):
+ with ZipFile(path_to_zip_file, "r") as zipObj:
+ # Extract all the contents of zip file in different directory
+ zipObj.extractall(self._tempdir)
+
+ if not os.path.exists(self._recording):
+ self._convert_to_new_format()
+
+ if not os.path.exists(self._metadata_path):
+ LOG.error("metadata file is missing!")
+ raise Exception("metadata file is missing!")
+
+ with open(self._metadata_path) as json_file:
+ self._metadata = json.load(json_file)
+ self.validate_recording()
+
+ else:
+ LOG.info("Recording file does not exists!!! Generating base structure")
+ self._metadata = {"content": [], "recording_date": str(datetime.now())}
+
+ def _convert_to_new_format(self):
+ # Convert zip recording to new format
+
+ LOG.info("Convert zip recording to new format")
+
+ for tmp_file in os.listdir(self._tempdir):
+ if tmp_file.endswith(".mp"):
+ LOG.info("Renaming %s to dump.mp file" % tmp_file)
+ os.rename(self._get_temp_path(tmp_file), self._get_temp_path("dump.mp"))
+ elif tmp_file.endswith(".json"):
+ if tmp_file.startswith("mitm_netlocs_"):
+ LOG.info("Renaming %s to netlocs.json file" % tmp_file)
+ os.rename(
+ self._get_temp_path("%s.json" % os.path.splitext(tmp_file)[0]),
+ self._get_temp_path("netlocs.json"),
+ )
+ else:
+ LOG.info("Renaming %s to metadata.json file" % tmp_file)
+ os.rename(
+ self._get_temp_path("%s.json" % os.path.splitext(tmp_file)[0]),
+ self._get_temp_path("metadata.json"),
+ )
+ elif tmp_file.endswith(".png"):
+ LOG.info("Renaming %s to screenshot.png file" % tmp_file)
+ os.rename(
+ self._get_temp_path("%s.png" % os.path.splitext(tmp_file)[0]),
+ self._get_temp_path("screenshot.png"),
+ )
+
+ def _get_temp_path(self, file_name):
+ return os.path.join(self._tempdir, file_name)
+
+ def validate_recording(self):
+ # Validates that minimum zip file content exists
+ if not os.path.exists(self._recording):
+ LOG.error("Recording file is missing!")
+ raise Exception("Recording file is missing!")
+
+ if not os.path.exists(self._metadata_path):
+ LOG.error("Metadata file is missing!")
+ raise Exception("Metadata file is missing!")
+
+ if "content" in self._metadata:
+ # check that all extra files specified in the recording are present
+ for content_file in self._metadata["content"]:
+ if not os.path.exists(self._get_temp_path(content_file)):
+ LOG.error("File %s does not exist!!" % content_file)
+ raise Exception("Recording file is missing!")
+ else:
+ LOG.info("Old file type! Not confirming content!")
+
+ def metadata(self, name):
+ # Return metadata value
+ return self._metadata[name]
+
+ def set_metadata(self, entry, value):
+ # Set metadata value
+ self._metadata[entry] = value
+
+ @property
+ def recording_path(self):
+ # Returns the path of the recoring.mp file included in the zip
+ return self._recording
+
+ def get_file(self, file_name):
+ # Returns the path to a specified file included in the recording zip
+ return self._get_temp_path(file_name)
+
+ def add_file(self, path):
+ # Adds file to Zip
+ if os.path.exists(path):
+ copyfile(path, self._tempdir)
+ self._metadata["content"].append(os.path.basename(path))
+ else:
+ LOG.error("Target file %s does not exist!!" % path)
+ raise Exception("File does not exist!!!")
+
+ def update_metadata(self):
+ # Update metadata with information generated by HttpProtocolExtractor plugin
+ # This data is geerated after closing the MitMPtoxy process
+
+ dump_file = self._get_temp_path("dump.json")
+ if os.path.exists(dump_file):
+ with open(dump_file) as dump:
+ dump_info = json.load(dump)
+ self._metadata.update(dump_info)
+
+ def generate_zip_file(self):
+ self.update_metadata()
+ with open(self._get_temp_path(self._metadata_path), "w") as metadata_file:
+ json.dump(self._metadata, metadata_file)
+
+ with ZipFile(self._recording_zip_path, "w") as zf:
+ zf.write(self._metadata_path, "metadata.json")
+ zf.write(self.recording_path, "dump.mp")
+ for file in self._metadata["content"]:
+ zf.write(self._get_temp_path(file), file)
+ LOG.info("Generated new recording file at: %s" % self._recording_zip_path)
diff --git a/testing/mozbase/mozproxy/mozproxy/server.py b/testing/mozbase/mozproxy/mozproxy/server.py
new file mode 100644
index 0000000000..60ec44e146
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/server.py
@@ -0,0 +1,17 @@
+# 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/.
+
+from __future__ import absolute_import
+from mozproxy.backends.mitm.android import MitmproxyAndroid
+from mozproxy.backends.mitm.desktop import MitmproxyDesktop
+
+_BACKENDS = {"mitmproxy": MitmproxyDesktop, "mitmproxy-android": MitmproxyAndroid}
+
+
+def get_backend(name, *args, **kw):
+ """Returns the class that implements the backend.
+
+ Raises KeyError in case the backend does not exists.
+ """
+ return _BACKENDS[name](*args, **kw)
diff --git a/testing/mozbase/mozproxy/mozproxy/utils.py b/testing/mozbase/mozproxy/mozproxy/utils.py
new file mode 100644
index 0000000000..1ef5b5d8cc
--- /dev/null
+++ b/testing/mozbase/mozproxy/mozproxy/utils.py
@@ -0,0 +1,247 @@
+"""Utility functions for Raptor"""
+# 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/.
+
+from __future__ import absolute_import
+
+import subprocess
+import time
+import bz2
+import gzip
+import os
+import signal
+import sys
+import socket
+
+from six.moves.urllib.request import urlretrieve
+from redo import retriable, retry
+
+try:
+ import zstandard
+except ImportError:
+ zstandard = None
+try:
+ import lzma
+except ImportError:
+ lzma = None
+
+from mozlog import get_proxy_logger
+from mozprocess import ProcessHandler
+from mozproxy import mozharness_dir
+
+
+LOG = get_proxy_logger(component="mozproxy")
+
+# running locally via mach
+TOOLTOOL_PATHS = [os.path.join(mozharness_dir, "external_tools", "tooltool.py")]
+
+if "MOZ_UPLOAD_DIR" in os.environ:
+ TOOLTOOL_PATHS.append(
+ os.path.join(
+ os.environ["MOZ_UPLOAD_DIR"],
+ "..",
+ "..",
+ "mozharness",
+ "external_tools",
+ "tooltool.py",
+ )
+ )
+
+
+def transform_platform(str_to_transform, config_platform, config_processor=None):
+ """Transform platform name i.e. 'mitmproxy-rel-bin-{platform}.manifest'
+
+ transforms to 'mitmproxy-rel-bin-osx.manifest'.
+ Also transform '{x64}' if needed for 64 bit / win 10
+ """
+ if "{platform}" not in str_to_transform and "{x64}" not in str_to_transform:
+ return str_to_transform
+
+ if "win" in config_platform:
+ platform_id = "win"
+ elif config_platform == "mac":
+ platform_id = "osx"
+ else:
+ platform_id = "linux64"
+
+ if "{platform}" in str_to_transform:
+ str_to_transform = str_to_transform.replace("{platform}", platform_id)
+
+ if "{x64}" in str_to_transform and config_processor is not None:
+ if "x86_64" in config_processor:
+ str_to_transform = str_to_transform.replace("{x64}", "_x64")
+ else:
+ str_to_transform = str_to_transform.replace("{x64}", "")
+
+ return str_to_transform
+
+
+@retriable(sleeptime=2)
+def tooltool_download(manifest, run_local, raptor_dir):
+ """Download a file from tooltool using the provided tooltool manifest"""
+
+ def outputHandler(line):
+ LOG.info(line)
+
+ tooltool_path = None
+
+ for path in TOOLTOOL_PATHS:
+ if os.path.exists(os.path.dirname(path)):
+ tooltool_path = path
+ break
+
+ if tooltool_path is None:
+ raise Exception("Could not find tooltool path!")
+
+ if run_local:
+ command = [sys.executable, tooltool_path, "fetch", "-o", "-m", manifest]
+ else:
+ # Attempt to determine the tooltool cache path:
+ # - TOOLTOOLCACHE is used by Raptor tests
+ # - TOOLTOOL_CACHE is automatically set for taskcluster jobs
+ # - fallback to a hardcoded path
+ _cache = next(
+ x
+ for x in (
+ os.environ.get("TOOLTOOLCACHE"),
+ os.environ.get("TOOLTOOL_CACHE"),
+ "/builds/tooltool_cache",
+ )
+ if x is not None
+ )
+
+ command = [
+ sys.executable,
+ tooltool_path,
+ "fetch",
+ "-o",
+ "-m",
+ manifest,
+ "-c",
+ _cache,
+ ]
+
+ try:
+ proc = ProcessHandler(
+ command, processOutputLine=outputHandler, storeOutput=False, cwd=raptor_dir
+ )
+ proc.run()
+ if proc.wait() != 0:
+ raise Exception("Command failed")
+ except Exception as e:
+ LOG.critical(
+ "Error while downloading {} from tooltool:{}".format(manifest, str(e))
+ )
+ if proc.poll() is None:
+ proc.kill(signal.SIGTERM)
+ raise
+
+
+def archive_type(path):
+ filename, extension = os.path.splitext(path)
+ filename, extension2 = os.path.splitext(filename)
+ if extension2 != "":
+ extension = extension2
+ if extension == ".tar":
+ return "tar"
+ elif extension == ".zip":
+ return "zip"
+ return None
+
+
+def extract_archive(path, dest_dir, typ):
+ """Extract an archive to a destination directory."""
+
+ # Resolve paths to absolute variants.
+ path = os.path.abspath(path)
+ dest_dir = os.path.abspath(dest_dir)
+ suffix = os.path.splitext(path)[-1]
+
+ # We pipe input to the decompressor program so that we can apply
+ # custom decompressors that the program may not know about.
+ if typ == "tar":
+ if suffix == ".bz2":
+ ifh = bz2.open(str(path), "rb")
+ elif suffix == ".gz":
+ ifh = gzip.open(str(path), "rb")
+ elif suffix == ".xz":
+ if not lzma:
+ raise ValueError("lzma Python package not available")
+ ifh = lzma.open(str(path), "rb")
+ elif suffix == ".zst":
+ if not zstandard:
+ raise ValueError("zstandard Python package not available")
+ dctx = zstandard.ZstdDecompressor()
+ ifh = dctx.stream_reader(path.open("rb"))
+ elif suffix == ".tar":
+ ifh = path.open("rb")
+ else:
+ raise ValueError("unknown archive format for tar file: %s" % path)
+ args = ["tar", "xf", "-"]
+ pipe_stdin = True
+ elif typ == "zip":
+ # unzip from stdin has wonky behavior. We don't use a pipe for it.
+ ifh = open(os.devnull, "rb")
+ args = ["unzip", "-o", str(path)]
+ pipe_stdin = False
+ else:
+ raise ValueError("unknown archive format: %s" % path)
+
+ LOG.info("Extracting %s to %s using %r" % (path, dest_dir, args))
+ t0 = time.time()
+ with ifh:
+ p = subprocess.Popen(args, cwd=str(dest_dir), bufsize=0, stdin=subprocess.PIPE)
+ while True:
+ if not pipe_stdin:
+ break
+ chunk = ifh.read(131072)
+ if not chunk:
+ break
+ p.stdin.write(chunk)
+ # make sure we wait for the command to finish
+ p.communicate()
+
+ if p.returncode:
+ raise Exception("%r exited %d" % (args, p.returncode))
+ LOG.info("%s extracted in %.3fs" % (path, time.time() - t0))
+
+
+def download_file_from_url(url, local_dest, extract=False):
+ """Receive a file in a URL and download it, i.e. for the hostutils tooltool manifest
+ the url received would be formatted like this:
+ config/tooltool-manifests/linux64/hostutils.manifest"""
+ if os.path.exists(local_dest):
+ LOG.info("file already exists at: %s" % local_dest)
+ if not extract:
+ return True
+ else:
+ LOG.info("downloading: %s to %s" % (url, local_dest))
+ try:
+ retry(urlretrieve, args=(url, local_dest), attempts=3, sleeptime=5)
+ except Exception:
+ LOG.error("Failed to download file: %s" % local_dest, exc_info=True)
+ if os.path.exists(local_dest):
+ # delete partial downloaded file
+ os.remove(local_dest)
+ return False
+
+ if not extract:
+ return os.path.exists(local_dest)
+
+ typ = archive_type(local_dest)
+ if typ is None:
+ LOG.info("Not able to determine archive type for: %s" % local_dest)
+ return False
+
+ extract_archive(local_dest, os.path.dirname(local_dest), typ)
+ return True
+
+
+def get_available_port():
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ s.bind(("", 0))
+ s.listen(1)
+ port = s.getsockname()[1]
+ s.close()
+ return port
diff --git a/testing/mozbase/mozproxy/setup.py b/testing/mozbase/mozproxy/setup.py
new file mode 100644
index 0000000000..9fc4ef4526
--- /dev/null
+++ b/testing/mozbase/mozproxy/setup.py
@@ -0,0 +1,39 @@
+# 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/.
+
+from __future__ import absolute_import
+
+from setuptools import setup
+
+PACKAGE_NAME = "mozproxy"
+PACKAGE_VERSION = "1.0"
+
+# dependencies
+deps = ["redo", "mozinfo", "mozlog >= 6.0"]
+
+setup(
+ name=PACKAGE_NAME,
+ version=PACKAGE_VERSION,
+ description="Proxy for playback",
+ long_description="see https://firefox-source-docs.mozilla.org/mozbase/index.html",
+ classifiers=[
+ "Programming Language :: Python :: 2.7",
+ "Programming Language :: Python :: 3.5",
+ ],
+ # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+ keywords="mozilla",
+ author="Mozilla Automation and Tools team",
+ author_email="tools@lists.mozilla.org",
+ url="https://wiki.mozilla.org/Auto-tools/Projects/Mozbase",
+ license="MPL",
+ packages=["mozproxy"],
+ install_requires=deps,
+ entry_points={
+ "console_scripts": [
+ "mozproxy=mozproxy.driver:main",
+ ],
+ },
+ include_package_data=True,
+ zip_safe=False,
+)
diff --git a/testing/mozbase/mozproxy/tests/__init__.py b/testing/mozbase/mozproxy/tests/__init__.py
new file mode 100644
index 0000000000..792d600548
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/__init__.py
@@ -0,0 +1 @@
+#
diff --git a/testing/mozbase/mozproxy/tests/archive.tar.gz b/testing/mozbase/mozproxy/tests/archive.tar.gz
new file mode 100644
index 0000000000..b4f9461b09
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/archive.tar.gz
Binary files differ
diff --git a/testing/mozbase/mozproxy/tests/example.dump b/testing/mozbase/mozproxy/tests/example.dump
new file mode 100644
index 0000000000..aee6249ac7
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/example.dump
Binary files differ
diff --git a/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest
new file mode 100644
index 0000000000..0060b25393
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.manifest
@@ -0,0 +1,10 @@
+[
+ {
+ "algorithm": "sha512",
+ "digest": "d801dc23873ef5fac668aa58fa948f5de0d9f3ccc53d6773fb5a137515bd04e72cc8c0c7975c6e1fc19c72b3d721effb5432fce78b0ca6f3a90f2d6467ee5b68",
+ "filename": "mitm5-linux-firefox-amazon.zip",
+ "size": 6588776,
+ "unpack": true,
+ "visibility": "public"
+ }
+]
diff --git a/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip
new file mode 100644
index 0000000000..8c724762d3
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/files/mitm5-linux-firefox-amazon.zip
Binary files differ
diff --git a/testing/mozbase/mozproxy/tests/files/recording.zip b/testing/mozbase/mozproxy/tests/files/recording.zip
new file mode 100644
index 0000000000..7cea81a5e6
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/files/recording.zip
Binary files differ
diff --git a/testing/mozbase/mozproxy/tests/firefox b/testing/mozbase/mozproxy/tests/firefox
new file mode 100644
index 0000000000..4a938f06f7
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/firefox
@@ -0,0 +1 @@
+# I am firefox
diff --git a/testing/mozbase/mozproxy/tests/manifest.ini b/testing/mozbase/mozproxy/tests/manifest.ini
new file mode 100644
index 0000000000..51817ac77a
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/manifest.ini
@@ -0,0 +1,10 @@
+[DEFAULT]
+subsuite = mozbase
+[test_proxy.py]
+[test_utils.py]
+[test_command_line.py]
+run-if = python == 3 # The mozproxy command line interface is designed to run on Python 3.
+[test_recordings.py]
+[test_recording.py]
+[test_mitm_addons.py]
+run-if = python == 3 # The mitm addons are designed to run on Python 3.
diff --git a/testing/mozbase/mozproxy/tests/paypal.mp b/testing/mozbase/mozproxy/tests/paypal.mp
new file mode 100644
index 0000000000..8e48bd50de
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/paypal.mp
@@ -0,0 +1 @@
+# fake recorded playback
diff --git a/testing/mozbase/mozproxy/tests/support.py b/testing/mozbase/mozproxy/tests/support.py
new file mode 100644
index 0000000000..6471c080ef
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/support.py
@@ -0,0 +1,15 @@
+from __future__ import absolute_import, print_function
+import shutil
+import contextlib
+import tempfile
+
+
+# This helper can be replaced by pytest tmpdir fixture
+# once Bug 1536029 lands (@mock.patch disturbs pytest fixtures)
+@contextlib.contextmanager
+def tempdir():
+ dest_dir = tempfile.mkdtemp()
+ try:
+ yield dest_dir
+ finally:
+ shutil.rmtree(dest_dir, ignore_errors=True)
diff --git a/testing/mozbase/mozproxy/tests/test_command_line.py b/testing/mozbase/mozproxy/tests/test_command_line.py
new file mode 100644
index 0000000000..aef9d94e3e
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_command_line.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+import json
+import os
+import re
+import signal
+import threading
+import time
+
+import mozunit
+import pytest
+from mozbuild.base import MozbuildObject
+from mozprocess import ProcessHandler
+
+here = os.path.dirname(__file__)
+
+
+# This is copied from <python/mozperftest/mozperftest/utils.py>. It's copied
+# instead of imported since mozperfest is Python 3, and this file is
+# (currently) Python 2.
+def _install_package(virtualenv_manager, package):
+ from pip._internal.req.constructors import install_req_from_line
+
+ req = install_req_from_line(package)
+ req.check_if_exists(use_user_site=False)
+ # already installed, check if it's in our venv
+ if req.satisfied_by is not None:
+ venv_site_lib = os.path.abspath(
+ os.path.join(virtualenv_manager.bin_path, "..", "lib")
+ )
+ site_packages = os.path.abspath(req.satisfied_by.location)
+ if site_packages.startswith(venv_site_lib):
+ # already installed in this venv, we can skip
+ return
+ virtualenv_manager._run_pip(["install", package])
+
+
+def _kill_mozproxy(pid):
+ kill_signal = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT)
+ os.kill(pid, kill_signal)
+
+
+class OutputHandler(object):
+ def __init__(self):
+ self.port = None
+ self.port_event = threading.Event()
+
+ def __call__(self, line):
+ if not line.strip():
+ return
+ line = line.decode("utf-8", errors="replace")
+ # Print the output we received so we have useful logs if a test fails.
+ print(line)
+
+ try:
+ data = json.loads(line)
+ except ValueError:
+ return
+
+ if isinstance(data, dict) and "action" in data:
+ # Retrieve the port number for the proxy server from the logs of
+ # our subprocess.
+ m = re.match(r"Proxy running on port (\d+)", data.get("message", ""))
+ if m:
+ self.port = m.group(1)
+ self.port_event.set()
+
+ def finished(self):
+ self.port_event.set()
+
+
+@pytest.fixture(scope="module")
+def install_mozproxy():
+ build = MozbuildObject.from_environment(cwd=here, virtualenv_name="python-test")
+ build.virtualenv_manager.activate()
+
+ mozbase = os.path.join(build.topsrcdir, "testing", "mozbase")
+ mozproxy_deps = ["mozinfo", "mozlog", "mozproxy"]
+ for i in mozproxy_deps:
+ _install_package(build.virtualenv_manager, os.path.join(mozbase, i))
+ return build
+
+
+def test_help(install_mozproxy):
+ p = ProcessHandler(["mozproxy", "--help"])
+ p.run()
+ assert p.wait() == 0
+
+
+def test_run(install_mozproxy):
+ build = install_mozproxy
+ output_handler = OutputHandler()
+ p = ProcessHandler(
+ [
+ "mozproxy",
+ "--local",
+ "--binary=firefox",
+ "--topsrcdir=" + build.topsrcdir,
+ "--objdir=" + build.topobjdir,
+ os.path.join(here, "files", "mitm5-linux-firefox-amazon.zip"),
+ ],
+ processOutputLine=output_handler,
+ onFinish=output_handler.finished,
+ )
+ p.run()
+ # The first time we run mozproxy, we need to fetch mitmproxy, which can
+ # take a while...
+ assert output_handler.port_event.wait(120) is True
+ # Give mitmproxy a bit of time to start up so we can verify that it's
+ # actually running before we kill mozproxy.
+ time.sleep(5)
+ _kill_mozproxy(p.pid)
+
+ assert p.wait(10) == 0
+ assert output_handler.port is not None
+
+
+def test_failure(install_mozproxy):
+ output_handler = OutputHandler()
+ p = ProcessHandler(
+ [
+ "mozproxy",
+ "--local",
+ # Exclude some options here to trigger a command-line error.
+ os.path.join(here, "files", "mitm5-linux-firefox-amazon.zip"),
+ ],
+ processOutputLine=output_handler,
+ onFinish=output_handler.finished,
+ )
+ p.run()
+ assert output_handler.port_event.wait(10) is True
+ assert p.wait(10) == 2
+ assert output_handler.port is None
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")
diff --git a/testing/mozbase/mozproxy/tests/test_mitm_addons.py b/testing/mozbase/mozproxy/tests/test_mitm_addons.py
new file mode 100644
index 0000000000..bcde35fc81
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_mitm_addons.py
@@ -0,0 +1,91 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+
+import json
+import os
+
+import mock
+import mozunit
+
+here = os.path.dirname(__file__)
+os.environ["MOZPROXY_DIR"] = os.path.join(here, "files")
+
+protocol = {
+ "http_protocol": {"aax-us-iad.amazon.com": "HTTP/1.1"},
+ "recorded_requests": 4,
+ "recorded_requests_unique": 1,
+}
+
+
+@mock.patch(
+ "mozproxy.backends.mitm.scripts.http_protocol_extractor.HttpProtocolExtractor.get_ctx"
+)
+def test_http_protocol_generate_json_file(ctx_mock):
+ ctx_mock.return_value.options.save_stream_file = os.path.join(
+ os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.mp"
+ )
+
+ from mozproxy.backends.mitm.scripts.http_protocol_extractor import (
+ HttpProtocolExtractor,
+ )
+
+ test_http_protocol = HttpProtocolExtractor()
+ test_http_protocol.ctx = test_http_protocol.get_ctx()
+
+ # test data
+ test_http_protocol.request_protocol = protocol["http_protocol"]
+ test_http_protocol.hashes = ["Hash string"]
+ test_http_protocol.request_count = protocol["recorded_requests"]
+
+ test_http_protocol.done()
+
+ json_path = os.path.join(
+ os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.json"
+ )
+ assert os.path.exists(json_path)
+ with open(json_path) as json_file:
+ output_data = json.load(json_file)
+
+ assert output_data["recorded_requests"] == protocol["recorded_requests"]
+ assert (
+ output_data["recorded_requests_unique"]
+ == protocol["recorded_requests_unique"]
+ )
+ assert output_data["http_protocol"] == protocol["http_protocol"]
+
+
+@mock.patch(
+ "mozproxy.backends.mitm.scripts.http_protocol_extractor.HttpProtocolExtractor.get_ctx"
+)
+def test_http_protocol_response(ctx_mock):
+ ctx_mock.return_value.options.save_stream_file = os.path.join(
+ os.environ["MOZPROXY_DIR"], "http_protocol_recording_done.mp"
+ )
+
+ from mozproxy.backends.mitm.scripts.http_protocol_extractor import (
+ HttpProtocolExtractor,
+ )
+
+ test_http_protocol = HttpProtocolExtractor()
+ test_http_protocol.ctx = test_http_protocol.get_ctx()
+
+ # test data
+ flow = mock.MagicMock()
+ flow.type = "http"
+ flow.request.url = "https://www.google.com/complete/search"
+ flow.request.port = 33
+ flow.response.data.http_version = b"HTTP/1.1"
+
+ test_http_protocol.request_protocol = {}
+ test_http_protocol.hashes = []
+ test_http_protocol.request_count = 0
+
+ test_http_protocol.response(flow)
+
+ assert test_http_protocol.request_count == 1
+ assert len(test_http_protocol.hashes) == 1
+ assert test_http_protocol.request_protocol["www.google.com"] == "HTTP/1.1"
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")
diff --git a/testing/mozbase/mozproxy/tests/test_proxy.py b/testing/mozbase/mozproxy/tests/test_proxy.py
new file mode 100644
index 0000000000..f9b80aeb8a
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_proxy.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+import os
+
+import mock
+import mozunit
+import mozinfo
+import requests
+from mozproxy import get_playback
+from support import tempdir
+
+here = os.path.dirname(__file__)
+
+
+class Process:
+ def __init__(self, *args, **kw):
+ pass
+
+ def run(self):
+ print("I am running something")
+
+ def poll(self):
+ return None
+
+ def wait(self):
+ return 0
+
+ def kill(self, sig=9):
+ pass
+
+ proc = object()
+ pid = 1234
+ stderr = stdout = None
+ returncode = 0
+
+
+_RETRY = 0
+
+
+class ProcessWithRetry(Process):
+ def __init__(self, *args, **kw):
+ Process.__init__(self, *args, **kw)
+
+ def wait(self):
+ global _RETRY
+ _RETRY += 1
+ if _RETRY >= 2:
+ _RETRY = 0
+ return 0
+ return -1
+
+
+def kill(pid, signal):
+ if pid == 1234:
+ return
+ return os.kill(pid, signal)
+
+
+def get_status_code(url, playback):
+ response = requests.get(
+ url=url, proxies={"http": "http://%s:%s/" % (playback.host, playback.port)}
+ )
+ return response.status_code
+
+
+def test_mitm_check_proxy(*args):
+ # test setup
+ pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest")
+
+ config = {
+ "playback_tool": "mitmproxy",
+ "playback_files": [os.path.join(here, "files", pageset_name)],
+ "playback_version": "5.1.1",
+ "platform": mozinfo.os,
+ "run_local": "MOZ_AUTOMATION" not in os.environ,
+ "binary": "firefox",
+ "app": "firefox",
+ "host": "127.0.0.1",
+ }
+
+ with tempdir() as obj_path:
+ config["obj_path"] = obj_path
+ playback = get_playback(config)
+ assert playback is not None
+
+ try:
+ playback.start()
+
+ url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js"
+ assert get_status_code(url, playback) == 200
+
+ url = "http://mozproxy/checkProxy"
+ assert get_status_code(url, playback) == 404
+ finally:
+ playback.stop()
+
+
+@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy")
+@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=Process)
+@mock.patch("mozproxy.utils.ProcessHandler", new=Process)
+@mock.patch("os.kill", new=kill)
+def test_mitm(*args):
+ pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest")
+
+ config = {
+ "playback_tool": "mitmproxy",
+ "playback_files": [pageset_name],
+ "playback_version": "5.1.1",
+ "platform": mozinfo.os,
+ "run_local": True,
+ "binary": "firefox",
+ "app": "firefox",
+ "host": "example.com",
+ }
+
+ with tempdir() as obj_path:
+ config["obj_path"] = obj_path
+ playback = get_playback(config)
+ assert playback is not None
+ try:
+ playback.start()
+ finally:
+ playback.stop()
+
+
+@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy")
+@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=Process)
+@mock.patch("mozproxy.utils.ProcessHandler", new=Process)
+@mock.patch("os.kill", new=kill)
+def test_playback_setup_failed(*args):
+ class SetupFailed(Exception):
+ pass
+
+ def setup(*args, **kw):
+ def _s(self):
+ raise SetupFailed("Failed")
+
+ return _s
+
+ pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest")
+
+ config = {
+ "playback_tool": "mitmproxy",
+ "playback_files": [pageset_name],
+ "playback_version": "4.0.4",
+ "platform": mozinfo.os,
+ "run_local": True,
+ "binary": "firefox",
+ "app": "firefox",
+ "host": "example.com",
+ }
+
+ prefix = "mozproxy.backends.mitm.desktop.MitmproxyDesktop."
+
+ with tempdir() as obj_path:
+ config["obj_path"] = obj_path
+ with mock.patch(prefix + "setup", new_callable=setup):
+ with mock.patch(prefix + "stop_mitmproxy_playback") as p:
+ try:
+ pb = get_playback(config)
+ pb.start()
+ except SetupFailed:
+ assert p.call_count == 1
+ except Exception:
+ raise
+
+
+@mock.patch("mozproxy.backends.mitm.Mitmproxy.check_proxy")
+@mock.patch("mozproxy.backends.mitm.mitm.ProcessHandler", new=ProcessWithRetry)
+@mock.patch("mozproxy.utils.ProcessHandler", new=ProcessWithRetry)
+@mock.patch("os.kill", new=kill)
+def test_mitm_with_retry(*args):
+ pageset_name = os.path.join(here, "files", "mitm5-linux-firefox-amazon.manifest")
+
+ config = {
+ "playback_tool": "mitmproxy",
+ "playback_files": [pageset_name],
+ "playback_version": "5.1.1",
+ "platform": mozinfo.os,
+ "run_local": True,
+ "binary": "firefox",
+ "app": "firefox",
+ "host": "example.com",
+ }
+
+ with tempdir() as obj_path:
+ config["obj_path"] = obj_path
+ playback = get_playback(config)
+ assert playback is not None
+ try:
+ playback.start()
+ finally:
+ playback.stop()
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")
diff --git a/testing/mozbase/mozproxy/tests/test_recording.py b/testing/mozbase/mozproxy/tests/test_recording.py
new file mode 100644
index 0000000000..d47a1714b0
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_recording.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+
+import os
+
+import mozinfo
+import mozunit
+import requests
+from mozproxy import get_playback
+from support import tempdir
+
+here = os.path.dirname(__file__)
+os.environ["MOZPROXY_DIR"] = os.path.join(here, "files")
+
+
+def get_status_code(url, playback):
+ response = requests.get(
+ url=url, proxies={"http": "http://%s:%s/" % (playback.host, playback.port)}
+ )
+ return response.status_code
+
+
+def test_record_and_replay(*args):
+ # test setup
+ recording_file = os.path.join(here, "files", "new_recoding.zip")
+
+ # Record part
+ config = {
+ "playback_tool": "mitmproxy",
+ "recording_file": recording_file,
+ "playback_version": "5.1.1",
+ "platform": mozinfo.os,
+ "run_local": "MOZ_AUTOMATION" not in os.environ,
+ "binary": "firefox",
+ "app": "firefox",
+ "host": "127.0.0.1",
+ "record": True,
+ }
+
+ with tempdir() as obj_path:
+ config["obj_path"] = obj_path
+ record = get_playback(config)
+ assert record is not None
+
+ try:
+ record.start()
+
+ url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js"
+ assert get_status_code(url, record) == 200
+ finally:
+ record.stop()
+
+ # playback part
+ config["record"] = False
+ config["recording_file"] = None
+ config["playback_files"] = [recording_file]
+ playback = get_playback(config)
+ assert playback is not None
+ try:
+ playback.start()
+
+ url = "https://m.media-amazon.com/images/G/01/csm/showads.v2.js"
+ assert get_status_code(url, playback) == 200
+ finally:
+ playback.stop()
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")
diff --git a/testing/mozbase/mozproxy/tests/test_recordings.py b/testing/mozbase/mozproxy/tests/test_recordings.py
new file mode 100644
index 0000000000..fe321bcf13
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_recordings.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+
+import os
+
+import mozunit
+from mozproxy.recordings import RecordingFile
+
+here = os.path.dirname(__file__)
+os.environ["MOZPROXY_DIR"] = os.path.join(here, "files")
+
+
+def test_recording_generation(*args):
+ test_file = os.path.join(here, "files", "new_file.zip")
+ file = RecordingFile(test_file)
+ with open(file.recording_path, "w") as recording:
+ recording.write("This is a recording")
+
+ file.set_metadata("test_file", True)
+ file.generate_zip_file()
+
+ assert os.path.exists(test_file)
+ os.remove(test_file)
+
+
+def test_recording_content(*args):
+ test_file = os.path.join(here, "files", "recording.zip")
+ file = RecordingFile(test_file)
+
+ assert file.metadata("test_file") is True
+ assert os.path.exists(file.recording_path)
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")
diff --git a/testing/mozbase/mozproxy/tests/test_utils.py b/testing/mozbase/mozproxy/tests/test_utils.py
new file mode 100644
index 0000000000..967517c0f1
--- /dev/null
+++ b/testing/mozbase/mozproxy/tests/test_utils.py
@@ -0,0 +1,34 @@
+#!/usr/bin/env python
+from __future__ import absolute_import, print_function
+
+import os
+import shutil
+import mock
+import mozunit
+
+from mozproxy.utils import download_file_from_url
+from support import tempdir
+
+here = os.path.dirname(__file__)
+
+
+def urlretrieve(*args, **kw):
+ def _urlretrieve(url, local_dest):
+ # simply copy over our tarball
+ shutil.copyfile(os.path.join(here, "archive.tar.gz"), local_dest)
+ return local_dest, {}
+
+ return _urlretrieve
+
+
+@mock.patch("mozproxy.utils.urlretrieve", new_callable=urlretrieve)
+def test_download_file(*args):
+ with tempdir() as dest_dir:
+ dest = os.path.join(dest_dir, "archive.tar.gz")
+ download_file_from_url("http://example.com/archive.tar.gz", dest, extract=True)
+ # archive.tar.gz contains hey.txt, if it worked we should see it
+ assert os.path.exists(os.path.join(dest_dir, "hey.txt"))
+
+
+if __name__ == "__main__":
+ mozunit.main(runwith="pytest")