# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this file, # You can obtain one at http://mozilla.org/MPL/2.0/. import binascii import hashlib import json import os import shutil import sys import tempfile import zipfile from xml.dom import minidom import mozfile from mozlog.unstructured import getLogger _SALT = binascii.hexlify(os.urandom(32)) _TEMPORARY_ADDON_SUFFIX = "@temporary-addon" # Logger for 'mozprofile.addons' module module_logger = getLogger(__name__) class AddonFormatError(Exception): """Exception for not well-formed add-on manifest files""" class AddonManager: """ Handles all operations regarding addons in a profile including: installing and cleaning addons """ def __init__(self, profile, restore=True): """ :param profile: the path to the profile for which we install addons :param restore: whether to reset to the previous state on instance garbage collection """ self.profile = profile self.restore = restore # Initialize all class members self._internal_init() def _internal_init(self): """Internal: Initialize all class members to their default value""" # Add-ons installed; needed for cleanup self._addons = [] # Backup folder for already existing addons self.backup_dir = None # Information needed for profile reset (see http://bit.ly/17JesUf) self.installed_addons = [] def __del__(self): # reset to pre-instance state if self.restore: self.clean() def clean(self): """Clean up addons in the profile.""" # Remove all add-ons installed for addon in self._addons: # TODO (bug 934642) # Once we have a proper handling of add-ons we should kill the id # from self._addons once the add-on is removed. For now lets forget # about the exception try: self.remove_addon(addon) except OSError: pass # restore backups if self.backup_dir and os.path.isdir(self.backup_dir): extensions_path = os.path.join(self.profile, "extensions") for backup in os.listdir(self.backup_dir): backup_path = os.path.join(self.backup_dir, backup) shutil.move(backup_path, extensions_path) if not os.listdir(self.backup_dir): mozfile.remove(self.backup_dir) # reset instance variables to defaults self._internal_init() def get_addon_path(self, addon_id): """Returns the path to the installed add-on :param addon_id: id of the add-on to retrieve the path from """ # By default we should expect add-ons being located under the # extensions folder. extensions_path = os.path.join(self.profile, "extensions") paths = [ os.path.join(extensions_path, addon_id), os.path.join(extensions_path, addon_id + ".xpi"), ] for path in paths: if os.path.exists(path): return path raise OSError("Add-on not found: %s" % addon_id) @classmethod def is_addon(self, addon_path): """ Checks if the given path is a valid addon :param addon_path: path to the add-on directory or XPI """ try: self.addon_details(addon_path) return True except AddonFormatError: return False def _install_addon(self, path, unpack=False): addons = [path] # if path is not an add-on, try to install all contained add-ons try: self.addon_details(path) except AddonFormatError as e: module_logger.warning("Could not install %s: %s" % (path, str(e))) # If the path doesn't exist, then we don't really care, just return if not os.path.isdir(path): return addons = [ os.path.join(path, x) for x in os.listdir(path) if self.is_addon(os.path.join(path, x)) ] addons.sort() # install each addon for addon in addons: # determine the addon id addon_details = self.addon_details(addon) addon_id = addon_details.get("id") # if the add-on has to be unpacked force it now # note: we might want to let Firefox do it in case of addon details orig_path = None if os.path.isfile(addon) and (unpack or addon_details["unpack"]): orig_path = addon addon = tempfile.mkdtemp() mozfile.extract(orig_path, addon) # copy the addon to the profile extensions_path = os.path.join(self.profile, "extensions") addon_path = os.path.join(extensions_path, addon_id) if os.path.isfile(addon): addon_path += ".xpi" # move existing xpi file to backup location to restore later if os.path.exists(addon_path): self.backup_dir = self.backup_dir or tempfile.mkdtemp() shutil.move(addon_path, self.backup_dir) # copy new add-on to the extension folder if not os.path.exists(extensions_path): os.makedirs(extensions_path) shutil.copy(addon, addon_path) else: # move existing folder to backup location to restore later if os.path.exists(addon_path): self.backup_dir = self.backup_dir or tempfile.mkdtemp() shutil.move(addon_path, self.backup_dir) # copy new add-on to the extension folder shutil.copytree(addon, addon_path, symlinks=True) # if we had to extract the addon, remove the temporary directory if orig_path: mozfile.remove(addon) addon = orig_path self._addons.append(addon_id) self.installed_addons.append(addon) def install(self, addons, **kwargs): """ Installs addons from a filepath or directory of addons in the profile. :param addons: paths to .xpi or addon directories :param unpack: whether to unpack unless specified otherwise in the install.rdf """ if not addons: return # install addon paths if isinstance(addons, str): addons = [addons] for addon in set(addons): self._install_addon(addon, **kwargs) @classmethod def _gen_iid(cls, addon_path): hash = hashlib.sha1(_SALT) hash.update(addon_path.encode()) return hash.hexdigest() + _TEMPORARY_ADDON_SUFFIX @classmethod def addon_details(cls, addon_path): """ Returns a dictionary of details about the addon. :param addon_path: path to the add-on directory or XPI Returns:: { "id": "rainbow@colors.org", # id of the addon "version": "1.4", # version of the addon "name": "Rainbow", # name of the addon "unpack": False, } # whether to unpack the addon """ details = {"id": None, "unpack": False, "name": None, "version": None} def get_namespace_id(doc, url): attributes = doc.documentElement.attributes namespace = "" for i in range(attributes.length): if attributes.item(i).value == url: if ":" in attributes.item(i).name: # If the namespace is not the default one remove 'xlmns:' namespace = attributes.item(i).name.split(":")[1] + ":" break return namespace def get_text(element): """Retrieve the text value of a given node""" rc = [] for node in element.childNodes: if node.nodeType == node.TEXT_NODE: rc.append(node.data) return "".join(rc).strip() if not os.path.exists(addon_path): raise OSError("Add-on path does not exist: %s" % addon_path) is_webext = False try: if zipfile.is_zipfile(addon_path): with zipfile.ZipFile(addon_path, "r") as compressed_file: filenames = [f.filename for f in (compressed_file).filelist] if "install.rdf" in filenames: manifest = compressed_file.read("install.rdf") elif "manifest.json" in filenames: is_webext = True manifest = compressed_file.read("manifest.json").decode() manifest = json.loads(manifest) else: raise KeyError("No manifest") elif os.path.isdir(addon_path): entries = os.listdir(addon_path) # Beginning with https://phabricator.services.mozilla.com/D126174 # directories may exist that contain one single XPI. If that's # the case we need to process it just as we do above. if len(entries) == 1 and zipfile.is_zipfile( os.path.join(addon_path, entries[0]) ): with zipfile.ZipFile( os.path.join(addon_path, entries[0]), "r" ) as compressed_file: filenames = [f.filename for f in (compressed_file).filelist] if "install.rdf" in filenames: manifest = compressed_file.read("install.rdf") elif "manifest.json" in filenames: is_webext = True manifest = compressed_file.read("manifest.json").decode() manifest = json.loads(manifest) else: raise KeyError("No manifest") # Otherwise, treat is an already unpacked XPI. else: try: with open(os.path.join(addon_path, "install.rdf")) as f: manifest = f.read() except OSError: with open(os.path.join(addon_path, "manifest.json")) as f: manifest = json.loads(f.read()) is_webext = True else: raise OSError( "Add-on path is neither an XPI nor a directory: %s" % addon_path ) except (OSError, KeyError) as e: raise AddonFormatError(str(e)).with_traceback(sys.exc_info()[2]) if is_webext: details["version"] = manifest["version"] details["name"] = manifest["name"] # Bug 1572404 - we support two locations for gecko-specific # metadata. for location in ("applications", "browser_specific_settings"): try: details["id"] = manifest[location]["gecko"]["id"] break except KeyError: pass if details["id"] is None: details["id"] = cls._gen_iid(addon_path) details["unpack"] = False else: try: doc = minidom.parseString(manifest) # Get the namespaces abbreviations em = get_namespace_id(doc, "http://www.mozilla.org/2004/em-rdf#") rdf = get_namespace_id( doc, "http://www.w3.org/1999/02/22-rdf-syntax-ns#" ) description = doc.getElementsByTagName(rdf + "Description").item(0) for entry, value in description.attributes.items(): # Remove the namespace prefix from the tag for comparison entry = entry.replace(em, "") if entry in details.keys(): details.update({entry: value}) for node in description.childNodes: # Remove the namespace prefix from the tag for comparison entry = node.nodeName.replace(em, "") if entry in details.keys(): details.update({entry: get_text(node)}) except Exception as e: raise AddonFormatError(str(e)).with_traceback(sys.exc_info()[2]) # turn unpack into a true/false value if isinstance(details["unpack"], str): details["unpack"] = details["unpack"].lower() == "true" # If no ID is set, the add-on is invalid if details.get("id") is None and not is_webext: raise AddonFormatError("Add-on id could not be found.") return details def remove_addon(self, addon_id): """Remove the add-on as specified by the id :param addon_id: id of the add-on to be removed """ path = self.get_addon_path(addon_id) mozfile.remove(path)