author | Kris Maglione <maglione.k@gmail.com> |
Mon, 09 Apr 2018 17:21:13 -0700 | |
changeset 412741 | bc804d75911a04cc104898a0cee538e761f5582b |
parent 412740 | 19d7d934850c1c3efcda7b1227cfa5ffcd2ce63b |
child 412742 | ac40ae7983a0235ca4d98e3f769ae3c60b8b6d55 |
push id | 33818 |
push user | apavel@mozilla.com |
push date | Wed, 11 Apr 2018 14:36:40 +0000 |
treeherder | mozilla-central@cfe6399e142c [default view] [failures only] |
perfherder | [talos] [build metrics] [platform microbench] (compared to previous push) |
reviewers | aswan |
bugs | 1452827 |
milestone | 61.0a1 |
first release with | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
last release without | nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
|
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm +++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm @@ -3,22 +3,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; var EXPORTED_SYMBOLS = [ "DownloadAddonInstall", "LocalAddonInstall", "UpdateChecker", + "XPIInstall", "loadManifestFromFile", "verifyBundleSignedState", ]; /* globals DownloadAddonInstall, LocalAddonInstall */ +Cu.importGlobalProperties(["TextDecoder", "TextEncoder", "fetch"]); + ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); ChromeUtils.import("resource://gre/modules/AddonManager.jsm"); ChromeUtils.defineModuleGetter(this, "AddonRepository", "resource://gre/modules/addons/AddonRepository.jsm"); ChromeUtils.defineModuleGetter(this, "AddonSettings", "resource://gre/modules/addons/AddonSettings.jsm"); @@ -41,23 +44,30 @@ ChromeUtils.defineModuleGetter(this, "OS "resource://gre/modules/osfile.jsm"); ChromeUtils.defineModuleGetter(this, "ZipUtils", "resource://gre/modules/ZipUtils.jsm"); const {nsIBlocklistService} = Ci; const nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile", "initWithPath"); +const CryptoHash = Components.Constructor("@mozilla.org/security/hash;1", + "nsICryptoHash", "initWithString"); +const ZipReader = Components.Constructor("@mozilla.org/libjar/zip-reader;1", + "nsIZipReader", "open"); -XPCOMUtils.defineLazyServiceGetter(this, "gCertDB", - "@mozilla.org/security/x509certdb;1", - "nsIX509CertDB"); -XPCOMUtils.defineLazyServiceGetter(this, "gRDF", "@mozilla.org/rdf/rdf-service;1", - Ci.nsIRDFService); +const RDFDataSource = Components.Constructor( + "@mozilla.org/rdf/datasource;1?name=in-memory-datasource", "nsIRDFDataSource"); +const parseRDFString = Components.Constructor( + "@mozilla.org/rdf/xml-parser;1", "nsIRDFXMLParser", "parseString"); +XPCOMUtils.defineLazyServiceGetters(this, { + gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"], + gRDF: ["@mozilla.org/rdf/rdf-service;1", "nsIRDFService"], +}); ChromeUtils.defineModuleGetter(this, "XPIInternal", "resource://gre/modules/addons/XPIProvider.jsm"); ChromeUtils.defineModuleGetter(this, "XPIProvider", "resource://gre/modules/addons/XPIProvider.jsm"); const PREF_ALLOW_NON_RESTARTLESS = "extensions.legacy.non-restartless.enabled"; @@ -115,17 +125,16 @@ function getFile(path, base = null) { file.appendRelativePath(path); return file; } 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 PREF_INSTALL_REQUIREBUILTINCERTS = "extensions.install.requireBuiltInCerts"; -const FILE_RDF_MANIFEST = "install.rdf"; const FILE_WEB_MANIFEST = "manifest.json"; const KEY_TEMPDIR = "TmpD"; const RDFURI_INSTALL_MANIFEST_ROOT = "urn:mozilla:install-manifest"; const PREFIX_NS_EM = "http://www.mozilla.org/2004/em-rdf#"; // Properties that exist in the install manifest @@ -179,16 +188,193 @@ var gIDTest = /^(\{[0-9a-f]{8}-[0-9a-f]{ 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); +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() {} + + getURI(...path) { + return Services.io.newURI(path.join("/"), null, this.rootURI); + } + + async getManifestFile() { + if (await this.hasResource("manifest.json")) { + return "manifest.json"; + } + if (await this.hasResource("install.rdf")) { + return "install.rdf"; + } + return null; + } + + async readString(...path) { + let buffer = await this.readBinary(...path); + return new TextDecoder().decode(buffer); + } + + async verifySignedState(addon) { + if (!shouldVerifySignedState(addon)) { + 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(addon, root); + } +} + +DirPackage = class DirPackage extends Package { + constructor(file) { + super(file, Services.io.newFileURI(file)); + } + + hasResource(...path) { + return OS.File.exists(OS.Path.join(this.filePath, ...path)); + } + + async iterDirectory(path, callback) { + let fullPath = OS.Path.join(this.filePath, ...path); + + let iter = new OS.File.DirectoryIterator(fullPath); + await iter.forEach(callback); + iter.close(); + } + + 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 { + let stat = await OS.File.stat(OS.Path.join(this.filePath, ...entryPath)); + callback({ + path: entryPath.join("/"), + isDir: false, + size: stat.size, + }); + } + }); + } + + readBinary(...path) { + return OS.File.read(OS.Path.join(this.filePath, ...path)); + } + + verifySignedStateForRoot(addon, root) { + return new Promise(resolve => { + let callback = { + verifySignedDirectoryFinished(aRv, aCert) { + resolve({ + signedState: getSignedStatus(aRv, aCert, addon.id), + 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; + + gCertDB.verifySignedDirectoryAsync(root, this.file, callback); + }); + } +}; + +XPIPackage = class XPIPackage extends Package { + constructor(file) { + super(file, getJarURI(file)); + + this.zipReader = new ZipReader(file); + } + + close() { + this.zipReader.close(); + this.zipReader = null; + } + + async hasResource(...path) { + return this.zipReader.hasEntry(path.join("/")); + } + + async iterFiles(callback) { + for (let path of XPCOMUtils.IterStringEnumerator(this.zipReader.findEntries("*"))) { + let entry = this.zipReader.getEntry(path); + callback({ + path, + isDir: entry.isDirectory, + size: entry.realSize, + }); + } + } + + async readBinary(...path) { + let response = await fetch(this.rootURI.resolve(path.join("/"))); + return response.arrayBuffer(); + } + + verifySignedStateForRoot(addon, root) { + return new Promise(resolve => { + let callback = { + openSignedAppFileFinished(aRv, aZipReader, aCert) { + if (aZipReader) + aZipReader.close(); + resolve({ + signedState: getSignedStatus(aRv, aCert, addon.id), + 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; + + gCertDB.openSignedAppFileAsync(root, this.file, callback); + }); + } +}; /** * Sets permissions on a file * * @param aFile * The file or directory to operate on. * @param aPermissions * The permissions to set @@ -201,34 +387,16 @@ function setFilePermissions(aFile, aPerm aFile.path, e); } } function EM_R(aProperty) { return gRDF.GetResource(PREFIX_NS_EM + aProperty); } -function getManifestFileForDir(aDir) { - let file = getFile(FILE_RDF_MANIFEST, aDir); - if (file.exists() && file.isFile()) - return file; - file.leafName = FILE_WEB_MANIFEST; - if (file.exists() && file.isFile()) - return file; - return null; -} - -function getManifestEntryForZipReader(aZipReader) { - if (aZipReader.hasEntry(FILE_RDF_MANIFEST)) - return FILE_RDF_MANIFEST; - if (aZipReader.hasEntry(FILE_WEB_MANIFEST)) - return FILE_WEB_MANIFEST; - return null; -} - /** * Converts an RDF literal, resource or integer into a string. * * @param aLiteral * The RDF object to convert * @return a string if the object could be converted or null */ function getRDFValue(aLiteral) { @@ -386,23 +554,23 @@ async function loadManifestFromWebManife return addon; } /** * Reads an AddonInternal object from an RDF stream. * * @param aUri * The URI that the manifest is being read from - * @param aStream - * An open stream to read the RDF from + * @param aData + * The manifest text * @return an AddonInternal object * @throws if the install manifest in the RDF stream is corrupt or could not * be read */ -async function loadManifestFromRDF(aUri, aStream) { +async function loadManifestFromRDF(aUri, aData) { function getPropertyArray(aDs, aSource, aProperty) { let values = []; let targets = aDs.GetTargets(aSource, EM_R(aProperty), true); while (targets.hasMoreElements()) values.push(getRDFValue(targets.getNext())); return values; } @@ -459,43 +627,18 @@ async function loadManifestFromRDF(aUri, prop.substring(0, prop.length - 1)); if (props.length > 0) locale[prop] = props; } return locale; } - let rdfParser = Cc["@mozilla.org/rdf/xml-parser;1"]. - createInstance(Ci.nsIRDFXMLParser); - let ds = Cc["@mozilla.org/rdf/datasource;1?name=in-memory-datasource"]. - createInstance(Ci.nsIRDFDataSource); - let listener = rdfParser.parseAsync(ds, aUri); - let channel = Cc["@mozilla.org/network/input-stream-channel;1"]. - createInstance(Ci.nsIInputStreamChannel); - channel.setURI(aUri); - channel.contentStream = aStream; - channel.QueryInterface(Ci.nsIChannel); - channel.contentType = "text/xml"; - - listener.onStartRequest(channel, null); - - try { - let pos = 0; - let count = aStream.available(); - while (count > 0) { - listener.onDataAvailable(channel, null, aStream, pos, count); - pos += count; - count = aStream.available(); - } - listener.onStopRequest(channel, null, Cr.NS_OK); - } catch (e) { - listener.onStopRequest(channel, null, e.result); - throw e; - } + let ds = new RDFDataSource(); + parseRDFString(ds, aUri, aData); let root = gRDF.GetResource(RDFURI_INSTALL_MANIFEST_ROOT); let addon = new AddonInternal(); for (let prop of PROP_METADATA) { addon[prop] = getRDFProperty(ds, root, prop); } if (!addon.type) { @@ -685,239 +828,98 @@ function defineSyncGUID(aAddon) { }, configurable: true, enumerable: true, }); } // Generate a unique ID based on the path to this temporary add-on location. function generateTemporaryInstallID(aFile) { - const hasher = Cc["@mozilla.org/security/hash;1"] - .createInstance(Ci.nsICryptoHash); - hasher.init(hasher.SHA1); + 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)}${TEMPORARY_ADDON_SUFFIX}`; logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`); return id; } -/** - * Loads an AddonInternal object from an add-on extracted in a directory. - * - * @param aDir - * The nsIFile directory holding the add-on - * @return an AddonInternal object - * @throws if the directory does not contain a valid install manifest - */ -var loadManifestFromDir = async function(aDir, aInstallLocation) { - function getFileSize(aFile) { - if (aFile.isSymlink()) - return 0; - - if (!aFile.isDirectory()) - return aFile.fileSize; +var loadManifest = async function(aPackage, aInstallLocation) { + async function loadFromRDF(aUri) { + let manifest = await aPackage.readString("install.rdf"); + let addon = await loadManifestFromRDF(aUri, manifest); - let size = 0; - let entries = aFile.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator); - let entry; - while ((entry = entries.nextFile)) - size += getFileSize(entry); - entries.close(); - return size; - } - - async function loadFromRDF(aUri) { - let fis = Cc["@mozilla.org/network/file-input-stream;1"]. - createInstance(Ci.nsIFileInputStream); - fis.init(aUri.file, -1, -1, false); - let bis = Cc["@mozilla.org/network/buffered-input-stream;1"]. - createInstance(Ci.nsIBufferedInputStream); - bis.init(fis, 4096); - try { - var addon = await loadManifestFromRDF(aUri, bis); - } finally { - bis.close(); - fis.close(); - } - - let iconFile = getFile("icon.png", aDir); - - if (iconFile.exists()) { + if (await aPackage.hasResource("icon.png")) { addon.icons[32] = "icon.png"; addon.icons[48] = "icon.png"; } - let icon64File = getFile("icon64.png", aDir); - - if (icon64File.exists()) { + if (await aPackage.hasResource("icon64.png")) { addon.icons[64] = "icon64.png"; } + return addon; } - let file = getManifestFileForDir(aDir); - if (!file) { - throw new Error("Directory " + aDir.path + " does not contain a valid " + + let entry = await aPackage.getManifestFile(); + if (!entry) { + throw new Error("File " + aPackage.filePath + " does not contain a valid " + "install manifest"); } - let uri = Services.io.newFileURI(file).QueryInterface(Ci.nsIFileURL); + let isWebExtension = entry == FILE_WEB_MANIFEST; + let addon = isWebExtension ? + await loadManifestFromWebManifest(aPackage.rootURI) : + await loadFromRDF(aPackage.getURI("install.rdf")); + + addon._sourceBundle = aPackage.file; + addon._installLocation = aInstallLocation; - let addon; - if (file.leafName == FILE_WEB_MANIFEST) { - addon = await loadManifestFromWebManifest(uri); - if (!addon.id) { - if (aInstallLocation.name == KEY_APP_TEMPORARY) { - addon.id = generateTemporaryInstallID(aDir); - } else { - addon.id = aDir.leafName; + addon.size = 0; + await aPackage.iterFiles(entry => { + if (!entry.isDir) { + addon.size += entry.size; + } + }); + + let {signedState, cert} = await aPackage.verifySignedState(addon); + addon.signedState = signedState; + + if (isWebExtension && !addon.id) { + if (cert) { + addon.id = cert.commonName; + if (!gIDTest.test(addon.id)) { + throw new Error(`Webextension is signed with an invalid id (${addon.id})`); } } - } else { - addon = await loadFromRDF(uri); + if (!addon.id && aInstallLocation.name == KEY_APP_TEMPORARY) { + addon.id = generateTemporaryInstallID(aPackage.file); + } } - addon._sourceBundle = aDir.clone(); - addon._installLocation = aInstallLocation; - addon.size = getFileSize(aDir); - addon.signedState = await verifyDirSignedState(aDir, addon) - .then(({signedState}) => signedState); addon.updateBlocklistState(); addon.appDisabled = !isUsableAddon(addon); defineSyncGUID(addon); return addon; }; -/** - * Loads an AddonInternal object from an nsIZipReader for an add-on. - * - * @param aZipReader - * An open nsIZipReader for the add-on's files - * @return an AddonInternal object - * @throws if the XPI file does not contain a valid install manifest - */ -var loadManifestFromZipReader = async function(aZipReader, aInstallLocation) { - async function loadFromRDF(aUri) { - let zis = aZipReader.getInputStream(entry); - let bis = Cc["@mozilla.org/network/buffered-input-stream;1"]. - createInstance(Ci.nsIBufferedInputStream); - bis.init(zis, 4096); - try { - var addon = await loadManifestFromRDF(aUri, bis); - } finally { - bis.close(); - zis.close(); - } - - if (aZipReader.hasEntry("icon.png")) { - addon.icons[32] = "icon.png"; - addon.icons[48] = "icon.png"; - } - - if (aZipReader.hasEntry("icon64.png")) { - addon.icons[64] = "icon64.png"; - } - +var loadManifestFromFile = async function(aFile, aInstallLocation) { + let pkg = Package.get(aFile); + try { + let addon = await loadManifest(pkg, aInstallLocation); return addon; - } - - let entry = getManifestEntryForZipReader(aZipReader); - if (!entry) { - throw new Error("File " + aZipReader.file.path + " does not contain a valid " + - "install manifest"); - } - - let uri = buildJarURI(aZipReader.file, entry); - - let isWebExtension = (entry == FILE_WEB_MANIFEST); - - let addon = isWebExtension ? - await loadManifestFromWebManifest(uri) : - await loadFromRDF(uri); - - addon._sourceBundle = aZipReader.file; - addon._installLocation = aInstallLocation; - - addon.size = 0; - let entries = aZipReader.findEntries(null); - while (entries.hasMore()) - addon.size += aZipReader.getEntry(entries.getNext()).realSize; - - let {signedState, cert} = await verifyZipSignedState(aZipReader.file, addon); - addon.signedState = signedState; - if (isWebExtension && !addon.id) { - if (cert) { - addon.id = cert.commonName; - if (!gIDTest.test(addon.id)) { - throw new Error(`Webextension is signed with an invalid id (${addon.id})`); - } - } - if (!addon.id && aInstallLocation.name == KEY_APP_TEMPORARY) { - addon.id = generateTemporaryInstallID(aZipReader.file); - } - } - addon.updateBlocklistState(); - addon.appDisabled = !isUsableAddon(addon); - - defineSyncGUID(addon); - - return addon; -}; - -/** - * Loads an AddonInternal object from an add-on in an XPI file. - * - * @param aXPIFile - * An nsIFile pointing to the add-on's XPI file - * @return an AddonInternal object - * @throws if the XPI file does not contain a valid install manifest - */ -var loadManifestFromZipFile = async function(aXPIFile, aInstallLocation) { - let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"]. - createInstance(Ci.nsIZipReader); - try { - zipReader.open(aXPIFile); - - // Can't return this promise because that will make us close the zip reader - // before it has finished loading the manifest. Wait for the result and then - // return. - let manifest = await loadManifestFromZipReader(zipReader, aInstallLocation); - return manifest; } finally { - zipReader.close(); + pkg.close(); } }; -var loadManifestFromFile = function(aFile, aInstallLocation) { - if (aFile.isFile()) - return loadManifestFromZipFile(aFile, aInstallLocation); - return loadManifestFromDir(aFile, aInstallLocation); -}; - -/** - * Creates a jar: URI for a file inside a ZIP file. - * - * @param aJarfile - * The ZIP file as an nsIFile - * @param aPath - * The path inside the ZIP file - * @return 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); -} - /** * Sends local and remote notifications to flush a JAR file cache entry * * @param aJarFile * The ZIP/XPI/JAR file as a nsIFile */ function flushJarCache(aJarFile) { Services.obs.notifyObservers(aJarFile, "flush-cache-entry"); @@ -951,24 +953,19 @@ function getTemporaryFile() { /** * Returns the signedState for a given return code and certificate by verifying * it against the expected ID. */ function getSignedStatus(aRv, aCert, aAddonID) { let expectedCommonName = aAddonID; if (aAddonID && aAddonID.length > 64) { - let converter = Cc["@mozilla.org/intl/scriptableunicodeconverter"]. - createInstance(Ci.nsIScriptableUnicodeConverter); - converter.charset = "UTF-8"; - let data = converter.convertToByteArray(aAddonID, {}); + let data = new Uint8Array(new TextEncoder().encode(aAddonID)); - let crypto = Cc["@mozilla.org/security/hash;1"]. - createInstance(Ci.nsICryptoHash); - crypto.init(Ci.nsICryptoHash.SHA256); + let crypto = CryptoHash("sha256"); crypto.update(data, data.length); expectedCommonName = getHashStringForCrypto(crypto); } switch (aRv) { case Cr.NS_OK: if (expectedCommonName && expectedCommonName != aCert.commonName) return AddonManager.SIGNEDSTATE_BROKEN; @@ -1010,111 +1007,33 @@ function shouldVerifySignedState(aAddon) return false; // Otherwise only check signatures if signing is enabled and the add-on is one // of the signed types. return AddonSettings.ADDON_SIGNING && SIGNED_TYPES.has(aAddon.type); } /** - * Verifies that a zip file's contents are all correctly signed by an - * AMO-issued certificate - * - * @param aFile - * the xpi file to check - * @param aAddon - * the add-on object to verify - * @return a Promise that resolves to an object with properties: - * signedState: an AddonManager.SIGNEDSTATE_* constant - * cert: an nsIX509Cert - */ -function verifyZipSignedState(aFile, aAddon) { - if (!shouldVerifySignedState(aAddon)) - return Promise.resolve({ - 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 new Promise(resolve => { - let callback = { - openSignedAppFileFinished(aRv, aZipReader, aCert) { - if (aZipReader) - aZipReader.close(); - resolve({ - signedState: getSignedStatus(aRv, aCert, aAddon.id), - 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; - - gCertDB.openSignedAppFileAsync(root, aFile, callback); - }); -} - -/** - * Verifies that a directory's contents are all correctly signed by an - * AMO-issued certificate - * - * @param aDir - * the directory to check - * @param aAddon - * the add-on object to verify - * @return a Promise that resolves to an object with properties: - * signedState: an AddonManager.SIGNEDSTATE_* constant - * cert: an nsIX509Cert - */ -function verifyDirSignedState(aDir, aAddon) { - if (!shouldVerifySignedState(aAddon)) - return Promise.resolve({ - 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 new Promise(resolve => { - let callback = { - verifySignedDirectoryFinished(aRv, aCert) { - resolve({ - signedState: getSignedStatus(aRv, aCert, aAddon.id), - cert: null, - }); - } - }; - // 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; - - gCertDB.verifySignedDirectoryAsync(root, aDir, callback); - }); -} - -/** * Verifies that a bundle's contents are all correctly signed by an * AMO-issued certificate * * @param aBundle * the nsIFile for the bundle to check, either a directory or zip file * @param aAddon * the add-on object to verify * @return a Promise that resolves to an AddonManager.SIGNEDSTATE_* constant. */ -var verifyBundleSignedState = function(aBundle, aAddon) { - let promise = aBundle.isFile() ? verifyZipSignedState(aBundle, aAddon) - : verifyDirSignedState(aBundle, aAddon); - return promise.then(({signedState}) => signedState); +var verifyBundleSignedState = async function(aBundle, aAddon) { + let pkg = Package.get(aBundle); + try { + let {signedState} = await pkg.verifySignedState(aAddon); + return signedState; + } finally { + pkg.close(); + } }; /** * Replaces %...% strings in an addon url (update and updateInfo) with * appropriate values. * * @param aAddon * The AddonInternal representing the add-on @@ -1497,72 +1416,67 @@ class AddonInstall { * manifest can be read. * * @param aCallback * A function to call when the manifest has been loaded * @throws if the add-on does not contain a valid install manifest or the * XPI is incorrectly signed */ async loadManifest(file) { - let zipreader = Cc["@mozilla.org/libjar/zip-reader;1"]. - createInstance(Ci.nsIZipReader); + let pkg; try { - zipreader.open(file); + pkg = Package.get(file); } catch (e) { - zipreader.close(); return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]); } try { - // loadManifestFromZipReader performs the certificate verification for us - this.addon = await loadManifestFromZipReader(zipreader, this.installLocation); - } catch (e) { - zipreader.close(); - return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]); - } + try { + this.addon = await loadManifest(pkg, this.installLocation); + } catch (e) { + return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]); + } - if (!this.addon.id) { - let err = new Error(`Cannot find id for addon ${file.path}`); - return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, err]); - } - - if (this.existingAddon) { - // Check various conditions related to upgrades - if (this.addon.id != this.existingAddon.id) { - zipreader.close(); - return Promise.reject([AddonManager.ERROR_INCORRECT_ID, - `Refusing to upgrade addon ${this.existingAddon.id} to different ID ${this.addon.id}`]); + if (!this.addon.id) { + let err = new Error(`Cannot find id for addon ${file.path}`); + return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, err]); } - if (isWebExtension(this.existingAddon.type) && !isWebExtension(this.addon.type)) { - zipreader.close(); - return Promise.reject([AddonManager.ERROR_UNEXPECTED_ADDON_TYPE, - "WebExtensions may not be updated to other extension types"]); + 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 (isWebExtension(this.existingAddon.type) && !isWebExtension(this.addon.type)) { + return Promise.reject([AddonManager.ERROR_UNEXPECTED_ADDON_TYPE, + "WebExtensions may not be updated to other extension types"]); + } } - } - if (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; - zipreader.close(); + if (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"]); + 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"]); + return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, + "signature verification failed"]); + } } + } finally { + pkg.close(); } - zipreader.close(); - this.updateAddonURIs(); 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 @@ -1590,17 +1504,17 @@ class AddonInstall { if (!this.addon.icons || !this.file) { return null; } let {icon} = IconDetails.getPreferredIcon(this.addon.icons, null, desiredSize); if (icon.startsWith("chrome://")) { return icon; } - return buildJarURI(this.file, icon).spec; + 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). @@ -1934,20 +1848,19 @@ var LocalAddonInstall = class extends Ad return; } this.state = AddonManager.STATE_DOWNLOADED; this.progress = this.file.fileSize; this.maxProgress = this.file.fileSize; if (this.hash) { - let crypto = Cc["@mozilla.org/security/hash;1"]. - createInstance(Ci.nsICryptoHash); + let crypto; try { - crypto.initWithString(this.hash.algorithm); + crypto = CryptoHash(this.hash.algorithm); } catch (e) { logger.warn("Unknown hash algorithm '" + this.hash.algorithm + "' for addon " + this.sourceURI.spec, e); this.state = AddonManager.STATE_DOWNLOAD_FAILED; this.error = AddonManager.ERROR_INCORRECT_HASH; XPIProvider.removeActiveInstall(this); return; } @@ -2232,35 +2145,33 @@ var DownloadAddonInstall = class extends } /** * This is the first chance to get at real headers on the channel. * * @see nsIStreamListener */ onStartRequest(aRequest, aContext) { - this.crypto = Cc["@mozilla.org/security/hash;1"]. - createInstance(Ci.nsICryptoHash); if (this.hash) { try { - this.crypto.initWithString(this.hash.algorithm); + this.crypto = CryptoHash(this.hash.algorithm); } catch (e) { logger.warn("Unknown hash algorithm '" + this.hash.algorithm + "' for addon " + this.sourceURI.spec, e); this.state = AddonManager.STATE_DOWNLOAD_FAILED; this.error = AddonManager.ERROR_INCORRECT_HASH; XPIProvider.removeActiveInstall(this); AddonManagerPrivate.callInstallListeners("onDownloadFailed", this.listeners, this.wrapper); aRequest.cancel(Cr.NS_BINDING_ABORTED); return; } } else { // We always need something to consume data from the inputstream passed // to onDataAvailable so just create a dummy cryptohasher to do that. - this.crypto.initWithString("sha1"); + this.crypto = CryptoHash("sha1"); } this.progress = 0; if (aRequest instanceof Ci.nsIChannel) { try { this.maxProgress = aRequest.contentLength; } catch (e) { } @@ -2739,8 +2650,14 @@ UpdateChecker.prototype = { let parser = this._parser; if (parser) { this._parser = null; // This will call back to onUpdateCheckError with a CANCELLED error parser.cancel(); } } }; + +var XPIInstall = { + flushChromeCaches, + flushJarCache, + recursiveRemove, +};
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm +++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm @@ -30,16 +30,17 @@ XPCOMUtils.defineLazyModuleGetters(this, JSONFile: "resource://gre/modules/JSONFile.jsm", LegacyExtensionsUtils: "resource://gre/modules/LegacyExtensionsUtils.jsm", setTimeout: "resource://gre/modules/Timer.jsm", clearTimeout: "resource://gre/modules/Timer.jsm", DownloadAddonInstall: "resource://gre/modules/addons/XPIInstall.jsm", LocalAddonInstall: "resource://gre/modules/addons/XPIInstall.jsm", UpdateChecker: "resource://gre/modules/addons/XPIInstall.jsm", + XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm", loadManifestFromFile: "resource://gre/modules/addons/XPIInstall.jsm", verifyBundleSignedState: "resource://gre/modules/addons/XPIInstall.jsm", }); const {nsIBlocklistService} = Ci; XPCOMUtils.defineLazyServiceGetters(this, { Blocklist: ["@mozilla.org/extensions/blocklist;1", "nsIBlocklistService"], @@ -247,19 +248,16 @@ const XPI_BEFORE_UI_STARTUP = "BeforeFin // event happened after final-ui-startup const XPI_AFTER_UI_STARTUP = "AfterFinalUIStartup"; const COMPATIBLE_BY_DEFAULT_TYPES = { extension: true, dictionary: true }; -const MSG_JAR_FLUSH = "AddonJarFlush"; -const MSG_MESSAGE_MANAGER_CACHES_FLUSH = "AddonMessageManagerCachesFlush"; - 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; ChromeUtils.import("resource://gre/modules/Log.jsm"); @@ -300,17 +298,17 @@ function loadLazyObjects() { DB_SCHEMA, DEFAULT_SKIN, AddonInternal, XPIProvider, XPIStates, syncLoadManifestFromFile, isUsableAddon, recordAddonTelemetry, - flushChromeCaches, + flushChromeCaches: XPIInstall.flushChromeCaches, descriptorToPath, }); Services.scriptloader.loadSubScript(uri, scope); for (let name of LAZY_OBJECTS) { delete gGlobalScope[name]; gGlobalScope[name] = scope[name]; @@ -735,17 +733,17 @@ SafeInstallOperation.prototype = { // 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 > 0) - recursiveRemove(this._createdDirs.pop()); + XPIInstall.recursiveRemove(this._createdDirs.pop()); } }; /** * Evaluates whether an add-on is allowed to run in safe mode. * * @param aAddon * The add-on to check @@ -977,87 +975,16 @@ function getURIForResourceInFile(aFile, */ function buildJarURI(aJarfile, aPath) { let uri = Services.io.newFileURI(aJarfile); uri = "jar:" + uri.spec + "!/" + aPath; return Services.io.newURI(uri); } /** - * Sends local and remote notifications to flush a JAR file cache entry - * - * @param aJarFile - * The ZIP/XPI/JAR file as a nsIFile - */ -function flushJarCache(aJarFile) { - Services.obs.notifyObservers(aJarFile, "flush-cache-entry"); - Services.mm.broadcastAsyncMessage(MSG_JAR_FLUSH, aJarFile.path); -} - -function flushChromeCaches() { - // Init this, so it will get the notification. - Services.obs.notifyObservers(null, "startupcache-invalidate"); - // Flush message manager cached scripts - Services.obs.notifyObservers(null, "message-manager-flush-caches"); - // Also dispatch this event to child processes - Services.mm.broadcastAsyncMessage(MSG_MESSAGE_MANAGER_CACHES_FLUSH, null); -} - -/** - * Recursively removes a directory or file fixing permissions when necessary. - * - * @param 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 - // data fork for the file. See bug 733436. - if (e.result == Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST) - return; - if (e.result == Cr.NS_ERROR_FILE_NOT_FOUND) - return; - - throw e; - } - - setFilePermissions(aFile, isDir ? FileUtils.PERMS_DIRECTORY - : 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 - // embedded filesystem has this issue, see bug 772238), and to remove - // normal files before their resource forks on OSX (see bug 733436). - let entries = getDirectoryEntries(aFile, true); - entries.forEach(recursiveRemove); - - try { - aFile.remove(true); - } catch (e) { - logger.error("Failed to remove empty directory " + aFile.path, e); - throw e; - } -} - -/** * Gets a snapshot of directory entries. * * @param aDir * Directory to look at * @param aSortEntries * True to sort entries by filename * @return An array of nsIFile, or an empty array if aDir is not a readable directory */ @@ -2781,17 +2708,17 @@ var XPIProvider = { let newVersion = addon.version; let oldVersion = existingAddon; let uninstallReason = newVersionReason(oldVersion, newVersion); this.callBootstrapMethod(existingAddon, file, "uninstall", uninstallReason, { newVersion }); this.unloadBootstrapScope(id); - flushChromeCaches(); + XPIInstall.flushChromeCaches(); } } catch (e) { Cu.reportError(e); } } try { addon._sourceBundle = location.installAddon({ @@ -3337,17 +3264,17 @@ var XPIProvider = { * (see MutableDirectoryInstallLocation.installAddon) * * @return a Promise that resolves to an Addon object on success, or rejects * if the add-on is not a valid restartless add-on or if the * same ID is already installed. */ async installAddonFromLocation(aFile, aInstallLocation, aInstallAction) { if (aFile.exists() && aFile.isFile()) { - flushJarCache(aFile); + XPIInstall.flushJarCache(aFile); } let addon = await loadManifestFromFile(aFile, aInstallLocation); aInstallLocation.installAddon({ id: addon.id, source: aFile, action: aInstallAction }); if (addon.appDisabled) { let message = `Add-on ${addon.id} is not compatible with application version.`; @@ -3407,17 +3334,17 @@ var XPIProvider = { extraParams); } if (!callUpdate) { this.callBootstrapMethod(oldAddon, existingAddon, "uninstall", uninstallReason, extraParams); } this.unloadBootstrapScope(existingAddonID); - flushChromeCaches(); + XPIInstall.flushChromeCaches(); } } else { addon.installDate = Date.now(); } let file = addon._sourceBundle; let method = callUpdate ? "update" : "install"; @@ -4223,17 +4150,17 @@ var XPIProvider = { } if (!callUpdate) { this.callBootstrapMethod(aAddon, aAddon._sourceBundle, "uninstall", reason); } XPIStates.disableAddon(aAddon.id); this.unloadBootstrapScope(aAddon.id); - flushChromeCaches(); + XPIInstall.flushChromeCaches(); } aAddon._installLocation.uninstallAddon(aAddon.id); XPIDatabase.removeAddonMetadata(aAddon); XPIStates.removeAddon(aAddon.location, aAddon.id); AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper); if (existingAddon) { XPIDatabase.getAddonInLocation(aAddon.id, existingAddon.location.name, existing => { @@ -5658,17 +5585,17 @@ class MutableDirectoryInstallLocation ex * An array of file or directory to remove from the directory, the * array may be empty */ cleanStagingDir(aLeafNames = []) { let dir = this.getStagingDir(); for (let name of aLeafNames) { let file = getFile(name, dir); - recursiveRemove(file); + XPIInstall.recursiveRemove(file); } if (this._stagingDirLock > 0) return; let dirEntries = dir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator); try { if (dirEntries.nextFile) @@ -5694,17 +5621,17 @@ class MutableDirectoryInstallLocation ex * * @return an nsIFile */ getTrashDir() { let trashDir = getFile(DIR_TRASH, this._directory); let trashDirExists = trashDir.exists(); try { if (trashDirExists) - recursiveRemove(trashDir); + XPIInstall.recursiveRemove(trashDir); trashDirExists = false; } catch (e) { logger.warn("Failed to remove trash directory", e); } if (!trashDirExists) trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); return trashDir; @@ -5737,17 +5664,17 @@ class MutableDirectoryInstallLocation ex let moveOldAddon = aId => { let file = getFile(aId, this._directory); if (file.exists()) transaction.moveUnder(file, trashDir); file = getFile(`${aId}.xpi`, this._directory); if (file.exists()) { - flushJarCache(file); + XPIInstall.flushJarCache(file); transaction.moveUnder(file, trashDir); } }; // If any of these operations fails the finally block will clean up the // temporary directory try { moveOldAddon(id); @@ -5777,26 +5704,26 @@ class MutableDirectoryInstallLocation ex } } } if (action == "copy") { transaction.copy(source, this._directory); } else if (action == "move") { if (source.isFile()) - flushJarCache(source); + XPIInstall.flushJarCache(source); transaction.moveUnder(source, this._directory); } // Do nothing for the proxy file as we sideload an addon permanently } finally { // It isn't ideal if this cleanup fails but it isn't worth rolling back // the install because of it. try { - recursiveRemove(trashDir); + XPIInstall.recursiveRemove(trashDir); } catch (e) { logger.warn("Failed to remove trash directory when installing " + id, e); } } let newFile = this._directory.clone(); if (action == "proxy") { @@ -5850,28 +5777,28 @@ class MutableDirectoryInstallLocation ex delete this._IDToFileMap[aId]; return; } let trashDir = this.getTrashDir(); if (file.leafName != aId) { logger.debug("uninstallAddon: flushing jar cache " + file.path + " for addon " + aId); - flushJarCache(file); + XPIInstall.flushJarCache(file); } let transaction = new SafeInstallOperation(); try { transaction.moveUnder(file, trashDir); } finally { // It isn't ideal if this cleanup fails, but it is probably better than // rolling back the uninstall at this point try { - recursiveRemove(trashDir); + XPIInstall.recursiveRemove(trashDir); } catch (e) { logger.warn("Failed to remove trash directory when uninstalling " + aId, e); } } XPIStates.removeAddon(this.name, aId); delete this._IDToFileMap[aId]; @@ -6302,17 +6229,17 @@ class SystemAddonInstallLocation extends * * @return an nsIFile */ getTrashDir() { let trashDir = getFile(DIR_TRASH, this._directory); let trashDirExists = trashDir.exists(); try { if (trashDirExists) - recursiveRemove(trashDir); + XPIInstall.recursiveRemove(trashDir); trashDirExists = false; } catch (e) { logger.warn("Failed to remove trash directory", e); } if (!trashDirExists) trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); return trashDir; @@ -6330,25 +6257,25 @@ class SystemAddonInstallLocation extends installAddon({id, source}) { let trashDir = this.getTrashDir(); let transaction = new SafeInstallOperation(); // If any of these operations fails the finally block will clean up the // temporary directory try { if (source.isFile()) { - flushJarCache(source); + XPIInstall.flushJarCache(source); } transaction.moveUnder(source, this._directory); } finally { // It isn't ideal if this cleanup fails but it isn't worth rolling back // the install because of it. try { - recursiveRemove(trashDir); + XPIInstall.recursiveRemove(trashDir); } catch (e) { logger.warn("Failed to remove trash directory when installing " + id, e); } } let newFile = getFile(source.leafName, this._directory); try {
deleted file mode 100644 --- a/toolkit/mozapps/extensions/test/addons/webextension_2/install.rdf +++ /dev/null @@ -1,31 +0,0 @@ -<?xml version="1.0"?> - -<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" - xmlns:em="http://www.mozilla.org/2004/em-rdf#"> - - <Description about="urn:mozilla:install-manifest"> - <em:id>first-webextension2@tests.mozilla.org</em:id> - <em:version>2.0</em:version> - <em:bootstrap>true</em:bootstrap> - - <em:targetApplication> - <Description> - <em:id>xpcshell@tests.mozilla.org</em:id> - <em:minVersion>1</em:minVersion> - <em:maxVersion>1</em:maxVersion> - </Description> - </em:targetApplication> - - <!-- Front End MetaData --> - <em:name>XPI Add-on 1</em:name> - <em:description>XPI Add-on 1 - Description</em:description> - <em:creator>XPI Add-on 1 - Creator</em:creator> - <em:developer>XPI Add-on 1 - First Developer</em:developer> - <em:translator>XPI Add-on 1 - First Translator</em:translator> - <em:contributor>XPI Add-on 1 - First Contributor</em:contributor> - <em:homepageURL>http://localhost/xpi/1/homepage.html</em:homepageURL> - <em:optionsURL>http://localhost/xpi/1/options.html</em:optionsURL> - <em:aboutURL>http://localhost/xpi/1/about.html</em:aboutURL> - <em:iconURL>http://localhost/xpi/1/icon.png</em:iconURL> - </Description> -</RDF>
deleted file mode 100644 --- a/toolkit/mozapps/extensions/test/addons/webextension_2/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Web Extension Name", - "version": "1.0", - "manifest_version": 2, - "applications": { - "gecko": { - "id": "last-webextension2@tests.mozilla.org" - } - } -}
--- a/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js +++ b/toolkit/mozapps/extensions/test/xpcshell/test_AddonRepository.js @@ -183,17 +183,18 @@ add_task(async function setup() { // Install an add-on so can check that it isn't returned in the results await promiseInstallFile(xpis[0]); await promiseRestartManager(); // Create an active AddonInstall so can check that it isn't returned in the results let install = await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL2, undefined, "application/x-xpinstall"); - install.install(); + let promise = promiseCompleteInstall(install); + registerCleanupFunction(() => promise); // Create a non-active AddonInstall so can check that it is returned in the results await AddonManager.getInstallForURL(BASE_URL + INSTALL_URL3, undefined, "application/x-xpinstall"); }); // Tests homepageURL and getSearchURL() add_task(async function test_1() {
--- a/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js +++ b/toolkit/mozapps/extensions/test/xpcshell/test_webextension.js @@ -207,39 +207,16 @@ add_task(async function() { Assert.equal(addon, null); let file = getFileForAddon(profileDir, ID); Assert.ok(!file.exists()); await promiseRestartManager(); }); -// install.rdf should be read before manifest.json -add_task(async function() { - - await Promise.all([ - promiseInstallAllFiles([do_get_addon("webextension_2")], true) - ]); - - await promiseRestartManager(); - - let installrdf_id = "first-webextension2@tests.mozilla.org"; - let first_addon = await promiseAddonByID(installrdf_id); - Assert.notEqual(first_addon, null); - Assert.ok(!first_addon.appDisabled); - Assert.ok(first_addon.isActive); - Assert.ok(!first_addon.isSystem); - - let manifestjson_id = "last-webextension2@tests.mozilla.org"; - let last_addon = await promiseAddonByID(manifestjson_id); - Assert.equal(last_addon, null); - - await promiseRestartManager(); -}); - // Test that the "options_ui" manifest section is processed correctly. add_task(async function test_options_ui() { let OPTIONS_RE = /^moz-extension:\/\/[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}\/options\.html$/; const extensionId = "webextension@tests.mozilla.org"; await promiseInstallWebExtension({ manifest: { applications: {gecko: {id: extensionId}},