# 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)