Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
"use strict";
/**
* This file contains most of the logic required to install extensions.
* In general, we try to avoid loading it until extension installation
* or update is required. Please keep that in mind when deciding whether
* to add code here or elsewhere.
*/
/**
* @typedef {number} integer
*/
/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
var EXPORTED_SYMBOLS = [
"UpdateChecker",
"XPIInstall",
"verifyBundleSignedState",
];
const { XPCOMUtils } = ChromeUtils.importESModule(
);
const { computeSha256HashAsString, getHashStringForCrypto } =
ChromeUtils.importESModule(
);
const { AppConstants } = ChromeUtils.importESModule(
);
const { AddonManager, AddonManagerPrivate } = ChromeUtils.importESModule(
);
const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ProductAddonChecker:
});
XPCOMUtils.defineLazyModuleGetters(lazy, {
});
XPCOMUtils.defineLazyGetter(lazy, "IconDetails", () => {
return ChromeUtils.importESModule(
).ExtensionParent.IconDetails;
});
const { nsIBlocklistService } = Ci;
const nsIFile = Components.Constructor(
"@mozilla.org/file/local;1",
"nsIFile",
"initWithPath"
);
const BinaryOutputStream = Components.Constructor(
"@mozilla.org/binaryoutputstream;1",
"nsIBinaryOutputStream",
"setOutputStream"
);
const CryptoHash = Components.Constructor(
"@mozilla.org/security/hash;1",
"nsICryptoHash",
"initWithString"
);
const FileInputStream = Components.Constructor(
"@mozilla.org/network/file-input-stream;1",
"nsIFileInputStream",
"init"
);
const FileOutputStream = Components.Constructor(
"@mozilla.org/network/file-output-stream;1",
"nsIFileOutputStream",
"init"
);
const ZipReader = Components.Constructor(
"@mozilla.org/libjar/zip-reader;1",
"nsIZipReader",
"open"
);
XPCOMUtils.defineLazyServiceGetters(lazy, {
gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
});
const PREF_INSTALL_REQUIRESECUREORIGIN =
"extensions.install.requireSecureOrigin";
const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
const PREF_SYSTEM_ADDON_UPDATE_URL = "extensions.systemAddon.update.url";
const PREF_XPI_ENABLED = "xpinstall.enabled";
const PREF_XPI_DIRECT_WHITELISTED = "xpinstall.whitelist.directRequest";
const PREF_XPI_FILE_WHITELISTED = "xpinstall.whitelist.fileRequest";
const PREF_XPI_WHITELIST_REQUIRED = "xpinstall.whitelist.required";
const PREF_SELECTED_THEME = "extensions.activeThemeID";
const TOOLKIT_ID = "toolkit@mozilla.org";
/**
* Returns a nsIFile instance for the given path, relative to the given
* base file, if provided.
*
* @param {string} path
* The (possibly relative) path of the file.
* @param {nsIFile} [base]
* An optional file to use as a base path if `path` is relative.
* @returns {nsIFile}
*/
function getFile(path, base = null) {
// First try for an absolute path, as we get in the case of proxy
// files. Ideally we would try a relative path first, but on Windows,
// paths which begin with a drive letter are valid as relative paths,
// and treated as such.
try {
return new nsIFile(path);
} catch (e) {
// Ignore invalid relative paths. The only other error we should see
// here is EOM, and either way, any errors that we care about should
// be re-thrown below.
}
// If the path isn't absolute, we must have a base path.
let file = base.clone();
file.appendRelativePath(path);
return file;
}
/**
* Sends local and remote notifications to flush a JAR file cache entry
*
* @param {nsIFile} aJarFile
* The ZIP/XPI/JAR file as a nsIFile
*/
function flushJarCache(aJarFile) {
Services.obs.notifyObservers(aJarFile, "flush-cache-entry");
Services.ppmm.broadcastAsyncMessage(MSG_JAR_FLUSH, {
path: aJarFile.path,
});
}
const PREF_EM_UPDATE_BACKGROUND_URL = "extensions.update.background.url";
const PREF_EM_UPDATE_URL = "extensions.update.url";
const PREF_XPI_SIGNATURES_DEV_ROOT = "xpinstall.signatures.dev-root";
const KEY_TEMPDIR = "TmpD";
// This is a random number array that can be used as "salt" when generating
// an automatic ID based on the directory path of an add-on. It will prevent
// someone from creating an ID for a permanent add-on that could be replaced
// by a temporary add-on (because that would be confusing, I guess).
const TEMP_INSTALL_ID_GEN_SESSION = new Uint8Array(
Float64Array.of(Math.random()).buffer
);
const MSG_JAR_FLUSH = "Extension:FlushJarCache";
/**
* Valid IDs fit this pattern.
*/
var gIDTest =
/^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
const { Log } = ChromeUtils.importESModule(
);
const LOGGER_ID = "addons.xpi";
// Create a new logger for use by all objects in this Addons XPI Provider module
// (Requires AddonManager.jsm)
var logger = Log.repository.getLogger(LOGGER_ID);
// Stores the ID of the theme which was selected during the last session,
// if any. When installing a new built-in theme with this ID, it will be
// automatically enabled.
let lastSelectedTheme = null;
function getJarURI(file, path = "") {
if (file instanceof Ci.nsIFile) {
file = Services.io.newFileURI(file);
}
if (file instanceof Ci.nsIURI) {
file = file.spec;
}
return Services.io.newURI(`jar:${file}!/${path}`);
}
let DirPackage;
let XPIPackage;
class Package {
static get(file) {
if (file.isFile()) {
return new XPIPackage(file);
}
return new DirPackage(file);
}
constructor(file, rootURI) {
this.file = file;
this.filePath = file.path;
this.rootURI = rootURI;
}
close() {}
async readString(...path) {
let buffer = await this.readBinary(...path);
return new TextDecoder().decode(buffer);
}
async verifySignedState(addonId, addonType, addonLocation) {
if (!shouldVerifySignedState(addonType, addonLocation)) {
return {
signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
cert: null,
};
}
let root = Ci.nsIX509CertDB.AddonsPublicRoot;
if (
!AppConstants.MOZ_REQUIRE_SIGNING &&
Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)
) {
root = Ci.nsIX509CertDB.AddonsStageRoot;
}
return this.verifySignedStateForRoot(addonId, root);
}
flushCache() {}
}
DirPackage = class DirPackage extends Package {
constructor(file) {
super(file, Services.io.newFileURI(file));
}
hasResource(...path) {
return IOUtils.exists(PathUtils.join(this.filePath, ...path));
}
async iterDirectory(path, callback) {
let fullPath = PathUtils.join(this.filePath, ...path);
let children = await IOUtils.getChildren(fullPath);
for (let path of children) {
let { type } = await IOUtils.stat(path);
callback({
isDir: type == "directory",
name: PathUtils.filename(path),
path,
});
}
}
iterFiles(callback, path = []) {
return this.iterDirectory(path, async entry => {
let entryPath = [...path, entry.name];
if (entry.isDir) {
callback({
path: entryPath.join("/"),
isDir: true,
});
await this.iterFiles(callback, entryPath);
} else {
callback({
path: entryPath.join("/"),
isDir: false,
});
}
});
}
readBinary(...path) {
return IOUtils.read(PathUtils.join(this.filePath, ...path));
}
async verifySignedStateForRoot(addonId, root) {
return { signedState: AddonManager.SIGNEDSTATE_UNKNOWN, cert: null };
}
};
XPIPackage = class XPIPackage extends Package {
constructor(file) {
super(file, getJarURI(file));
this.zipReader = new ZipReader(file);
}
close() {
this.zipReader.close();
this.zipReader = null;
this.flushCache();
}
async hasResource(...path) {
return this.zipReader.hasEntry(path.join("/"));
}
async iterFiles(callback) {
for (let path of this.zipReader.findEntries("*")) {
let entry = this.zipReader.getEntry(path);
callback({
path,
isDir: entry.isDirectory,
});
}
}
async readBinary(...path) {
let response = await fetch(this.rootURI.resolve(path.join("/")));
return response.arrayBuffer();
}
verifySignedStateForRoot(addonId, root) {
return new Promise(resolve => {
let callback = {
openSignedAppFileFinished(aRv, aZipReader, aCert) {
if (aZipReader) {
aZipReader.close();
}
resolve({
signedState: getSignedStatus(aRv, aCert, addonId),
cert: aCert,
});
},
};
// This allows the certificate DB to get the raw JS callback object so the
// test code can pass through objects that XPConnect would reject.
callback.wrappedJSObject = callback;
lazy.gCertDB.openSignedAppFileAsync(root, this.file, callback);
});
}
flushCache() {
flushJarCache(this.file);
}
};
/**
* Return an object that implements enough of the Package interface
* to allow loadManifest() to work for a built-in addon (ie, one loaded
* from a resource: url)
*
* @param {nsIURL} baseURL The URL for the root of the add-on.
* @returns {object}
*/
function builtinPackage(baseURL) {
return {
rootURI: baseURL,
filePath: baseURL.spec,
file: null,
verifySignedState() {
return {
signedState: AddonManager.SIGNEDSTATE_NOT_REQUIRED,
cert: null,
};
},
async hasResource(path) {
try {
let response = await fetch(this.rootURI.resolve(path));
return response.ok;
} catch (e) {
return false;
}
},
};
}
/**
* Determine the reason to pass to an extension's bootstrap methods when
* switch between versions.
*
* @param {string} oldVersion The version of the existing extension instance.
* @param {string} newVersion The version of the extension being installed.
*
* @returns {integer}
* BOOSTRAP_REASONS.ADDON_UPGRADE or BOOSTRAP_REASONS.ADDON_DOWNGRADE
*/
function newVersionReason(oldVersion, newVersion) {
return Services.vc.compare(oldVersion, newVersion) <= 0
? lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_UPGRADE
: lazy.XPIInternal.BOOTSTRAP_REASONS.ADDON_DOWNGRADE;
}
// Behaves like Promise.all except waits for all promises to resolve/reject
// before resolving/rejecting itself
function waitForAllPromises(promises) {
return new Promise((resolve, reject) => {
let shouldReject = false;
let rejectValue = null;
let newPromises = promises.map(p =>
p.catch(value => {
shouldReject = true;
rejectValue = value;
})
);
Promise.all(newPromises).then(results =>
shouldReject ? reject(rejectValue) : resolve(results)
);
});
}
/**
* Reads an AddonInternal object from a webextension manifest.json
*
* @param {Package} aPackage
* The install package for the add-on
* @param {XPIStateLocation} aLocation
* The install location the add-on is installed in, or will be
* installed to.
* @returns {{ addon: AddonInternal, verifiedSignedState: object}}
* @throws if the install manifest in the stream is corrupt or could not
* be read
*/
async function loadManifestFromWebManifest(aPackage, aLocation) {
let verifiedSignedState;
const temporarilyInstalled = aLocation.isTemporary;
let extension = await lazy.ExtensionData.constructAsync({
rootURI: lazy.XPIInternal.maybeResolveURI(aPackage.rootURI),
temporarilyInstalled,
async checkPrivileged(type, id) {
verifiedSignedState = await aPackage.verifySignedState(
id,
type,
aLocation
);
return lazy.ExtensionData.getIsPrivileged({
signedState: verifiedSignedState.signedState,
builtIn: aLocation.isBuiltin,
temporarilyInstalled,
});
},
});
let manifest = await extension.loadManifest();
// Read the list of available locales, and pre-load messages for
// all locales.
let locales = !extension.errors.length
? await extension.initAllLocales()
: null;
if (extension.errors.length) {
let error = new Error("Extension is invalid");
// Add detailed errors on the error object so that the front end can display them
// if needed (eg in about:debugging).
error.additionalErrors = extension.errors;
throw error;
}
// Internally, we use the `applications` key but it is because we assign the value
// of `browser_specific_settings` to `applications` in `ExtensionData.parseManifest()`.
// Yet, as of MV3, only `browser_specific_settings` is accepted in manifest.json files.
let bss = manifest.applications?.gecko || {};
// A * is illegal in strict_min_version
if (bss.strict_min_version?.split(".").some(part => part == "*")) {
throw new Error("The use of '*' in strict_min_version is invalid");
}
let addon = new lazy.AddonInternal();
addon.id = bss.id;
addon.version = manifest.version;
addon.manifestVersion = manifest.manifest_version;
addon.type = extension.type;
addon.loader = null;
addon.strictCompatibility = true;
addon.internalName = null;
addon.updateURL = bss.update_url;
addon.installOrigins = manifest.install_origins;
addon.optionsBrowserStyle = true;
addon.optionsURL = null;
addon.optionsType = null;
addon.aboutURL = null;
addon.dependencies = Object.freeze(Array.from(extension.dependencies));
addon.startupData = extension.startupData;
addon.hidden = extension.isPrivileged && manifest.hidden;
addon.incognito = manifest.incognito;
if (addon.type === "theme" && (await aPackage.hasResource("preview.png"))) {
addon.previewImage = "preview.png";
}
if (addon.type == "sitepermission-deprecated") {
addon.sitePermissions = manifest.site_permissions;
addon.siteOrigin = manifest.install_origins[0];
}
if (manifest.options_ui) {
// Store just the relative path here, the AddonWrapper getURL
// wrapper maps this to a full URL.
addon.optionsURL = manifest.options_ui.page;
if (manifest.options_ui.open_in_tab) {
addon.optionsType = AddonManager.OPTIONS_TYPE_TAB;
} else {
addon.optionsType = AddonManager.OPTIONS_TYPE_INLINE_BROWSER;
}
addon.optionsBrowserStyle = manifest.options_ui.browser_style;
}
// WebExtensions don't use iconURLs
addon.iconURL = null;
addon.icons = manifest.icons || {};
addon.userPermissions = extension.manifestPermissions;
addon.optionalPermissions = extension.manifestOptionalPermissions;
addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
function getLocale(aLocale) {
// Use the raw manifest, here, since we need values with their
// localization placeholders still in place.
let rawManifest = extension.rawManifest;
let creator =
typeof rawManifest.author === "string" ? rawManifest.author : null;
let homepageURL = rawManifest.homepage_url;
// Allow developer to override creator and homepage_url.
if (rawManifest.developer) {
if (rawManifest.developer.name) {
creator = rawManifest.developer.name;
}
if (rawManifest.developer.url) {
homepageURL = rawManifest.developer.url;
}
}
let result = {
name: extension.localize(rawManifest.name, aLocale),
description: extension.localize(rawManifest.description, aLocale),
creator: extension.localize(creator, aLocale),
homepageURL: extension.localize(homepageURL, aLocale),
developers: null,
translators: null,
contributors: null,
locales: [aLocale],
};
return result;
}
addon.defaultLocale = getLocale(extension.defaultLocale);
addon.locales = Array.from(locales.keys(), getLocale);
delete addon.defaultLocale.locales;
addon.targetApplications = [
{
id: TOOLKIT_ID,
minVersion: bss.strict_min_version,
maxVersion: bss.strict_max_version,
},
];
addon.targetPlatforms = [];
// Themes are disabled by default, except when they're installed from a web page.
addon.userDisabled = extension.type === "theme";
addon.softDisabled =
addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
return { addon, verifiedSignedState };
}
async function readRecommendationStates(aPackage, aAddonID) {
let recommendationData;
try {
recommendationData = await aPackage.readString(
"mozilla-recommendation.json"
);
} catch (e) {
// Ignore I/O errors.
return null;
}
try {
recommendationData = JSON.parse(recommendationData);
} catch (e) {
logger.warn("Failed to parse recommendation", e);
}
if (recommendationData) {
let { addon_id, states, validity } = recommendationData;
if (addon_id === aAddonID && Array.isArray(states) && validity) {
let validNotAfter = Date.parse(validity.not_after);
let validNotBefore = Date.parse(validity.not_before);
if (validNotAfter && validNotBefore) {
return {
validNotAfter,
validNotBefore,
states,
};
}
}
logger.warn(
`Invalid recommendation for ${aAddonID}: ${JSON.stringify(
recommendationData
)}`
);
}
return null;
}
function defineSyncGUID(aAddon) {
// Define .syncGUID as a lazy property which is also settable
Object.defineProperty(aAddon, "syncGUID", {
get: () => {
aAddon.syncGUID = Services.uuid.generateUUID().toString();
return aAddon.syncGUID;
},
set: val => {
delete aAddon.syncGUID;
aAddon.syncGUID = val;
},
configurable: true,
enumerable: true,
});
}
// Generate a unique ID based on the path to this temporary add-on location.
function generateTemporaryInstallID(aFile) {
const hasher = CryptoHash("sha1");
const data = new TextEncoder().encode(aFile.path);
// Make it so this ID cannot be guessed.
const sess = TEMP_INSTALL_ID_GEN_SESSION;
hasher.update(sess, sess.length);
hasher.update(data, data.length);
let id = `${getHashStringForCrypto(hasher)}${
lazy.XPIInternal.TEMPORARY_ADDON_SUFFIX
}`;
logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`);
return id;
}
var loadManifest = async function (aPackage, aLocation, aOldAddon) {
let addon;
let verifiedSignedState;
if (await aPackage.hasResource("manifest.json")) {
({ addon, verifiedSignedState } = await loadManifestFromWebManifest(
aPackage,
aLocation
));
} else {
for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) {
if (await aPackage.hasResource(loader.manifestFile)) {
addon = await loader.loadManifest(aPackage);
addon.loader = loader.name;
verifiedSignedState = await aPackage.verifySignedState(
addon.id,
addon.type,
aLocation
);
break;
}
}
}
if (!addon) {
throw new Error(
`File ${aPackage.filePath} does not contain a valid manifest`
);
}
addon._sourceBundle = aPackage.file;
addon.rootURI = aPackage.rootURI.spec;
addon.location = aLocation;
let { signedState, cert } = verifiedSignedState;
addon.signedState = signedState;
addon.signedDate = cert?.validity?.notBefore / 1000 || null;
if (!addon.id) {
if (cert) {
addon.id = cert.commonName;
if (!gIDTest.test(addon.id)) {
throw new Error(`Extension is signed with an invalid id (${addon.id})`);
}
}
if (!addon.id && aLocation.isTemporary) {
addon.id = generateTemporaryInstallID(aPackage.file);
}
}
addon.propagateDisabledState(aOldAddon);
if (!aLocation.isSystem && !aLocation.isBuiltin) {
if (addon.type === "extension" && !aLocation.isTemporary) {
addon.recommendationState = await readRecommendationStates(
aPackage,
addon.id
);
}
await addon.updateBlocklistState();
addon.appDisabled = !lazy.XPIDatabase.isUsableAddon(addon);
// Always report when there is an attempt to install a blocked add-on.
// (transitions from STATE_BLOCKED to STATE_NOT_BLOCKED are checked
// in the individual AddonInstall subclasses).
if (addon.blocklistState == nsIBlocklistService.STATE_BLOCKED) {
addon.recordAddonBlockChangeTelemetry(
aOldAddon ? "addon_update" : "addon_install"
);
}
}
defineSyncGUID(addon);
return addon;
};
/**
* Loads an add-on's manifest from the given file or directory.
*
* @param {nsIFile} aFile
* The file to load the manifest from.
* @param {XPIStateLocation} aLocation
* The install location the add-on is installed in, or will be
* installed to.
* @param {AddonInternal?} aOldAddon
* The currently-installed add-on with the same ID, if one exist.
* This is used to migrate user settings like the add-on's
* disabled state.
* @returns {AddonInternal}
* The parsed Addon object for the file's manifest.
*/
var loadManifestFromFile = async function (aFile, aLocation, aOldAddon) {
let pkg = Package.get(aFile);
try {
let addon = await loadManifest(pkg, aLocation, aOldAddon);
return addon;
} finally {
pkg.close();
}
};
/*
* A synchronous method for loading an add-on's manifest. Do not use
* this.
*/
function syncLoadManifest(state, location, oldAddon) {
if (location.name == "app-builtin") {
let pkg = builtinPackage(Services.io.newURI(state.rootURI));
return lazy.XPIInternal.awaitPromise(loadManifest(pkg, location, oldAddon));
}
let file = new nsIFile(state.path);
let pkg = Package.get(file);
return lazy.XPIInternal.awaitPromise(
(async () => {
try {
let addon = await loadManifest(pkg, location, oldAddon);
addon.rootURI = lazy.XPIInternal.getURIForResourceInFile(file, "").spec;
return addon;
} finally {
pkg.close();
}
})()
);
}
/**
* Creates and returns a new unique temporary file. The caller should delete
* the file when it is no longer needed.
*
* @returns {nsIFile}
* An nsIFile that points to a randomly named, initially empty file in
* the OS temporary files directory
*/
function getTemporaryFile() {
let file = lazy.FileUtils.getDir(KEY_TEMPDIR, []);
let random = Math.round(Math.random() * 36 ** 3).toString(36);
file.append(`tmp-${random}.xpi`);
file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, lazy.FileUtils.PERMS_FILE);
return file;
}
function getHashForFile(file, algorithm) {
let crypto = CryptoHash(algorithm);
let fis = new FileInputStream(file, -1, -1, false);
try {
crypto.updateFromStream(fis, file.fileSize);
} finally {
fis.close();
}
return getHashStringForCrypto(crypto);
}
/**
* Returns the signedState for a given return code and certificate by verifying
* it against the expected ID.
*
* @param {nsresult} aRv
* The result code returned by the signature checker for the
* signature check operation.
* @param {nsIX509Cert?} aCert
* The certificate the add-on was signed with, if a valid
* certificate exists.
* @param {string?} aAddonID
* The expected ID of the add-on. If passed, this must match the
* ID in the certificate's CN field.
* @returns {number}
* A SIGNEDSTATE result code constant, as defined on the
* AddonManager class.
*/
function getSignedStatus(aRv, aCert, aAddonID) {
let expectedCommonName = aAddonID;
if (aAddonID && aAddonID.length > 64) {
expectedCommonName = computeSha256HashAsString(aAddonID);
}
switch (aRv) {
case Cr.NS_OK:
if (expectedCommonName && expectedCommonName != aCert.commonName) {
return AddonManager.SIGNEDSTATE_BROKEN;
}
if (aCert.organizationalUnit == "Mozilla Components") {
return AddonManager.SIGNEDSTATE_SYSTEM;
}
if (aCert.organizationalUnit == "Mozilla Extensions") {
return AddonManager.SIGNEDSTATE_PRIVILEGED;
}
return /preliminary/i.test(aCert.organizationalUnit)
? AddonManager.SIGNEDSTATE_PRELIMINARY
: AddonManager.SIGNEDSTATE_SIGNED;
case Cr.NS_ERROR_SIGNED_JAR_NOT_SIGNED:
return AddonManager.SIGNEDSTATE_MISSING;
case Cr.NS_ERROR_SIGNED_JAR_MANIFEST_INVALID:
case Cr.NS_ERROR_SIGNED_JAR_ENTRY_INVALID:
case Cr.NS_ERROR_SIGNED_JAR_ENTRY_MISSING:
case Cr.NS_ERROR_SIGNED_JAR_ENTRY_TOO_LARGE:
case Cr.NS_ERROR_SIGNED_JAR_UNSIGNED_ENTRY:
case Cr.NS_ERROR_SIGNED_JAR_MODIFIED_ENTRY:
return AddonManager.SIGNEDSTATE_BROKEN;
default:
// Any other error indicates that either the add-on isn't signed or it
// is signed by a signature that doesn't chain to the trusted root.
return AddonManager.SIGNEDSTATE_UNKNOWN;
}
}
function shouldVerifySignedState(aAddonType, aLocation) {
// TODO when KEY_APP_SYSTEM_DEFAULTS and KEY_APP_SYSTEM_ADDONS locations
// are removed, we need to reorganize the logic here. At that point we
// should:
// if builtin or MOZ_UNSIGNED_SCOPES return false
// if system return true
// return SIGNED_TYPES.has(type)
// We don't care about signatures for default system add-ons
if (aLocation.name == lazy.XPIInternal.KEY_APP_SYSTEM_DEFAULTS) {
return false;
}
// Updated system add-ons should always have their signature checked
if (aLocation.isSystem) {
return true;
}
if (
aLocation.isBuiltin ||
aLocation.scope & AppConstants.MOZ_UNSIGNED_SCOPES
) {
return false;
}
// Otherwise only check signatures if the add-on is one of the signed
// types.
return lazy.XPIDatabase.SIGNED_TYPES.has(aAddonType);
}
/**
* Verifies that a bundle's contents are all correctly signed by an
* AMO-issued certificate
*
* @param {nsIFile} aBundle
* The nsIFile for the bundle to check, either a directory or zip file.
* @param {AddonInternal} aAddon
* The add-on object to verify.
* @returns {Promise<number>}
* A Promise that resolves to an AddonManager.SIGNEDSTATE_* constant.
*/
var verifyBundleSignedState = async function (aBundle, aAddon) {
let pkg = Package.get(aBundle);
try {
let { signedState } = await pkg.verifySignedState(
aAddon.id,
aAddon.type,
aAddon.location
);
return signedState;
} finally {
pkg.close();
}
};
/**
* Replaces %...% strings in an addon url (update and updateInfo) with
* appropriate values.
*
* @param {AddonInternal} aAddon
* The AddonInternal representing the add-on
* @param {string} aUri
* The URI to escape
* @param {integer?} aUpdateType
* An optional number representing the type of update, only applicable
* when creating a url for retrieving an update manifest
* @param {string?} aAppVersion
* The optional application version to use for %APP_VERSION%
* @returns {string}
* The appropriately escaped URI.
*/
function escapeAddonURI(aAddon, aUri, aUpdateType, aAppVersion) {
let uri = AddonManager.escapeAddonURI(aAddon, aUri, aAppVersion);
// If there is an updateType then replace the UPDATE_TYPE string
if (aUpdateType) {
uri = uri.replace(/%UPDATE_TYPE%/g, aUpdateType);
}
// If this add-on has compatibility information for either the current
// application or toolkit then replace the ITEM_MAXAPPVERSION with the
// maxVersion
let app = aAddon.matchingTargetApplication;
if (app) {
var maxVersion = app.maxVersion;
} else {
maxVersion = "";
}
uri = uri.replace(/%ITEM_MAXAPPVERSION%/g, maxVersion);
let compatMode = "normal";
if (!AddonManager.checkCompatibility) {
compatMode = "ignore";
} else if (AddonManager.strictCompatibility) {
compatMode = "strict";
}
uri = uri.replace(/%COMPATIBILITY_MODE%/g, compatMode);
return uri;
}
/**
* Converts an iterable of addon objects into a map with the add-on's ID as key.
*
* @param {sequence<AddonInternal>} addons
* A sequence of AddonInternal objects.
*
* @returns {Map<string, AddonInternal>}
*/
function addonMap(addons) {
return new Map(addons.map(a => [a.id, a]));
}
async function removeAsync(aFile) {
await IOUtils.remove(aFile.path, { ignoreAbsent: true, recursive: true });
}
/**
* Recursively removes a directory or file fixing permissions when necessary.
*
* @param {nsIFile} aFile
* The nsIFile to remove
*/
function recursiveRemove(aFile) {
let isDir = null;
try {
isDir = aFile.isDirectory();
} catch (e) {
// If the file has already gone away then don't worry about it, this can
// happen on OSX where the resource fork is automatically moved with the
if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) {
return;
}
throw e;
}
setFilePermissions(
aFile,
isDir ? lazy.FileUtils.PERMS_DIRECTORY : lazy.FileUtils.PERMS_FILE
);
try {
aFile.remove(true);
return;
} catch (e) {
if (!aFile.isDirectory() || aFile.isSymlink()) {
logger.error("Failed to remove file " + aFile.path, e);
throw e;
}
}
// Use a snapshot of the directory contents to avoid possible issues with
// iterating over a directory while removing files from it (the YAFFS2
let entries = Array.from(lazy.XPIInternal.iterDirectory(aFile));
entries.forEach(recursiveRemove);
try {
aFile.remove(true);
} catch (e) {
logger.error("Failed to remove empty directory " + aFile.path, e);
throw e;
}
}
/**
* Sets permissions on a file
*
* @param {nsIFile} aFile
* The file or directory to operate on.
* @param {integer} aPermissions
* The permissions to set
*/
function setFilePermissions(aFile, aPermissions) {
try {
aFile.permissions = aPermissions;
} catch (e) {
logger.warn(
"Failed to set permissions " +
aPermissions.toString(8) +
" on " +
aFile.path,
e
);
}
}
/**
* Write a given string to a file
*
* @param {nsIFile} file
* The nsIFile instance to write into
* @param {string} string
* The string to write
*/
function writeStringToFile(file, string) {
let fileStream = new FileOutputStream(
file,
lazy.FileUtils.MODE_WRONLY |
lazy.FileUtils.MODE_CREATE |
lazy.FileUtils.MODE_TRUNCATE,
lazy.FileUtils.PERMS_FILE,
0
);
try {
let binStream = new BinaryOutputStream(fileStream);
binStream.writeByteArray(new TextEncoder().encode(string));
} finally {
fileStream.close();
}
}
/**
* A safe way to install a file or the contents of a directory to a new
* directory. The file or directory is moved or copied recursively and if
* anything fails an attempt is made to rollback the entire operation. The
* operation may also be rolled back to its original state after it has
* completed by calling the rollback method.
*
* Operations can be chained. Calling move or copy multiple times will remember
* the whole set and if one fails all of the operations will be rolled back.
*/
function SafeInstallOperation() {
this._installedFiles = [];
this._createdDirs = [];
}
SafeInstallOperation.prototype = {
_installedFiles: null,
_createdDirs: null,
_installFile(aFile, aTargetDirectory, aCopy) {
let oldFile = aCopy ? null : aFile.clone();
let newFile = aFile.clone();
try {
if (aCopy) {
newFile.copyTo(aTargetDirectory, null);
// copyTo does not update the nsIFile with the new.
newFile = getFile(aFile.leafName, aTargetDirectory);
// Windows roaming profiles won't properly sync directories if a new file
// has an older lastModifiedTime than a previous file, so update.
newFile.lastModifiedTime = Date.now();
} else {
newFile.moveTo(aTargetDirectory, null);
}
} catch (e) {
logger.error(
"Failed to " +
(aCopy ? "copy" : "move") +
" file " +
aFile.path +
" to " +
aTargetDirectory.path,
e
);
throw e;
}
this._installedFiles.push({ oldFile, newFile });
},
/**
* Moves a file or directory into a new directory. If an error occurs then all
* files that have been moved will be moved back to their original location.
*
* @param {nsIFile} aFile
* The file or directory to be moved.
* @param {nsIFile} aTargetDirectory
* The directory to move into, this is expected to be an empty
* directory.
*/
moveUnder(aFile, aTargetDirectory) {
try {
this._installFile(aFile, aTargetDirectory, false);
} catch (e) {
this.rollback();
throw e;
}
},
/**
* Renames a file to a new location. If an error occurs then all
* files that have been moved will be moved back to their original location.
*
* @param {nsIFile} aOldLocation
* The old location of the file.
* @param {nsIFile} aNewLocation
* The new location of the file.
*/
moveTo(aOldLocation, aNewLocation) {
try {
let oldFile = aOldLocation.clone(),
newFile = aNewLocation.clone();
oldFile.moveTo(newFile.parent, newFile.leafName);
this._installedFiles.push({ oldFile, newFile, isMoveTo: true });
} catch (e) {
this.rollback();
throw e;
}
},
/**
* Copies a file or directory into a new directory. If an error occurs then
* all new files that have been created will be removed.
*
* @param {nsIFile} aFile
* The file or directory to be copied.
* @param {nsIFile} aTargetDirectory
* The directory to copy into, this is expected to be an empty
* directory.
*/
copy(aFile, aTargetDirectory) {
try {
this._installFile(aFile, aTargetDirectory, true);
} catch (e) {
this.rollback();
throw e;
}
},
/**
* Rolls back all the moves that this operation performed. If an exception
* occurs here then both old and new directories are left in an indeterminate
* state
*/
rollback() {
while (this._installedFiles.length) {
let move = this._installedFiles.pop();
if (move.isMoveTo) {
move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
} else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
oldDir.create(
Ci.nsIFile.DIRECTORY_TYPE,
lazy.FileUtils.PERMS_DIRECTORY
);
} else if (!move.oldFile) {
// No old file means this was a copied file
move.newFile.remove(true);
} else {
move.newFile.moveTo(move.oldFile.parent, null);
}
}
while (this._createdDirs.length) {
recursiveRemove(this._createdDirs.pop());
}
},
};
// A hash algorithm if the caller of AddonInstall did not specify one.
const DEFAULT_HASH_ALGO = "sha256";
/**
* Base class for objects that manage the installation of an addon.
* This class isn't instantiated directly, see the derived classes below.
*/
class AddonInstall {
/**
* Instantiates an AddonInstall.
*
* @param {XPIStateLocation} installLocation
* The install location the add-on will be installed into
* @param {nsIURL} url
* The nsIURL to get the add-on from. If this is an nsIFileURL then
* the add-on will not need to be downloaded
* @param {Object} [options = {}]
* Additional options for the install
* @param {string} [options.hash]
* An optional hash for the add-on
* @param {AddonInternal} [options.existingAddon]
* The add-on this install will update if known
* @param {string} [options.name]
* An optional name for the add-on
* @param {string} [options.type]
* An optional type for the add-on
* @param {object} [options.icons]
* Optional icons for the add-on
* @param {string} [options.version]
* The expected version for the add-on.
* Required for updates, i.e. when existingAddon is set.
* @param {Object?} [options.telemetryInfo]
* An optional object which provides details about the installation source
* included in the addon manager telemetry events.
* @param {boolean} [options.isUserRequestedUpdate]
* An optional boolean, true if the install object is related to a user triggered update.
* @param {nsIURL} [options.releaseNotesURI]
* An optional nsIURL that release notes where release notes can be retrieved.
* @param {function(string) : Promise<void>} [options.promptHandler]
* A callback to prompt the user before installing.
*/
constructor(installLocation, url, options = {}) {
this.wrapper = new AddonInstallWrapper(this);
this.location = installLocation;
this.sourceURI = url;
if (options.hash) {
let hashSplit = options.hash.toLowerCase().split(":");
this.originalHash = {
algorithm: hashSplit[0],
data: hashSplit[1],
};
}
this.hash = this.originalHash;
this.fileHash = null;
this.existingAddon = options.existingAddon || null;
this.promptHandler = options.promptHandler || (() => Promise.resolve());
this.releaseNotesURI = options.releaseNotesURI || null;
this._startupPromise = null;
this._installPromise = new Promise(resolve => {
this._resolveInstallPromise = resolve;
});
// Ignore uncaught rejections for this promise, since they're
// handled by install listeners.
this._installPromise.catch(() => {});
this.listeners = [];
this.icons = options.icons || {};
this.error = 0;
this.progress = 0;
this.maxProgress = -1;
// Giving each instance of AddonInstall a reference to the logger.
this.logger = logger;
this.name = options.name || null;
this.type = options.type || null;
this.version = options.version || null;
this.isUserRequestedUpdate = options.isUserRequestedUpdate;
this.installTelemetryInfo = null;
if (options.telemetryInfo) {
this.installTelemetryInfo = options.telemetryInfo;
} else if (this.existingAddon) {
// Inherits the installTelemetryInfo on updates (so that the source of the original
// installation telemetry data is being preserved across the extension updates).
this.installTelemetryInfo = this.existingAddon.installTelemetryInfo;
this.existingAddon._updateInstall = this;
}
this.file = null;
this.ownsTempFile = null;
this.addon = null;
this.state = null;
XPIInstall.installs.add(this);
}
/**
* Called when we are finished with this install and are ready to remove
* any external references to it.
*/
_cleanup() {
XPIInstall.installs.delete(this);
if (this.addon && this.addon._install) {
if (this.addon._install === this) {
this.addon._install = null;
} else {
Cu.reportError(new Error("AddonInstall mismatch"));
}
}
if (this.existingAddon && this.existingAddon._updateInstall) {
if (this.existingAddon._updateInstall === this) {
this.existingAddon._updateInstall = null;
} else {
Cu.reportError(new Error("AddonInstall existingAddon mismatch"));
}
}
}
/**
* Starts installation of this add-on from whatever state it is currently at
* if possible.
*
* Note this method is overridden to handle additional state in
* the subclassses below.
*
* @returns {Promise<Addon>}
* @throws if installation cannot proceed from the current state
*/
install() {
switch (this.state) {
case AddonManager.STATE_DOWNLOADED:
this.checkPrompt();
break;
case AddonManager.STATE_PROMPTS_DONE:
this.checkForBlockers();
break;
case AddonManager.STATE_READY:
this.startInstall();
break;
case AddonManager.STATE_POSTPONED:
logger.debug(`Postponing install of ${this.addon.id}`);
break;
case AddonManager.STATE_DOWNLOADING:
case AddonManager.STATE_CHECKING_UPDATE:
case AddonManager.STATE_INSTALLING:
// Installation is already running
break;
default:
throw new Error("Cannot start installing from this state");
}
return this._installPromise;
}
continuePostponedInstall() {
if (this.state !== AddonManager.STATE_POSTPONED) {
throw new Error("AddonInstall not in postponed state");
}
// Force the postponed install to continue.
logger.info(`${this.addon.id} has resumed a previously postponed upgrade`);
this.state = AddonManager.STATE_READY;
this.install();
}
/**
* Called during XPIProvider shutdown so that we can do any necessary
* pre-shutdown cleanup.
*/
onShutdown() {
switch (this.state) {
case AddonManager.STATE_POSTPONED:
this.removeTemporaryFile();
break;
}
}
/**
* Cancels installation of this add-on.
*
* Note this method is overridden to handle additional state in
* the subclass DownloadAddonInstall.
*
* @throws if installation cannot be cancelled from the current state
*/
cancel() {
switch (this.state) {
case AddonManager.STATE_AVAILABLE:
case AddonManager.STATE_DOWNLOADED:
logger.debug("Cancelling download of " + this.sourceURI.spec);
this.state = AddonManager.STATE_CANCELLED;
this._cleanup();
this._callInstallListeners("onDownloadCancelled");
this.removeTemporaryFile();
break;
case AddonManager.STATE_POSTPONED:
logger.debug(`Cancelling postponed install of ${this.addon.id}`);
this.state = AddonManager.STATE_CANCELLED;
this._cleanup();
this._callInstallListeners("onInstallCancelled");
this.removeTemporaryFile();
let stagingDir = this.location.installer.getStagingDir();
let stagedAddon = stagingDir.clone();
this.unstageInstall(stagedAddon);
break;
default:
throw new Error(
"Cannot cancel install of " +
this.sourceURI.spec +
" from this state (" +
this.state +
")"
);
}
}
/**
* Adds an InstallListener for this instance if the listener is not already
* registered.
*
* @param {InstallListener} aListener
* The InstallListener to add
*/
addListener(aListener) {
if (
!this.listeners.some(function (i) {
return i == aListener;
})
) {
this.listeners.push(aListener);
}
}
/**
* Removes an InstallListener for this instance if it is registered.
*
* @param {InstallListener} aListener
* The InstallListener to remove
*/
removeListener(aListener) {
this.listeners = this.listeners.filter(function (i) {
return i != aListener;
});
}
/**
* Removes the temporary file owned by this AddonInstall if there is one.
*/
removeTemporaryFile() {
// Only proceed if this AddonInstall owns its XPI file
if (!this.ownsTempFile) {
this.logger.debug(
`removeTemporaryFile: ${this.sourceURI.spec} does not own temp file`
);
return;
}
try {
this.logger.debug(
`removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` +
this.file.path
);
flushJarCache(this.file);
this.file.remove(true);
this.ownsTempFile = false;
} catch (e) {
this.logger.warn(
`Failed to remove temporary file ${this.file.path} for addon ` +
this.sourceURI.spec,
e
);
}
}
_setFileHash(calculatedHash) {
this.fileHash = {
algorithm: this.hash ? this.hash.algorithm : DEFAULT_HASH_ALGO,
data: calculatedHash,
};
if (this.hash && calculatedHash != this.hash.data) {
return false;
}
return true;
}
/**
* Updates the addon metadata that has to be propagated across restarts.
*/
updatePersistedMetadata() {
this.addon.sourceURI = this.sourceURI.spec;
if (this.releaseNotesURI) {
this.addon.releaseNotesURI = this.releaseNotesURI.spec;
}
if (this.installTelemetryInfo) {
this.addon.installTelemetryInfo = this.installTelemetryInfo;
}
}
/**
* Called after the add-on is a local file and the signature and install
* manifest can be read.
*
* @param {nsIFile} file
* The file from which to load the manifest.
* @returns {Promise<void>}
*/
async loadManifest(file) {
let pkg;
try {
pkg = Package.get(file);
} catch (e) {
return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
}
try {
try {
this.addon = await loadManifest(pkg, this.location, this.existingAddon);
} catch (e) {
return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
}
if (!this.addon.id) {
let msg = `Cannot find id for addon ${file.path}.`;
if (Services.prefs.getBoolPref(PREF_XPI_SIGNATURES_DEV_ROOT, false)) {
msg += ` Preference ${PREF_XPI_SIGNATURES_DEV_ROOT} is set.`;
}
return Promise.reject([
AddonManager.ERROR_CORRUPT_FILE,
new Error(msg),
]);
}
if (this.existingAddon) {
// Check various conditions related to upgrades
if (this.addon.id != this.existingAddon.id) {
return Promise.reject([
AddonManager.ERROR_INCORRECT_ID,
`Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`,
]);
}
if (this.existingAddon.isWebExtension && !this.addon.isWebExtension) {
// This condition is never met on regular Firefox builds.
return Promise.reject([
AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
"WebExtensions may not be updated to other extension types",
]);
}
if (this.existingAddon.type != this.addon.type) {
return Promise.reject([
AddonManager.ERROR_UNEXPECTED_ADDON_TYPE,
`Refusing to change addon type from ${this.existingAddon.type} to ${this.addon.type}`,
]);
}
if (this.version !== this.addon.version) {
return Promise.reject([
AddonManager.ERROR_UNEXPECTED_ADDON_VERSION,
`Expected addon version ${this.version} instead of ${this.addon.version}`,
]);
}
}
if (lazy.XPIDatabase.mustSign(this.addon.type)) {
if (this.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) {
// This add-on isn't properly signed by a signature that chains to the
// trusted root.
let state = this.addon.signedState;
this.addon = null;
if (state == AddonManager.SIGNEDSTATE_MISSING) {
return Promise.reject([
AddonManager.ERROR_SIGNEDSTATE_REQUIRED,
"signature is required but missing",
]);
}
return Promise.reject([
AddonManager.ERROR_CORRUPT_FILE,
"signature verification failed",
]);
}
}
} finally {
pkg.close();
}
this.updatePersistedMetadata();
this.addon._install = this;
this.name = this.addon.selectedLocale.name;
this.type = this.addon.type;
this.version = this.addon.version;
// Setting the iconURL to something inside the XPI locks the XPI and
// makes it impossible to delete on Windows.
// Try to load from the existing cache first
let repoAddon = await lazy.AddonRepository.getCachedAddonByID(
this.addon.id
);
// It wasn't there so try to re-download it
if (!repoAddon) {
try {
[repoAddon] = await lazy.AddonRepository.cacheAddons([this.addon.id]);
} catch (err) {
logger.debug(
`Error getting metadata for ${this.addon.id}: ${err.message}`
);
}
}
this.addon._repositoryAddon = repoAddon;
this.name = this.name || this.addon._repositoryAddon.name;
this.addon.appDisabled = !lazy.XPIDatabase.isUsableAddon(this.addon);
return undefined;
}
getIcon(desiredSize = 64) {
if (!this.addon.icons || !this.file) {
return null;
}
let { icon } = lazy.IconDetails.getPreferredIcon(
this.addon.icons,
null,
desiredSize
);
if (icon.startsWith("chrome://")) {
return icon;
}
return getJarURI(this.file, icon).spec;
}
/**
* This method should be called when the XPI is ready to be installed,
* i.e., when a download finishes or when a local file has been verified.
* It should only be called from install() when the install is in
* STATE_DOWNLOADED (which actually means that the file is available
* and has been verified).
*/
checkPrompt() {
(async () => {
if (this.promptHandler) {
let info = {
existingAddon: this.existingAddon ? this.existingAddon.wrapper : null,
addon: this.addon.wrapper,
icon: this.getIcon(),
// Used in AMTelemetry to detect the install flow related to this prompt.
install: this.wrapper,
};
try {
await this.promptHandler(info);
} catch (err) {
if (this.error < 0) {
logger.info(`Install of ${this.addon.id} failed ${this.error}`);
this.state = AddonManager.STATE_INSTALL_FAILED;
this._cleanup();
// In some cases onOperationCancelled is called during failures
// to install/uninstall/enable/disable addons. We may need to
// do that here in the future.
this._callInstallListeners("onInstallFailed");
this.removeTemporaryFile();
} else {
logger.info(`Install of ${this.addon.id} cancelled by user`);
this.state = AddonManager.STATE_CANCELLED;