Path: blob/trunk/py/selenium/webdriver/firefox/firefox_profile.py
4095 views
# Licensed to the Software Freedom Conservancy (SFC) under one1# or more contributor license agreements. See the NOTICE file2# distributed with this work for additional information3# regarding copyright ownership. The SFC licenses this file4# to you under the Apache License, Version 2.0 (the5# "License"); you may not use this file except in compliance6# with the License. You may obtain a copy of the License at7#8# http://www.apache.org/licenses/LICENSE-2.09#10# Unless required by applicable law or agreed to in writing,11# software distributed under the License is distributed on an12# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY13# KIND, either express or implied. See the License for the14# specific language governing permissions and limitations15# under the License.1617import base6418import copy19import json20import os21import re22import shutil23import sys24import tempfile25import warnings26import zipfile27from io import BytesIO28from xml.dom import minidom2930from typing_extensions import deprecated3132from selenium.common.exceptions import WebDriverException3334WEBDRIVER_PREFERENCES = "webdriver_prefs.json"353637@deprecated("Addons must be added after starting the session")38class AddonFormatError(Exception):39"""Exception for not well-formed add-on manifest files."""404142class FirefoxProfile:43DEFAULT_PREFERENCES = None4445def __init__(self, profile_directory=None):46"""Initialises a new instance of a Firefox Profile.4748Args:49profile_directory: Directory of profile that you want to use. If a50directory is passed in it will be cloned and the cloned directory51will be used by the driver when instantiated.52This defaults to None and will create a new53directory when object is created.54"""55self._desired_preferences = {}56if profile_directory:57newprof = os.path.join(tempfile.mkdtemp(), "webdriver-py-profilecopy")58shutil.copytree(59profile_directory, newprof, ignore=shutil.ignore_patterns("parent.lock", "lock", ".parentlock")60)61self._profile_dir = newprof62os.chmod(self._profile_dir, 0o755)63else:64self._profile_dir = tempfile.mkdtemp()65if not FirefoxProfile.DEFAULT_PREFERENCES:66with open(67os.path.join(os.path.dirname(__file__), WEBDRIVER_PREFERENCES), encoding="utf-8"68) as default_prefs:69FirefoxProfile.DEFAULT_PREFERENCES = json.load(default_prefs)7071self._desired_preferences = copy.deepcopy(FirefoxProfile.DEFAULT_PREFERENCES["mutable"])72for key, value in FirefoxProfile.DEFAULT_PREFERENCES["frozen"].items():73self._desired_preferences[key] = value7475# Public Methods76def set_preference(self, key, value):77"""Sets the preference that we want in the profile."""78self._desired_preferences[key] = value7980@deprecated("Addons must be added after starting the session")81def add_extension(self, extension=None):82self._install_extension(extension)8384def update_preferences(self):85"""Writes the desired user prefs to disk."""86user_prefs = os.path.join(self._profile_dir, "user.js")87if os.path.isfile(user_prefs):88os.chmod(user_prefs, 0o644)89self._read_existing_userjs(user_prefs)90with open(user_prefs, "w", encoding="utf-8") as f:91for key, value in self._desired_preferences.items():92f.write(f'user_pref("{key}", {json.dumps(value)});\n')9394# Properties9596@property97def path(self):98"""Gets the profile directory that is currently being used."""99return self._profile_dir100101@property102@deprecated("The port is stored in the Service class")103def port(self):104"""Gets the port that WebDriver is working on."""105return self._port106107@port.setter108@deprecated("The port is stored in the Service class")109def port(self, port) -> None:110"""Sets the port that WebDriver will be running on."""111if not isinstance(port, int):112raise WebDriverException("Port needs to be an integer")113try:114port = int(port)115if port < 1 or port > 65535:116raise WebDriverException("Port number must be in the range 1..65535")117except (ValueError, TypeError):118raise WebDriverException("Port needs to be an integer")119self._port = port120self.set_preference("webdriver_firefox_port", self._port)121122@property123@deprecated("Allowing untrusted certs is toggled in the Options class")124def accept_untrusted_certs(self):125return self._desired_preferences["webdriver_accept_untrusted_certs"]126127@accept_untrusted_certs.setter128@deprecated("Allowing untrusted certs is toggled in the Options class")129def accept_untrusted_certs(self, value) -> None:130if not isinstance(value, bool):131raise WebDriverException("Please pass in a Boolean to this call")132self.set_preference("webdriver_accept_untrusted_certs", value)133134@property135@deprecated("Allowing untrusted certs is toggled in the Options class")136def assume_untrusted_cert_issuer(self):137return self._desired_preferences["webdriver_assume_untrusted_issuer"]138139@assume_untrusted_cert_issuer.setter140@deprecated("Allowing untrusted certs is toggled in the Options class")141def assume_untrusted_cert_issuer(self, value) -> None:142if not isinstance(value, bool):143raise WebDriverException("Please pass in a Boolean to this call")144145self.set_preference("webdriver_assume_untrusted_issuer", value)146147@property148def encoded(self) -> str:149"""Update preferences and create a zipped, base64-encoded profile directory string."""150if self._desired_preferences:151self.update_preferences()152fp = BytesIO()153with zipfile.ZipFile(fp, "w", zipfile.ZIP_DEFLATED, strict_timestamps=False) as zipped:154path_root = len(self.path) + 1 # account for trailing slash155for base, _, files in os.walk(self.path):156for fyle in files:157filename = os.path.join(base, fyle)158zipped.write(filename, filename[path_root:])159return base64.b64encode(fp.getvalue()).decode("UTF-8")160161def _read_existing_userjs(self, userjs):162"""Read existing preferences and add them to the desired preference dictionary."""163pref_pattern = re.compile(r'user_pref\("(.*)",\s(.*)\)')164with open(userjs, encoding="utf-8") as f:165for usr in f:166matches = pref_pattern.search(usr)167try:168self._desired_preferences[matches.group(1)] = json.loads(matches.group(2))169except Exception:170warnings.warn(171f"(skipping) failed to json.loads existing preference: {matches.group(1) + matches.group(2)}"172)173174@deprecated("Addons must be added after starting the session")175def _install_extension(self, addon, unpack=True):176"""Install addon from a filepath, URL, or directory of addons in the profile.177178Args:179addon: url, absolute path to .xpi, or directory of addons180unpack: whether to unpack unless specified otherwise in the install.rdf181"""182tmpdir = None183xpifile = None184if addon.endswith(".xpi"):185tmpdir = tempfile.mkdtemp(suffix="." + os.path.split(addon)[-1])186compressed_file = zipfile.ZipFile(addon, "r")187for name in compressed_file.namelist():188if name.endswith("/"):189if not os.path.isdir(os.path.join(tmpdir, name)):190os.makedirs(os.path.join(tmpdir, name))191else:192if not os.path.isdir(os.path.dirname(os.path.join(tmpdir, name))):193os.makedirs(os.path.dirname(os.path.join(tmpdir, name)))194data = compressed_file.read(name)195with open(os.path.join(tmpdir, name), "wb") as f:196f.write(data)197xpifile = addon198addon = tmpdir199200# determine the addon id201addon_details = self._addon_details(addon)202addon_id = addon_details.get("id")203assert addon_id, f"The addon id could not be found: {addon}"204205# copy the addon to the profile206extensions_dir = os.path.join(self._profile_dir, "extensions")207addon_path = os.path.join(extensions_dir, addon_id)208if not unpack and not addon_details["unpack"] and xpifile:209if not os.path.exists(extensions_dir):210os.makedirs(extensions_dir)211os.chmod(extensions_dir, 0o755)212shutil.copy(xpifile, addon_path + ".xpi")213else:214if not os.path.exists(addon_path):215shutil.copytree(addon, addon_path, symlinks=True)216217# remove the temporary directory, if any218if tmpdir:219shutil.rmtree(tmpdir)220221@deprecated("Addons must be added after starting the session")222def _addon_details(self, addon_path):223"""Returns a dictionary of details about the addon.224225Args:226addon_path: path to the add-on directory or XPI227228Returns:229A dictionary containing:230231{232"id": "[email protected]", # id of the addon233"version": "1.4", # version of the addon234"name": "Rainbow", # name of the addon235"unpack": False,236} # whether to unpack the addon237"""238details = {"id": None, "unpack": False, "name": None, "version": None}239240def get_namespace_id(doc, url):241attributes = doc.documentElement.attributes242namespace = ""243for i in range(attributes.length):244if attributes.item(i).value == url:245if ":" in attributes.item(i).name:246# If the namespace is not the default one remove 'xlmns:'247namespace = attributes.item(i).name.split(":")[1] + ":"248break249return namespace250251def get_text(element):252"""Retrieve the text value of a given node."""253rc = []254for node in element.childNodes:255if node.nodeType == node.TEXT_NODE:256rc.append(node.data)257return "".join(rc).strip()258259def parse_manifest_json(content):260"""Extract details from the contents of a WebExtensions manifest.json file."""261manifest = json.loads(content)262try:263id = manifest["applications"]["gecko"]["id"]264except KeyError:265id = manifest["name"].replace(" ", "") + "@" + manifest["version"]266return {267"id": id,268"version": manifest["version"],269"name": manifest["version"],270"unpack": False,271}272273if not os.path.exists(addon_path):274raise OSError(f"Add-on path does not exist: {addon_path}")275276try:277if zipfile.is_zipfile(addon_path):278with zipfile.ZipFile(addon_path, "r") as compressed_file:279if "manifest.json" in compressed_file.namelist():280return parse_manifest_json(compressed_file.read("manifest.json"))281282manifest = compressed_file.read("install.rdf")283elif os.path.isdir(addon_path):284manifest_json_filename = os.path.join(addon_path, "manifest.json")285if os.path.exists(manifest_json_filename):286with open(manifest_json_filename, encoding="utf-8") as f:287return parse_manifest_json(f.read())288289with open(os.path.join(addon_path, "install.rdf"), encoding="utf-8") as f:290manifest = f.read()291else:292raise OSError(f"Add-on path is neither an XPI nor a directory: {addon_path}")293except (OSError, KeyError) as e:294raise AddonFormatError(str(e), sys.exc_info()[2])295296try:297doc = minidom.parseString(manifest)298299# Get the namespaces abbreviations300em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#")301rdf = get_namespace_id(doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#")302303description = doc.getElementsByTagName(rdf + "Description").item(0)304if not description:305description = doc.getElementsByTagName("Description").item(0)306for node in description.childNodes:307# Remove the namespace prefix from the tag for comparison308entry = node.nodeName.replace(em, "")309if entry in details:310details.update({entry: get_text(node)})311if not details.get("id"):312for i in range(description.attributes.length):313attribute = description.attributes.item(i)314if attribute.name == em + "id":315details.update({"id": attribute.value})316except Exception as e:317raise AddonFormatError(str(e), sys.exc_info()[2])318319# turn unpack into a true/false value320if isinstance(details["unpack"], str):321details["unpack"] = details["unpack"].lower() == "true"322323# If no ID is set, the add-on is invalid324if not details.get("id"):325raise AddonFormatError("Add-on id could not be found.")326327return details328329330