Source code

Revision control

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
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/**
* This file contains most of the logic required to load and run
* extensions at startup. Anything which is not required immediately at
* startup should go in XPIInstall.jsm or XPIDatabase.jsm if at all
* possible, in order to minimize the impact on startup performance.
*/
/**
* @typedef {number} integer
*/
/* eslint "valid-jsdoc": [2, {requireReturn: false, requireReturnDescription: false, prefer: {return: "returns"}}] */
var EXPORTED_SYMBOLS = ["XPIProvider", "XPIInternal"];
const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
const { XPCOMUtils } = ChromeUtils.import(
);
const { AddonManager, AddonManagerPrivate } = ChromeUtils.import(
);
XPCOMUtils.defineLazyModuleGetters(this, {
});
XPCOMUtils.defineLazyServiceGetters(this, {
aomStartup: [
"@mozilla.org/addons/addon-manager-startup;1",
"amIAddonManagerStartup",
],
resProto: [
"@mozilla.org/network/protocol;1?name=resource",
"nsISubstitutingProtocolHandler",
],
spellCheck: ["@mozilla.org/spellchecker/engine;1", "mozISpellCheckingEngine"],
timerManager: [
"@mozilla.org/updates/timer-manager;1",
"nsIUpdateTimerManager",
],
});
const nsIFile = Components.Constructor(
"@mozilla.org/file/local;1",
"nsIFile",
"initWithPath"
);
const FileInputStream = Components.Constructor(
"@mozilla.org/network/file-input-stream;1",
"nsIFileInputStream",
"init"
);
const PREF_DB_SCHEMA = "extensions.databaseSchema";
const PREF_PENDING_OPERATIONS = "extensions.pendingOperations";
const PREF_EM_ENABLED_SCOPES = "extensions.enabledScopes";
const PREF_EM_STARTUP_SCAN_SCOPES = "extensions.startupScanScopes";
// xpinstall.signatures.required only supported in dev builds
const PREF_XPI_SIGNATURES_REQUIRED = "xpinstall.signatures.required";
const PREF_LANGPACK_SIGNATURES = "extensions.langpacks.signatures.required";
const PREF_INSTALL_DISTRO_ADDONS = "extensions.installDistroAddons";
const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon.";
const PREF_SYSTEM_ADDON_SET = "extensions.systemAddonSet";
const PREF_EM_LAST_APP_BUILD_ID = "extensions.lastAppBuildId";
// Specify a list of valid built-in add-ons to load.
const BUILT_IN_ADDONS_URI = "chrome://browser/content/built_in_addons.json";
const URI_EXTENSION_STRINGS =
const DIR_EXTENSIONS = "extensions";
const DIR_SYSTEM_ADDONS = "features";
const DIR_APP_SYSTEM_PROFILE = "system-extensions";
const DIR_STAGE = "staged";
const DIR_TRASH = "trash";
const FILE_XPI_STATES = "addonStartup.json.lz4";
const KEY_PROFILEDIR = "ProfD";
const KEY_ADDON_APP_DIR = "XREAddonAppDir";
const KEY_APP_DISTRIBUTION = "XREAppDist";
const KEY_APP_FEATURES = "XREAppFeat";
const KEY_APP_PROFILE = "app-profile";
const KEY_APP_SYSTEM_PROFILE = "app-system-profile";
const KEY_APP_SYSTEM_ADDONS = "app-system-addons";
const KEY_APP_SYSTEM_DEFAULTS = "app-system-defaults";
const KEY_APP_BUILTINS = "app-builtin";
const KEY_APP_GLOBAL = "app-global";
const KEY_APP_SYSTEM_LOCAL = "app-system-local";
const KEY_APP_SYSTEM_SHARE = "app-system-share";
const KEY_APP_SYSTEM_USER = "app-system-user";
const KEY_APP_TEMPORARY = "app-temporary";
const TEMPORARY_ADDON_SUFFIX = "@temporary-addon";
const STARTUP_MTIME_SCOPES = [
KEY_APP_GLOBAL,
KEY_APP_SYSTEM_LOCAL,
KEY_APP_SYSTEM_SHARE,
KEY_APP_SYSTEM_USER,
];
const NOTIFICATION_FLUSH_PERMISSIONS = "flush-pending-permissions";
const XPI_PERMISSION = "install";
const XPI_SIGNATURE_CHECK_PERIOD = 24 * 60 * 60;
const DB_SCHEMA = 33;
XPCOMUtils.defineLazyPreferenceGetter(
this,
"enabledScopesPref",
PREF_EM_ENABLED_SCOPES,
AddonManager.SCOPE_ALL
);
Object.defineProperty(this, "enabledScopes", {
get() {
// The profile location is always enabled
return enabledScopesPref | AddonManager.SCOPE_PROFILE;
},
});
function encoded(strings, ...values) {
let result = [];
for (let [i, string] of strings.entries()) {
result.push(string);
if (i < values.length) {
result.push(encodeURIComponent(values[i]));
}
}
return result.join("");
}
const BOOTSTRAP_REASONS = {
APP_STARTUP: 1,
APP_SHUTDOWN: 2,
ADDON_ENABLE: 3,
ADDON_DISABLE: 4,
ADDON_INSTALL: 5,
ADDON_UNINSTALL: 6,
ADDON_UPGRADE: 7,
ADDON_DOWNGRADE: 8,
};
const ALL_EXTERNAL_TYPES = new Set([
"dictionary",
"extension",
"locale",
"theme",
]);
var gGlobalScope = this;
/**
* 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.import("resource://gre/modules/Log.jsm");
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);
/**
* Spins the event loop until the given promise resolves, and then eiter returns
* its success value or throws its rejection value.
*
* @param {Promise} promise
* The promise to await.
* @returns {any}
* The promise's resolution value, if any.
*/
function awaitPromise(promise) {
let success = undefined;
let result = null;
promise.then(
val => {
success = true;
result = val;
},
val => {
success = false;
result = val;
}
);
Services.tm.spinEventLoopUntil(() => success !== undefined);
if (!success) {
throw result;
}
return result;
}
/**
* 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;
}
/**
* Returns true if the given file, based on its name, should be treated
* as an XPI. If the file does not have an appropriate extension, it is
* assumed to be an unpacked add-on.
*
* @param {string} filename
* The filename to check.
* @param {boolean} [strict = false]
* If true, this file is in a location maintained by the browser, and
* must have a strict, lower-case ".xpi" extension.
* @returns {boolean}
* True if the file is an XPI.
*/
function isXPI(filename, strict) {
if (strict) {
return filename.endsWith(".xpi");
}
let ext = filename.slice(-4).toLowerCase();
return ext === ".xpi" || ext === ".zip";
}
/**
* Returns the extension expected ID for a given file in an extension install
* directory.
*
* @param {nsIFile} file
* The extension XPI file or unpacked directory.
* @returns {AddonId?}
* The add-on ID, if valid, or null otherwise.
*/
function getExpectedID(file) {
let { leafName } = file;
let id = isXPI(leafName, true) ? leafName.slice(0, -4) : leafName;
if (gIDTest.test(id)) {
return id;
}
return null;
}
/**
* Evaluates whether an add-on is allowed to run in safe mode.
*
* @param {AddonInternal} aAddon
* The add-on to check
* @returns {boolean}
* True if the add-on should run in safe mode
*/
function canRunInSafeMode(aAddon) {
let location = aAddon.location || null;
if (!location) {
return false;
}
// Even though the updated system add-ons aren't generally run in safe mode we
// include them here so their uninstall functions get called when switching
// back to the default set.
// TODO product should make the call about temporary add-ons running
// in safe mode. assuming for now that they are.
return location.isTemporary || location.isSystem || location.isBuiltin;
}
/**
* Gets an nsIURI for a file within another file, either a directory or an XPI
* file. If aFile is a directory then this will return a file: URI, if it is an
* XPI file then it will return a jar: URI.
*
* @param {nsIFile} aFile
* The file containing the resources, must be either a directory or an
* XPI file
* @param {string} aPath
* The path to find the resource at, "/" separated. If aPath is empty
* then the uri to the root of the contained files will be returned
* @returns {nsIURI}
* An nsIURI pointing at the resource
*/
function getURIForResourceInFile(aFile, aPath) {
if (!isXPI(aFile.leafName)) {
let resource = aFile.clone();
if (aPath) {
aPath.split("/").forEach(part => resource.append(part));
}
return Services.io.newFileURI(resource);
}
return buildJarURI(aFile, aPath);
}
/**
* Creates a jar: URI for a file inside a ZIP file.
*
* @param {nsIFile} aJarfile
* The ZIP file as an nsIFile
* @param {string} aPath
* The path inside the ZIP file
* @returns {nsIURI}
* An nsIURI for the file
*/
function buildJarURI(aJarfile, aPath) {
let uri = Services.io.newFileURI(aJarfile);
uri = "jar:" + uri.spec + "!/" + aPath;
return Services.io.newURI(uri);
}
function maybeResolveURI(uri) {
if (uri.schemeIs("resource")) {
return Services.io.newURI(resProto.resolveURI(uri));
}
return uri;
}
/**
* Iterates over the entries in a given directory.
*
* Fails silently if the given directory does not exist.
*
* @param {nsIFile} aDir
* Directory to iterate.
*/
function* iterDirectory(aDir) {
let dirEnum;
try {
dirEnum = aDir.directoryEntries;
let file;
while ((file = dirEnum.nextFile)) {
yield file;
}
} catch (e) {
if (aDir.exists()) {
logger.warn(`Can't iterate directory ${aDir.path}`, e);
}
} finally {
if (dirEnum) {
dirEnum.close();
}
}
}
/**
* Migrate data about an addon to match the change made in bug 857456
* in which "webextension-foo" types were converted to "foo" and the
* "loader" property was added to distinguish different addon types.
*
* @param {Object} addon The addon info to migrate.
* @returns {boolean} True if the addon data was converted, false if not.
*/
function migrateAddonLoader(addon) {
if (addon.hasOwnProperty("loader")) {
return false;
}
switch (addon.type) {
case "extension":
case "dictionary":
case "locale":
case "theme":
addon.loader = "bootstrap";
break;
case "webextension":
addon.type = "extension";
addon.loader = null;
break;
case "webextension-dictionary":
addon.type = "dictionary";
addon.loader = null;
break;
case "webextension-langpack":
addon.type = "locale";
addon.loader = null;
break;
case "webextension-theme":
addon.type = "theme";
addon.loader = null;
break;
default:
logger.warn(`Not converting unknown addon type ${addon.type}`);
}
return true;
}
/**
* The on-disk state of an individual XPI, created from an Object
* as stored in the addonStartup.json file.
*/
const JSON_FIELDS = Object.freeze([
"dependencies",
"enabled",
"file",
"loader",
"lastModifiedTime",
"path",
"rootURI",
"runInSafeMode",
"signedState",
"signedDate",
"startupData",
"telemetryKey",
"type",
"version",
]);
class XPIState {
constructor(location, id, saved = {}) {
this.location = location;
this.id = id;
// Set default values.
this.type = "extension";
for (let prop of JSON_FIELDS) {
if (prop in saved) {
this[prop] = saved[prop];
}
}
// Builds prior to be 1512436 did not include the rootURI property.
// If we're updating from such a build, add that property now.
if (!("rootURI" in this) && this.file) {
this.rootURI = getURIForResourceInFile(this.file, "").spec;
}
if (!this.telemetryKey) {
this.telemetryKey = this.getTelemetryKey();
}
if (
saved.currentModifiedTime &&
saved.currentModifiedTime != this.lastModifiedTime
) {
this.lastModifiedTime = saved.currentModifiedTime;
} else if (saved.currentModifiedTime === null) {
this.missing = true;
}
}
// Compatibility shim getters for legacy callers in XPIDatabase.jsm.
get mtime() {
return this.lastModifiedTime;
}
get active() {
return this.enabled;
}
/**
* @property {string} path
* The full on-disk path of the add-on.
*/
get path() {
return this.file && this.file.path;
}
set path(path) {
this.file = path ? getFile(path, this.location.dir) : null;
}
/**
* @property {string} relativePath
* The path to the add-on relative to its parent location, or
* the full path if its parent location has no on-disk path.
*/
get relativePath() {
if (this.location.dir && this.location.dir.contains(this.file)) {
let path = this.file.getRelativePath(this.location.dir);
if (AppConstants.platform == "win") {
path = path.replace(/\//g, "\\");
}
return path;
}
return this.path;
}
/**
* Returns a JSON-compatible representation of this add-on's state
* data, to be saved to addonStartup.json.
*
* @returns {Object}
*/
toJSON() {
let json = {
dependencies: this.dependencies,
enabled: this.enabled,
lastModifiedTime: this.lastModifiedTime,
loader: this.loader,
path: this.relativePath,
rootURI: this.rootURI,
runInSafeMode: this.runInSafeMode,
signedState: this.signedState,
signedDate: this.signedDate,
telemetryKey: this.telemetryKey,
version: this.version,
};
if (this.type != "extension") {
json.type = this.type;
}
if (this.startupData) {
json.startupData = this.startupData;
}
return json;
}
get isWebExtension() {
return this.loader == null;
}
/**
* Update the last modified time for an add-on on disk.
*
* @param {nsIFile} aFile
* The location of the add-on.
* @returns {boolean}
* True if the time stamp has changed.
*/
getModTime(aFile) {
let mtime = 0;
try {
// Clone the file object so we always get the actual mtime, rather
// than whatever value it may have cached.
mtime = aFile.clone().lastModifiedTime;
} catch (e) {
logger.warn("Can't get modified time of ${path}", aFile, e);
}
let changed = mtime != this.lastModifiedTime;
this.lastModifiedTime = mtime;
return changed;
}
/**
* Returns a string key by which to identify this add-on in telemetry
* and crash reports.
*
* @returns {string}
*/
getTelemetryKey() {
return encoded`${this.id}:${this.version}`;
}
get resolvedRootURI() {
return maybeResolveURI(Services.io.newURI(this.rootURI));
}
/**
* Update the XPIState to match an XPIDatabase entry; if 'enabled' is changed to true,
* update the last-modified time. This should probably be made async, but for now we
* don't want to maintain parallel sync and async versions of the scan.
*
* Caller is responsible for doing XPIStates.save() if necessary.
*
* @param {DBAddonInternal} aDBAddon
* The DBAddonInternal for this add-on.
* @param {boolean} [aUpdated = false]
* The add-on was updated, so we must record new modified time.
*/
syncWithDB(aDBAddon, aUpdated = false) {
logger.debug("Updating XPIState for " + JSON.stringify(aDBAddon));
// If the add-on changes from disabled to enabled, we should re-check the modified time.
// If this is a newly found add-on, it won't have an 'enabled' field but we
// did a full recursive scan in that case, so we don't need to do it again.
// We don't use aDBAddon.active here because it's not updated until after restart.
let mustGetMod = aDBAddon.visible && !aDBAddon.disabled && !this.enabled;
this.enabled = aDBAddon.visible && !aDBAddon.disabled;
this.version = aDBAddon.version;
this.type = aDBAddon.type;
this.loader = aDBAddon.loader;
if (aDBAddon.startupData) {
this.startupData = aDBAddon.startupData;
}
this.telemetryKey = this.getTelemetryKey();
this.dependencies = aDBAddon.dependencies;
this.runInSafeMode = canRunInSafeMode(aDBAddon);
this.signedState = aDBAddon.signedState;
this.signedDate = aDBAddon.signedDate;
this.file = aDBAddon._sourceBundle;
this.rootURI = aDBAddon.rootURI;
if ((aUpdated || mustGetMod) && this.file) {
this.getModTime(this.file);
if (this.lastModifiedTime != aDBAddon.updateDate) {
aDBAddon.updateDate = this.lastModifiedTime;
if (XPIDatabase.initialized) {
XPIDatabase.saveChanges();
}
}
}
}
}
/**
* Manages the state data for add-ons in a given install location.
*
* @param {string} name
* The name of the install location (e.g., "app-profile").
* @param {string | nsIFile | null} path
* The on-disk path of the install location. May be null for some
* locations which do not map to a specific on-disk path.
* @param {integer} scope
* The scope of add-ons installed in this location.
* @param {object} [saved]
* The persisted JSON state data to restore.
*/
class XPIStateLocation extends Map {
constructor(name, path, scope, saved) {
super();
this.name = name;
this.scope = scope;
if (path instanceof Ci.nsIFile) {
this.dir = path;
this.path = path.path;
} else {
this.path = path;
this.dir = this.path && new nsIFile(this.path);
}
this.staged = {};
this.changed = false;
// The profile extensions directory is whitelisted for access by the
// content process sandbox if, and only if it already exists. Since
// we want it to be available for newly-installed extensions even if
// no profile extensions were present at startup, make sure it
// exists now.
if (name === KEY_APP_PROFILE) {
OS.File.makeDir(this.path, { ignoreExisting: true });
}
if (saved) {
this.restore(saved);
}
this._installler = undefined;
}
hasPrecedence(otherLocation) {
let locations = Array.from(XPIStates.locations());
return locations.indexOf(this) <= locations.indexOf(otherLocation);
}
get installer() {
if (this._installer === undefined) {
this._installer = this.makeInstaller();
}
return this._installer;
}
makeInstaller() {
return null;
}
restore(saved) {
if (!this.path && saved.path) {
this.path = saved.path;
this.dir = new nsIFile(this.path);
}
this.staged = saved.staged || {};
this.changed = saved.changed || false;
for (let [id, data] of Object.entries(saved.addons || {})) {
let xpiState = this._addState(id, data);
// Make a note that this state was restored from saved data. But
// only if this location hasn't moved since the last startup,
// since that causes problems for new system add-on bundles.
if (!this.path || this.path == saved.path) {
xpiState.wasRestored = true;
}
}
}
/**
* Returns a JSON-compatible representation of this location's state
* data, to be saved to addonStartup.json.
*
* @returns {Object}
*/
toJSON() {
let json = {
addons: {},
staged: this.staged,
};
if (this.path) {
json.path = this.path;
}
if (STARTUP_MTIME_SCOPES.includes(this.name)) {
json.checkStartupModifications = true;
}
for (let [id, addon] of this.entries()) {
json.addons[id] = addon;
}
return json;
}
get hasStaged() {
for (let key in this.staged) {
return true;
}
return false;
}
_addState(addonId, saved) {
let xpiState = new XPIState(this, addonId, saved);
this.set(addonId, xpiState);
return xpiState;
}
/**
* Adds state data for the given DB add-on to the DB.
*
* @param {DBAddon} addon
* The DBAddon to add.
*/
addAddon(addon) {
logger.debug(
"XPIStates adding add-on ${id} in ${location}: ${path}",
addon
);
XPIProvider.persistStartupData(addon);
let xpiState = this._addState(addon.id, { file: addon._sourceBundle });
xpiState.syncWithDB(addon, true);
XPIProvider.addTelemetry(addon.id, { location: this.name });
}
/**
* Remove the XPIState for an add-on and save the new state.
*
* @param {string} aId
* The ID of the add-on.
*/
removeAddon(aId) {
if (this.has(aId)) {
this.delete(aId);
XPIStates.save();
}
}
/**
* Adds stub state data for the local file to the DB.
*
* @param {string} addonId
* The ID of the add-on represented by the given file.
* @param {nsIFile} file
* The local file or directory containing the add-on.
* @returns {XPIState}
*/
addFile(addonId, file) {
let xpiState = this._addState(addonId, {
enabled: false,
file: file.clone(),
});
xpiState.getModTime(xpiState.file);
return xpiState;
}
/**
* Adds metadata for a staged install which should be performed after
* the next restart.
*
* @param {string} addonId
* The ID of the staged install. The leaf name of the XPI
* within the location's staging directory must correspond to
* this ID.
* @param {object} metadata
* The JSON metadata of the parsed install, to be used during
* the next startup.
*/
stageAddon(addonId, metadata) {
this.staged[addonId] = metadata;
XPIStates.save();
}
/**
* Removes staged install metadata for the given add-on ID.
*
* @param {string} addonId
* The ID of the staged install.
*/
unstageAddon(addonId) {
if (addonId in this.staged) {
delete this.staged[addonId];
XPIStates.save();
}
}
*getStagedAddons() {
for (let [id, metadata] of Object.entries(this.staged)) {
yield [id, metadata];
}
}
/**
* Returns true if the given addon was installed in this location by a text
* file pointing to its real path.
*
* @param {string} aId
* The ID of the addon
* @returns {boolean}
*/
isLinkedAddon(aId) {
if (!this.dir) {
return true;
}
return this.has(aId) && !this.dir.contains(this.get(aId).file);
}
get isTemporary() {
return false;
}
get isSystem() {
return false;
}
get isBuiltin() {
return false;
}
get hidden() {
return this.isBuiltin;
}
// If this property is false, it does not implement readAddons()
// interface. This is used for the temporary and built-in locations
// that do not correspond to a physical location that can be scanned.
get enumerable() {
return true;
}
}
class TemporaryLocation extends XPIStateLocation {
/**
* @param {string} name
* The string identifier for the install location.
*/
constructor(name) {
super(name, null, AddonManager.SCOPE_TEMPORARY);
this.locked = false;
}
makeInstaller() {
// Installs are a no-op. We only register that add-ons exist, and
// run them from their current location.
return {
installAddon() {},
uninstallAddon() {},
};
}
toJSON() {
return {};
}
get isTemporary() {
return true;
}
get enumerable() {
return false;
}
}
var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY);
/**
* A "location" for addons installed from assets packged into the app.
*/
var BuiltInLocation = new (class _BuiltInLocation extends XPIStateLocation {
constructor() {
super(KEY_APP_BUILTINS, null, AddonManager.SCOPE_APPLICATION);
this.locked = false;
}
// The installer object is responsible for moving files around on disk
// when (un)installing an addon. Since this location handles only addons
// that are embedded within the browser, these are no-ops.
makeInstaller() {
return {
installAddon() {},
uninstallAddon() {},
};
}
get hidden() {
return false;
}
get isBuiltin() {
return true;
}
get enumerable() {
return false;
}
// Builtin addons are never linked, return false
// here for correct behavior elsewhere.
isLinkedAddon(/* aId */) {
return false;
}
})();
/**
* An object which identifies a directory install location for add-ons. The
* location consists of a directory which contains the add-ons installed in the
* location.
*
*/
class DirectoryLocation extends XPIStateLocation {
/**
* Each add-on installed in the location is either a directory containing the
* add-on's files or a text file containing an absolute path to the directory
* containing the add-ons files. The directory or text file must have the same
* name as the add-on's ID.
*
* @param {string} name
* The string identifier for the install location.
* @param {nsIFile} dir
* The directory for the install location.
* @param {integer} scope
* The scope of add-ons installed in this location.
* @param {boolean} [locked = true]
* If false, the location accepts new add-on installs.
* @param {boolean} [system = false]
* If true, the location is a system addon location.
*/
constructor(name, dir, scope, locked = true, system = false) {
super(name, dir, scope);
this.locked = locked;
this._isSystem = system;
}
makeInstaller() {
if (this.locked) {
return null;
}
return new XPIInstall.DirectoryInstaller(this);
}
/**
* Reads a single-line file containing the path to a directory, and
* returns an nsIFile pointing to that directory, if successful.
*
* @param {nsIFile} aFile
* The file containing the directory path
* @returns {nsIFile?}
* An nsIFile object representing the linked directory, or null
* on error.
*/
_readLinkFile(aFile) {
let linkedDirectory;
if (aFile.isSymlink()) {
linkedDirectory = aFile.clone();
try {
linkedDirectory.normalize();
} catch (e) {
logger.warn(
`Symbolic link ${aFile.path} points to a path ` +
`which does not exist`
);
return null;
}
} else {
let fis = new FileInputStream(aFile, -1, -1, false);
let line = {};
fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
fis.close();
if (line.value) {
linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(
Ci.nsIFile
);
try {
linkedDirectory.initWithPath(line.value);
} catch (e) {
linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
}
}
}
if (linkedDirectory) {
if (!linkedDirectory.exists()) {
logger.warn(
`File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
"which does not exist"
);
return null;
}
if (!linkedDirectory.isDirectory()) {
logger.warn(
`File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
"which is not a directory"
);
return null;
}
return linkedDirectory;
}
logger.warn(`File pointer ${aFile.path} does not contain a path`);
return null;
}
/**
* Finds all the add-ons installed in this location.
*
* @returns {Map<AddonID, nsIFile>}
* A map of add-ons present in this location.
*/
readAddons() {
let addons = new Map();
if (!this.dir) {
return addons;
}
// Use a snapshot of the directory contents to avoid possible issues with
// iterating over a directory while removing files from it (the YAFFS2
// embedded filesystem has this issue, see bug 772238).
for (let entry of Array.from(iterDirectory(this.dir))) {
let id = getExpectedID(entry);
if (!id) {
if (![DIR_STAGE, DIR_TRASH].includes(entry.leafName)) {
logger.debug(
"Ignoring file: name is not a valid add-on ID: ${}",
entry.path
);
}
continue;
}
if (id == entry.leafName && (entry.isFile() || entry.isSymlink())) {
let newEntry = this._readLinkFile(entry);
if (!newEntry) {
logger.debug(`Deleting stale pointer file ${entry.path}`);
try {
entry.remove(true);
} catch (e) {
logger.warn(`Failed to remove stale pointer file ${entry.path}`, e);
// Failing to remove the stale pointer file is ignorable
}
continue;
}
entry = newEntry;
}
addons.set(id, entry);
}
return addons;
}
get isSystem() {
return this._isSystem;
}
}
/**
* An object which identifies a built-in install location for add-ons, such
* as default system add-ons.
*
* This location should point either to a XPI, or a directory in a local build.
*/
class SystemAddonDefaults extends DirectoryLocation {
/**
* Read the manifest of allowed add-ons and build a mapping between ID and URI
* for each.
*
* @returns {Map<AddonID, nsIFile>}
* A map of add-ons present in this location.
*/
readAddons() {
let addons = new Map();
let manifest = XPIProvider.builtInAddons;
if (!("system" in manifest)) {
logger.debug("No list of valid system add-ons found.");
return addons;
}
for (let id of manifest.system) {
let file = this.dir.clone();
file.append(`${id}.xpi`);
// Only attempt to load unpacked directory if unofficial build.
if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
file = this.dir.clone();
file.append(`${id}`);
}
addons.set(id, file);
}
return addons;
}
get isSystem() {
return true;
}
get isBuiltin() {
return true;
}
}
/**
* An object which identifies a directory install location for system add-ons
* updates.
*/
class SystemAddonLocation extends DirectoryLocation {
/**
* The location consists of a directory which contains the add-ons installed.
*
* @param {string} name
* The string identifier for the install location.
* @param {nsIFile} dir
* The directory for the install location.
* @param {integer} scope
* The scope of add-ons installed in this location.
* @param {boolean} resetSet
* True to throw away the current add-on set
*/
constructor(name, dir, scope, resetSet) {
let addonSet = SystemAddonLocation._loadAddonSet();
let directory = null;
// The system add-on update directory is stored in a pref.
// Therefore, this is looked up before calling the
// constructor on the superclass.
if (addonSet.directory) {
directory = getFile(addonSet.directory, dir);
logger.info(`SystemAddonLocation scanning directory ${directory.path}`);
} else {
logger.info("SystemAddonLocation directory is missing");
}
super(name, directory, scope, false);
this._addonSet = addonSet;
this._baseDir = dir;
if (resetSet) {
this.installer.resetAddonSet();
}
}
makeInstaller() {
if (this.locked) {
return null;
}
return new XPIInstall.SystemAddonInstaller(this);
}
/**
* Reads the current set of system add-ons
*
* @returns {Object}
*/
static _loadAddonSet() {
try {
let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
if (setStr) {
let addonSet = JSON.parse(setStr);
if (typeof addonSet == "object" && addonSet.schema == 1) {
return addonSet;
}
}
} catch (e) {
logger.error("Malformed system add-on set, resetting.");
}
return { schema: 1, addons: {} };
}
readAddons() {
// Updated system add-ons are ignored in safe mode
if (Services.appinfo.inSafeMode) {
return new Map();
}
let addons = super.readAddons();
// Strip out any unexpected add-ons from the list
for (let id of addons.keys()) {
if (!(id in this._addonSet.addons)) {
addons.delete(id);
}
}
return addons;
}
/**
* Tests whether updated system add-ons are expected.
*
* @returns {boolean}
*/
isActive() {
return this.dir != null;
}
get isSystem() {
return true;
}
get isBuiltin() {
return true;
}
}
/**
* An object that identifies a registry install location for add-ons. The location
* consists of a registry key which contains string values mapping ID to the
* path where an add-on is installed
*
*/
class WinRegLocation extends XPIStateLocation {
/**
* @param {string} name
* The string identifier for the install location.
* @param {integer} rootKey
* The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
* @param {integer} scope
* The scope of add-ons installed in this location.
*/
constructor(name, rootKey, scope) {
super(name, undefined, scope);
this.locked = true;
this._rootKey = rootKey;
}
/**
* Retrieves the path of this Application's data key in the registry.
*/
get _appKeyPath() {
let appVendor = Services.appinfo.vendor;
let appName = Services.appinfo.name;
// XXX Thunderbird doesn't specify a vendor string
if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird") {
appVendor = "Mozilla";
}
return `SOFTWARE\\${appVendor}\\${appName}`;
}
/**
* Read the registry and build a mapping between ID and path for each
* installed add-on.
*
* @returns {Map<AddonID, nsIFile>}
* A map of add-ons in this location.
*/
readAddons() {
let addons = new Map();
let path = `${this._appKeyPath}\\Extensions`;
let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
Ci.nsIWindowsRegKey
);
// Reading the registry may throw an exception, and that's ok. In error
// cases, we just leave ourselves in the empty state.
try {
key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
} catch (e) {
return addons;
}
try {
let count = key.valueCount;
for (let i = 0; i < count; ++i) {
let id = key.getValueName(i);
let file = new nsIFile(key.readStringValue(id));
if (!file.exists()) {
logger.warn(`Ignoring missing add-on in ${file.path}`);
continue;
}
addons.set(id, file);
}
} finally {
key.close();
}
return addons;
}
}
/**
* Keeps track of the state of XPI add-ons on the file system.
*/
var XPIStates = {
// Map(location-name -> XPIStateLocation)
db: new Map(),
_jsonFile: null,
/**
* @property {Map<string, XPIState>} sideLoadedAddons
* A map of new add-ons detected during install location
* directory scans. Keys are add-on IDs, values are XPIState
* objects corresponding to those add-ons.
*/
sideLoadedAddons: new Map(),
get size() {
let count = 0;
for (let location of this.locations()) {
count += location.size;
}
return count;
},
/**
* Load extension state data from addonStartup.json.
*
* @returns {Object}
*/
loadExtensionState() {
let state;
try {
state = aomStartup.readStartupData();
} catch (e) {
logger.warn("Error parsing extensions state: ${error}", { error: e });
}
// When upgrading from a build prior to bug 857456, convert startup
// metadata.
let done = false;
for (let location of Object.values(state || {})) {
for (let data of Object.values(location.addons || {})) {
if (!migrateAddonLoader(data)) {
done = true;
break;
}
}
if (done) {
break;
}
}
logger.debug("Loaded add-on state: ${}", state);
return state || {};
},
/**
* Walk through all install locations, highest priority first,
* comparing the on-disk state of extensions to what is stored in prefs.
*
* @param {boolean} [ignoreSideloads = true]
* If true, ignore changes in scopes where we don't accept
* side-loads.
*
* @returns {boolean}
* True if anything has changed.
*/
scanForChanges(ignoreSideloads = true) {
let oldState = this.initialStateData || this.loadExtensionState();
// We're called twice, do not restore the second time as new data
// may have been inserted since the first call.
let shouldRestoreLocationData = !this.initialStateData;
this.initialStateData = oldState;
let changed = false;
let oldLocations = new Set(Object.keys(oldState));
let startupScanScopes;
if (
Services.appinfo.appBuildID ==
Services.prefs.getCharPref(PREF_EM_LAST_APP_BUILD_ID, "")
) {
startupScanScopes = Services.prefs.getIntPref(
PREF_EM_STARTUP_SCAN_SCOPES,
0
);
} else {
// If the build id has changed, we need to do a full scan on first startup.
Services.prefs.setCharPref(
PREF_EM_LAST_APP_BUILD_ID,
Services.appinfo.appBuildID
);
startupScanScopes = AddonManager.SCOPE_ALL;
}
for (let loc of XPIStates.locations()) {
oldLocations.delete(loc.name);
if (shouldRestoreLocationData && oldState[loc.name]) {
loc.restore(oldState[loc.name]);
}
changed = changed || loc.changed;
// Don't bother checking scopes where we don't accept side-loads.
if (ignoreSideloads && !(loc.scope & startupScanScopes)) {
continue;
}
if (!loc.enumerable) {
continue;
}
// Don't bother scanning scopes where we don't have addons installed if they
// do not allow sideloading new addons. Once we have an addon in one of those
// locations, we need to check the location for changes (updates/deletions).
if (!loc.size && !(loc.scope & AddonSettings.SCOPES_SIDELOAD)) {
continue;
}
let knownIds = new Set(loc.keys());
for (let [id, file] of loc.readAddons()) {
knownIds.delete(id);
let xpiState = loc.get(id);
if (!xpiState) {
// If the location is not supported for sideloading, skip new
// addons. We handle this here so changes for existing sideloads
// will function.
if (!loc.isSystem && !(loc.scope & AddonSettings.SCOPES_SIDELOAD)) {
continue;
}
logger.debug("New add-on ${id} in ${loc}", { id, loc: loc.name });
changed = true;
xpiState = loc.addFile(id, file);
if (!loc.isSystem) {
this.sideLoadedAddons.set(id, xpiState);
}
} else {
let addonChanged =
xpiState.getModTime(file) || file.path != xpiState.path;
xpiState.file = file.clone();
if (addonChanged) {
changed = true;
logger.debug("Changed add-on ${id} in ${loc}", {
id,
loc: loc.name,
});
} else {
logger.debug("Existing add-on ${id} in ${loc}", {
id,
loc: loc.name,
});
}
}
XPIProvider.addTelemetry(id, { location: loc.name });
}
// Anything left behind in oldState was removed from the file system.
for (let id of knownIds) {
loc.delete(id);
changed = true;
}
}
// If there's anything left in oldState, an install location that held add-ons
// was removed from the browser configuration.
changed = changed || oldLocations.size > 0;
logger.debug("scanForChanges changed: ${rv}, state: ${state}", {
rv: changed,
state: this.db,
});
return changed;
},
locations() {
return this.db.values();
},
/**
* @param {string} name
* The location name.
* @param {XPIStateLocation} location
* The location object.
*/
addLocation(name, location) {
if (this.db.has(name)) {
throw new Error(`Trying to add duplicate location: ${name}`);
}
this.db.set(name, location);
},
/**
* Get the Map of XPI states for a particular location.
*
* @param {string} name
* The name of the install location.
*
* @returns {XPIStateLocation?}
* (id -> XPIState) or null if there are no add-ons in the location.
*/
getLocation(name) {
return this.db.get(name);
},
/**
* Get the XPI state for a specific add-on in a location.
* If the state is not in our cache, return null.
*
* @param {string} aLocation
* The name of the location where the add-on is installed.
* @param {string} aId
* The add-on ID
*
* @returns {XPIState?}
* The XPIState entry for the add-on, or null.
*/
getAddon(aLocation, aId) {
let location = this.db.get(aLocation);
return location && location.get(aId);
},
/**
* Find the highest priority location of an add-on by ID and return the
* XPIState.
* @param {string} aId
* The add-on IDa
* @param {function} aFilter
* An optional filter to apply to install locations. If provided,
* addons in locations that do not match the filter are not considered.
*
* @returns {XPIState?}
*/
findAddon(aId, aFilter = location => true) {
// Fortunately the Map iterator returns in order of insertion, which is
// also our highest -> lowest priority order.
for (let location of this.locations()) {
if (!aFilter(location)) {
continue;
}
if (location.has(aId)) {
return location.get(aId);
}
}
return undefined;
},
/**
* Iterates over the list of all enabled add-ons in any location.
*/
*enabledAddons() {
for (let location of this.locations()) {
for (let entry of location.values()) {
if (entry.enabled) {
yield entry;
}
}
}
},
/**
* Add a new XPIState for an add-on and synchronize it with the DBAddonInternal.
*
* @param {DBAddonInternal} aAddon
* The add-on to add.
*/
addAddon(aAddon) {
aAddon.location.addAddon(aAddon);
},
/**
* Save the current state of installed add-ons.
*/
save() {
if (!this._jsonFile) {
this._jsonFile = new JSONFile({
path: OS.Path.join(OS.Constants.Path.profileDir, FILE_XPI_STATES),
finalizeAt: AddonManagerPrivate.finalShutdown,
compression: "lz4",
});
this._jsonFile.data = this;
}
this._jsonFile.saveSoon();
},
toJSON() {
let data = {};
for (let [key, loc] of this.db.entries()) {
if (!loc.isTemporary && (loc.size || loc.hasStaged)) {
data[key] = loc;
}
}
return data;
},
/**
* Remove the XPIState for an add-on and save the new state.
*
* @param {string} aLocation
* The name of the add-on location.
* @param {string} aId
* The ID of the add-on.
*
*/
removeAddon(aLocation, aId) {
logger.debug(`Removing XPIState for ${aLocation}: ${aId}`);
let location = this.db.get(aLocation);
if (location) {
location.removeAddon(aId);
this.save();
}
},
/**
* Disable the XPIState for an add-on.
*
* @param {string} aId
* The ID of the add-on.
*/
disableAddon(aId) {
logger.debug(`Disabling XPIState for ${aId}`);
let state = this.findAddon(aId);
if (state) {
state.enabled = false;
}
},
};
/**
* A helper class to manage the lifetime of and interaction with
* bootstrap scopes for an add-on.
*
* @param {Object} addon
* The add-on which owns this scope. Should be either an
* AddonInternal or XPIState object.
*/
class BootstrapScope {
constructor(addon) {
if (!addon.id || !addon.version || !addon.type) {
throw new Error("Addon must include an id, version, and type");
}
this.addon = addon;
this.instanceID = null;
this.scope = null;
this.started = false;
}
/**
* Returns a BootstrapScope object for the given add-on. If an active
* scope exists, it is returned. Otherwise a new one is created.
*
* @param {Object} addon
* The add-on which owns this scope, as accepted by the
* constructor.
* @returns {BootstrapScope}
*/
static get(addon) {
let scope = XPIProvider.activeAddons.get(addon.id);
if (!scope) {
scope = new this(addon);
}
return scope;
}
get file() {
return this.addon.file || this.addon._sourceBundle;
}
get runInSafeMode() {
return "runInSafeMode" in this.addon
? this.addon.runInSafeMode
: canRunInSafeMode(this.addon);
}
/**
* Returns state information for use by an AsyncShutdown blocker. If
* the wrapped bootstrap scope has a fetchState method, it is called,
* and its result returned. If not, returns null.
*
* @returns {Object|null}
*/
fetchState() {
if (this.scope && this.scope.fetchState) {
return this.scope.fetchState();
}
return null;
}
/**
* Calls a bootstrap method for an add-on.
*
* @param {string} aMethod
* The name of the bootstrap method to call
* @param {integer} aReason
* The reason flag to pass to the bootstrap's startup method
* @param {Object} [aExtraParams = {}]
* An object of additional key/value pairs to pass to the method in
* the params argument
* @returns {any}
* The return value of the bootstrap method.
*/
async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) {
let { addon, runInSafeMode } = this;
if (
Services.appinfo.inSafeMode &&
!runInSafeMode &&
aMethod !== "uninstall"
) {
return null;
}
try {
if (!this.scope) {
this.loadBootstrapScope(aReason);
}
if (aMethod == "startup" || aMethod == "shutdown") {
aExtraParams.instanceID = this.instanceID;
}
let method = undefined;
let { scope } = this;
try {
method = scope[aMethod];
} catch (e) {
// An exception will be caught if the expected method is not defined.
// That will be logged below.
}
if (aMethod == "startup") {
this.started = true;
} else if (aMethod == "shutdown") {
this.started = false;
// Extensions are automatically deinitialized in the correct order at shutdown.
if (aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
this._pendingDisable = true;
for (let addon of XPIProvider.getDependentAddons(this.addon)) {
if (addon.active) {
await XPIDatabase.updateAddonDisabledState(addon);
}
}
}
}
let params = {
id: addon.id,
version: addon.version,
resourceURI: addon.resolvedRootURI,
signedState: addon.signedState,
temporarilyInstalled: addon.location.isTemporary,
builtIn: addon.location.isBuiltin,
isSystem: addon.location.isSystem,
};
if (aMethod == "startup" && addon.startupData) {
params.startupData = addon.startupData;
}
Object.assign(params, aExtraParams);
let result;
if (!method) {
logger.warn(
`Add-on ${addon.id} is missing bootstrap method ${aMethod}`
);
} else {
logger.debug(
`Calling bootstrap method ${aMethod} on ${addon.id} version ${addon.version}`
);
this._beforeCallBootstrapMethod(aMethod, params, aReason);
try {
result = await method.call(scope, params, aReason);
} catch (e) {
logger.warn(
`Exception running bootstrap method ${aMethod} on ${addon.id}`,
e
);
}
}
return result;
} finally {
// Extensions are automatically initialized in the correct order at startup.
if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
for (let addon of XPIProvider.getDependentAddons(this.addon)) {
XPIDatabase.updateAddonDisabledState(addon);
}
}
}
}
// No-op method to be overridden by tests.
_beforeCallBootstrapMethod() {}
/**
* Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason
* values as constants in the scope.
*
* @param {integer?} [aReason]
* The reason this bootstrap is being loaded, as passed to a
* bootstrap method.
*/
loadBootstrapScope(aReason) {
this.instanceID = Symbol(this.addon.id);
this._pendingDisable = false;
XPIProvider.activeAddons.set(this.addon.id, this);
// Mark the add-on as active for the crash reporter before loading.
// But not at app startup, since we'll already have added all of our
// annotations before starting any loads.
if (aReason !== BOOTSTRAP_REASONS.APP_STARTUP) {
XPIProvider.addAddonsToCrashReporter();
}
logger.debug(`Loading bootstrap scope from ${this.addon.rootURI}`);
if (this.addon.isWebExtension) {
switch (this.addon.type) {
case "extension":
case "theme":
this.scope = Extension.getBootstrapScope();
break;
case "locale":
this.scope = Langpack.getBootstrapScope();
break;
case "dictionary":
this.scope = Dictionary.getBootstrapScope();
break;