diff --git a/toolkit/modules/GMPInstallManager.jsm b/toolkit/modules/GMPInstallManager.jsm index 208d513201d2..8a603ea95c33 100644 --- a/toolkit/modules/GMPInstallManager.jsm +++ b/toolkit/modules/GMPInstallManager.jsm @@ -8,10 +8,6 @@ this.EXPORTED_SYMBOLS = []; const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = Components; -// Chunk size for the incremental downloader -const DOWNLOAD_CHUNK_BYTES_SIZE = 300000; -// Incremental downloader interval -const DOWNLOAD_INTERVAL = 0; // 1 day default const DEFAULT_SECONDS_BETWEEN_CHECKS = 60 * 60 * 24; @@ -31,6 +27,7 @@ Cu.import("resource://gre/modules/Log.jsm"); Cu.import("resource://gre/modules/osfile.jsm"); Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/GMPUtils.jsm"); +Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); this.EXPORTED_SYMBOLS = ["GMPInstallManager", "GMPExtractor", "GMPDownloader", "GMPAddon"]; @@ -45,16 +42,6 @@ XPCOMUtils.defineLazyGetter(this, "gCertUtils", function() { XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", "resource://gre/modules/UpdateUtils.jsm"); -/** - * Number of milliseconds after which we need to cancel `checkForAddons`. - * - * Bug 1087674 suggests that the XHR we use in `checkForAddons` may - * never terminate in presence of network nuisances (e.g. strange - * antivirus behavior). This timeout is a defensive measure to ensure - * that we fail cleanly in such case. - */ -const CHECK_FOR_ADDONS_TIMEOUT_DELAY_MS = 20000; - function getScopedLogger(prefix) { // `PARENT_LOGGER_ID.` being passed here effectively links this logger // to the parentLogger. @@ -108,38 +95,27 @@ GMPInstallManager.prototype = { this._deferred = Promise.defer(); let url = this._getURL(); - this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. - createInstance(Ci.nsISupports); - // This is here to let unit test code override XHR - if (this._request.wrappedJSObject) { - this._request = this._request.wrappedJSObject; + let allowNonBuiltIn = true; + let certs = null; + if (!Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE)) { + allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_REQUIREBUILTIN, true); + if (GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true)) { + certs = gCertUtils.readCertPrefs(GMPPrefs.KEY_CERTS_BRANCH); + } } - this._request.open("GET", url, true); - let allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true); - this._request.channel.notificationCallbacks = - new gCertUtils.BadCertHandler(allowNonBuiltIn); - // Prevent the request from reading from the cache. - this._request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; - // Prevent the request from writing to the cache. - this._request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; - this._request.overrideMimeType("text/xml"); - // The Cache-Control header is only interpreted by proxies and the - // final destination. It does not help if a resource is already - // cached locally. - this._request.setRequestHeader("Cache-Control", "no-cache"); - // HTTP/1.0 servers might not implement Cache-Control and - // might only implement Pragma: no-cache - this._request.setRequestHeader("Pragma", "no-cache"); - - this._request.timeout = CHECK_FOR_ADDONS_TIMEOUT_DELAY_MS; - this._request.addEventListener("error", event => this.onFailXML("onErrorXML", event), false); - this._request.addEventListener("abort", event => this.onFailXML("onAbortXML", event), false); - this._request.addEventListener("timeout", event => this.onFailXML("onTimeoutXML", event), false); - this._request.addEventListener("load", event => this.onLoadXML(event), false); - - log.info("sending request to: " + url); - this._request.send(null); + ProductAddonChecker.getProductAddonList(url, allowNonBuiltIn, certs).then((addons) => { + if (!addons) { + this._deferred.resolve([]); + } + else { + this._deferred.resolve([for (a of addons) new GMPAddon(a)]); + } + delete this._deferred; + }, (ex) => { + this._deferred.reject(ex); + delete this._deferred; + }); return this._deferred.promise; }, @@ -341,132 +317,6 @@ GMPInstallManager.prototype = { * This is useful for tests. */ overrideLeaveDownloadedZip: false, - - /** - * The XMLHttpRequest succeeded and the document was loaded. - * @param event The nsIDOMEvent for the load - */ - onLoadXML: function(event) { - let log = getScopedLogger("GMPInstallManager.onLoadXML"); - try { - log.info("request completed downloading document"); - let certs = null; - if (!Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE) && - GMPPrefs.get(GMPPrefs.KEY_CERT_CHECKATTRS, true)) { - certs = gCertUtils.readCertPrefs(GMPPrefs.KEY_CERTS_BRANCH); - } - - let allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_REQUIREBUILTIN, - true); - log.info("allowNonBuiltIn: " + allowNonBuiltIn); - - gCertUtils.checkCert(this._request.channel, allowNonBuiltIn, certs); - - this.parseResponseXML(); - } catch (ex) { - log.error("could not load xml: " + ex); - this._deferred.reject({ - target: event.target, - status: this._getChannelStatus(event.target), - message: "" + ex, - }); - delete this._deferred; - } - }, - - /** - * Returns the status code for the XMLHttpRequest - */ - _getChannelStatus: function(request) { - let log = getScopedLogger("GMPInstallManager._getChannelStatus"); - let status = null; - try { - status = request.status; - log.info("request.status is: " + request.status); - } - catch (e) { - } - - if (status == null) { - status = request.channel.QueryInterface(Ci.nsIRequest).status; - } - return status; - }, - - /** - * There was an error of some kind during the XMLHttpRequest. This - * error may have been caused by external factors (e.g. network - * issues) or internally (by a timeout). - * - * @param event The nsIDOMEvent for the error - */ - onFailXML: function(failure, event) { - let log = getScopedLogger("GMPInstallManager.onFailXML " + failure); - let request = event.target; - let status = this._getChannelStatus(request); - let message = "request.status: " + status + " (" + event.type + ")"; - log.warn(message); - this._deferred.reject({ - target: request, - status: status, - message: message - }); - delete this._deferred; - }, - - /** - * Returns an array of GMPAddon objects discovered by the update check. - * Or returns an empty array if there were any problems with parsing. - * If there's an error, it will be logged if logging is enabled. - */ - parseResponseXML: function() { - try { - let log = getScopedLogger("GMPInstallManager.parseResponseXML"); - let updatesElement = this._request.responseXML.documentElement; - if (!updatesElement) { - let message = "empty updates document"; - log.warn(message); - this._deferred.reject({ - target: this._request, - message: message - }); - delete this._deferred; - return; - } - - if (updatesElement.nodeName != "updates") { - let message = "got node name: " + updatesElement.nodeName + - ", expected: updates"; - log.warn(message); - this._deferred.reject({ - target: this._request, - message: message - }); - delete this._deferred; - return; - } - - const ELEMENT_NODE = Ci.nsIDOMNode.ELEMENT_NODE; - let gmpResults = []; - for (let i = 0; i < updatesElement.childNodes.length; ++i) { - let updatesChildElement = updatesElement.childNodes.item(i); - if (updatesChildElement.nodeType != ELEMENT_NODE) { - continue; - } - if (updatesChildElement.localName == "addons") { - gmpResults = GMPAddon.parseGMPAddonsNode(updatesChildElement); - } - } - this._deferred.resolve(gmpResults); - delete this._deferred; - } catch (e) { - this._deferred.reject({ - target: this._request, - message: e - }); - delete this._deferred; - } - }, }; /** @@ -474,49 +324,16 @@ GMPInstallManager.prototype = { * GMPAddon objects are returns from GMPInstallManager.checkForAddons * GMPAddon objects can also be used in calls to GMPInstallManager.installAddon * - * @param gmpAddon The AUS response XML's DOM element `addon` + * @param addon The ProductAddonChecker `addon` object */ -function GMPAddon(gmpAddon) { +function GMPAddon(addon) { let log = getScopedLogger("GMPAddon.constructor"); - gmpAddon.QueryInterface(Ci.nsIDOMElement); - ["id", "URL", "hashFunction", - "hashValue", "version", "size"].forEach(name => { - if (gmpAddon.hasAttribute(name)) { - this[name] = gmpAddon.getAttribute(name); - } - }); - this.size = Number(this.size) || undefined; + for (let name of Object.keys(addon)) { + this[name] = addon[name]; + } log.info ("Created new addon: " + this.toString()); } -/** - * Parses an XML GMP addons node from AUS into an array - * @param addonsElement An nsIDOMElement compatible node with XML from AUS - * @return An array of GMPAddon results - */ -GMPAddon.parseGMPAddonsNode = function(addonsElement) { - let log = getScopedLogger("GMPAddon.parseGMPAddonsNode"); - let gmpResults = []; - if (addonsElement.localName !== "addons") { - return; - } - addonsElement.QueryInterface(Ci.nsIDOMElement); - let addonCount = addonsElement.childNodes.length; - for (let i = 0; i < addonCount; ++i) { - let addonElement = addonsElement.childNodes.item(i); - if (addonElement.localName !== "addon") { - continue; - } - addonElement.QueryInterface(Ci.nsIDOMElement); - try { - gmpResults.push(new GMPAddon(addonElement)); - } catch (e) { - log.warn("invalid addon: " + e); - continue; - } - } - return gmpResults; -}; GMPAddon.prototype = { /** * Returns a string representation of the addon @@ -647,38 +464,7 @@ function GMPDownloader(gmpAddon) { this._gmpAddon = gmpAddon; } -/** - * Computes the file hash of fileToHash with the specified hash function - * @param hashFunctionName A hash function name such as sha512 - * @param fileToHash An nsIFile to hash - * @return a promise which resolve to a digest in binary hex format - */ -GMPDownloader.computeHash = function(hashFunctionName, fileToHash) { - let log = getScopedLogger("GMPDownloader.computeHash"); - let digest; - let fileStream = Cc["@mozilla.org/network/file-input-stream;1"]. - createInstance(Ci.nsIFileInputStream); - fileStream.init(fileToHash, FileUtils.MODE_RDONLY, - FileUtils.PERMS_FILE, 0); - try { - let hash = Cc["@mozilla.org/security/hash;1"]. - createInstance(Ci.nsICryptoHash); - let hashFunction = - Ci.nsICryptoHash[hashFunctionName.toUpperCase()]; - if (!hashFunction) { - log.error("could not get hash function"); - return Promise.reject(); - } - hash.init(hashFunction); - hash.updateFromStream(fileStream, -1); - digest = binaryToHex(hash.finish(false)); - } catch (e) { - log.warn("failed to compute hash: " + e); - digest = ""; - } - fileStream.close(); - return Promise.resolve(digest); -}, + GMPDownloader.prototype = { /** * Starts the download process for an addon. @@ -686,9 +472,10 @@ GMPDownloader.prototype = { * See GMPInstallManager.installAddon for resolve/rejected info */ start: function() { - let log = getScopedLogger("GMPDownloader.start"); - this._deferred = Promise.defer(); - if (!this._gmpAddon.isValid) { + let log = getScopedLogger("GMPDownloader"); + let gmpAddon = this._gmpAddon; + + if (!gmpAddon.isValid) { log.info("gmpAddon is not valid, will not continue"); return Promise.reject({ target: this, @@ -697,55 +484,14 @@ GMPDownloader.prototype = { }); } - let uri = Services.io.newURI(this._gmpAddon.URL, null, null); - this._request = Cc["@mozilla.org/network/incremental-download;1"]. - createInstance(Ci.nsIIncrementalDownload); - let gmpFile = FileUtils.getFile("TmpD", [this._gmpAddon.id + ".zip"]); - if (gmpFile.exists()) { - gmpFile.remove(false); - } - - log.info("downloading from " + uri.spec + " to " + gmpFile.path); - this._request.init(uri, gmpFile, DOWNLOAD_CHUNK_BYTES_SIZE, - DOWNLOAD_INTERVAL); - this._request.start(this, null); - return this._deferred.promise; - }, - // For nsIRequestObserver - onStartRequest: function(request, context) { - }, - // For nsIRequestObserver - // Called when the GMP addon zip file is downloaded - onStopRequest: function(request, context, status) { - let log = getScopedLogger("GMPDownloader.onStopRequest"); - log.info("onStopRequest called"); - if (!Components.isSuccessCode(status)) { - log.info("status failed: " + status); - this._deferred.reject({ - target: this, - status: status, - type: "downloaderr" - }); - return; - } - - let promise = this._verifyDownload(); - promise.then(() => { - log.info("GMP file is ready to unzip"); - let destination = this._request.destination; - - let zipPath = destination.path; - let gmpAddon = this._gmpAddon; - let installToDirPath = Cc["@mozilla.org/file/local;1"]. - createInstance(Ci.nsIFile); + return ProductAddonChecker.downloadAddon(gmpAddon).then((zipPath) => { let path = OS.Path.join(OS.Constants.Path.profileDir, gmpAddon.id, gmpAddon.version); - installToDirPath.initWithPath(path); - log.info("install to directory path: " + installToDirPath.path); - let gmpInstaller = new GMPExtractor(zipPath, installToDirPath.path); + log.info("install to directory path: " + path); + let gmpInstaller = new GMPExtractor(zipPath, path); let installPromise = gmpInstaller.install(); - installPromise.then(extractedPaths => { + return installPromise.then(extractedPaths => { // Success, set the prefs let now = Math.round(Date.now() / 1000); GMPPrefs.set(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, now, gmpAddon.id); @@ -761,73 +507,8 @@ GMPDownloader.prototype = { // if you need to set other prefs etc. do it before this. GMPPrefs.set(GMPPrefs.KEY_PLUGIN_VERSION, gmpAddon.version, gmpAddon.id); - this._deferred.resolve(extractedPaths); - }, err => { - this._deferred.reject(err); - }); - }, err => { - log.warn("verifyDownload check failed"); - this._deferred.reject({ - target: this, - status: 200, - type: "verifyerr" + return extractedPaths; }); }); }, - /** - * Verifies that the downloaded zip file's hash matches the GMPAddon hash. - * @return a promise which resolves if the download verifies - */ - _verifyDownload: function() { - let verifyDownloadDeferred = Promise.defer(); - let log = getScopedLogger("GMPDownloader._verifyDownload"); - log.info("_verifyDownload called"); - if (!this._request) { - return Promise.reject(); - } - - let destination = this._request.destination; - log.info("for path: " + destination.path); - - // Ensure that the file size matches the expected file size. - if (this._gmpAddon.size !== undefined && - destination.fileSize != this._gmpAddon.size) { - log.warn("Downloader:_verifyDownload downloaded size " + - destination.fileSize + " != expected size " + - this._gmpAddon.size + "."); - return Promise.reject(); - } - - let promise = GMPDownloader.computeHash(this._gmpAddon.hashFunction, destination); - promise.then(digest => { - let expectedDigest = this._gmpAddon.hashValue.toLowerCase(); - if (digest !== expectedDigest) { - log.warn("hashes do not match! Got: `" + - digest + "`, expected: `" + expectedDigest + "`"); - this._deferred.reject(); - return; - } - - log.info("hashes match!"); - verifyDownloadDeferred.resolve(); - }, err => { - verifyDownloadDeferred.reject(); - }); - return verifyDownloadDeferred.promise; - }, - QueryInterface: XPCOMUtils.generateQI([Ci.nsIRequestObserver]) }; - -/** - * Convert a string containing binary values to hex. - */ -function binaryToHex(input) { - let result = ""; - for (let i = 0; i < input.length; ++i) { - let hex = input.charCodeAt(i).toString(16); - if (hex.length == 1) - hex = "0" + hex; - result += hex; - } - return result; -} diff --git a/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js b/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js index 909ca5a820e8..2aad75ac200a 100644 --- a/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js +++ b/toolkit/modules/tests/xpcshell/test_GMPInstallManager.js @@ -13,6 +13,8 @@ Cu.import("resource://gre/modules/Promise.jsm"); Cu.import("resource://gre/modules/Preferences.jsm") Cu.import("resource://gre/modules/UpdateUtils.jsm"); +let { computeHash } = Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); + do_get_profile(); function run_test() {Cu.import("resource://gre/modules/Preferences.jsm") @@ -431,7 +433,7 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) { let data = "e~=0.5772156649"; let zipFile = createNewZipFile(zipFileName, data); let hashFunc = "sha256"; - let expectedDigest = yield GMPDownloader.computeHash(hashFunc, zipFile); + let expectedDigest = yield computeHash(hashFunc, zipFile.path); let fileSize = zipFile.fileSize; if (wantInstallReject) { fileSize = 1; @@ -457,7 +459,6 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) { let gmpAddon = gmpAddons[0]; do_check_false(gmpAddon.isInstalled); - GMPInstallManager.overrideLeaveDownloadedZip = true; try { let extractedPaths = yield installManager.installAddon(gmpAddon); if (wantInstallReject) { @@ -475,14 +476,6 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) { let readData = readStringFromFile(extractedFile); do_check_eq(readData, data); - // Check that the downloaded zip matches the offered zip exactly - let downloadedGMPFile = FileUtils.getFile("TmpD", - [gmpAddon.id + ".zip"]); - do_check_true(downloadedGMPFile.exists()); - let downloadedBytes = getBinaryFileData(downloadedGMPFile); - let sourceBytes = getBinaryFileData(zipFile); - do_check_true(compareBinaryData(downloadedBytes, sourceBytes)); - // Make sure the prefs are set correctly do_check_true(!!GMPScope.GMPPrefs.get( GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", gmpAddon.id)); @@ -499,16 +492,9 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) { extractedFile.parent.remove(true); zipFile.remove(false); httpServer.stop(function() {}); - do_print("Removing downloaded GMP file: " + downloadedGMPFile.path); - downloadedGMPFile.remove(false); installManager.uninit(); } catch(ex) { zipFile.remove(false); - let downloadedGMPFile = FileUtils.getFile("TmpD", - [gmpAddon.id + ".zip"]); - do_print("Removing downloaded GMP file from exception handler: " + - downloadedGMPFile.path); - downloadedGMPFile.remove(false); if (!wantInstallReject) { do_throw("install update should not reject"); } @@ -799,45 +785,6 @@ function overrideXHR(status, response, options) { return overrideXHR.myxhr; } -/** - * Compares binary data of 2 arrays and returns true if they are the same - * - * @param arr1 The first array to compare - * @param arr2 The second array to compare -*/ -function compareBinaryData(arr1, arr2) { - do_check_eq(arr1.length, arr2.length); - for (let i = 0; i < arr1.length; i++) { - if (arr1[i] != arr2[i]) { - do_print("Data differs at index " + i + - ", arr1: " + arr1[i] + ", arr2: " + arr2[i]); - return false; - } - } - return true; -} - -/** - * Reads a file's data and returns it - * - * @param file The file to read the data from - * @return array of bytes for the data in the file. -*/ -function getBinaryFileData(file) { - let fileStream = Cc["@mozilla.org/network/file-input-stream;1"]. - createInstance(Ci.nsIFileInputStream); - // Open as RD_ONLY with default permissions. - fileStream.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); - - // Check the returned size versus the expected size. - let stream = Cc["@mozilla.org/binaryinputstream;1"]. - createInstance(Ci.nsIBinaryInputStream); - stream.setInputStream(fileStream); - let bytes = stream.readByteArray(stream.available()); - fileStream.close(); - return bytes; -} - /** * Creates a new zip file containing a file with the specified data * @param zipName The name of the zip file diff --git a/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm b/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm new file mode 100644 index 000000000000..96f6f13ba153 --- /dev/null +++ b/toolkit/mozapps/extensions/internal/ProductAddonChecker.jsm @@ -0,0 +1,322 @@ +/* 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"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +this.EXPORTED_SYMBOLS = [ "ProductAddonChecker" ]; + +Cu.import("resource://gre/modules/Task.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/CertUtils.jsm"); +Cu.import("resource://gre/modules/FileUtils.jsm"); +Cu.import("resource://gre/modules/NetUtil.jsm"); +Cu.import("resource://gre/modules/osfile.jsm"); + +let logger = Log.repository.getLogger("addons.productaddons"); + +/** + * Number of milliseconds after which we need to cancel `downloadXML`. + * + * Bug 1087674 suggests that the XHR we use in `downloadXML` may + * never terminate in presence of network nuisances (e.g. strange + * antivirus behavior). This timeout is a defensive measure to ensure + * that we fail cleanly in such case. + */ +const TIMEOUT_DELAY_MS = 20000; +// Chunk size for the incremental downloader +const DOWNLOAD_CHUNK_BYTES_SIZE = 300000; +// Incremental downloader interval +const DOWNLOAD_INTERVAL = 0; +// How much of a file to read into memory at a time for hashing +const HASH_CHUNK_SIZE = 8192; + +/** + * Gets the status of an XMLHttpRequest either directly or from its underlying + * channel. + * + * @param request + * The XMLHttpRequest. + * @return an integer status value. + */ +function getRequestStatus(request) { + let status = null; + try { + status = request.status; + } + catch (e) { + } + + if (status != null) { + return status; + } + + return request.channel.QueryInterface(Ci.nsIRequest).status; +} + +/** + * Downloads an XML document from a URL optionally testing the SSL certificate + * for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to the DOM document downloaded or rejects + * with a JS exception in case of error. + */ +function downloadXML(url, allowNonBuiltIn = false, allowedCerts = null) { + return new Promise((resolve, reject) => { + let request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. + createInstance(Ci.nsISupports); + // This is here to let unit test code override XHR + if (request.wrappedJSObject) { + request = request.wrappedJSObject; + } + request.open("GET", url, true); + request.channel.notificationCallbacks = new BadCertHandler(allowNonBuiltIn); + // Prevent the request from reading from the cache. + request.channel.loadFlags |= Ci.nsIRequest.LOAD_BYPASS_CACHE; + // Prevent the request from writing to the cache. + request.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING; + request.timeout = TIMEOUT_DELAY_MS; + + request.overrideMimeType("text/xml"); + // The Cache-Control header is only interpreted by proxies and the + // final destination. It does not help if a resource is already + // cached locally. + request.setRequestHeader("Cache-Control", "no-cache"); + // HTTP/1.0 servers might not implement Cache-Control and + // might only implement Pragma: no-cache + request.setRequestHeader("Pragma", "no-cache"); + + let fail = (event) => { + let request = event.target; + let status = getRequestStatus(request); + let message = "Failed downloading XML, status: " + status + ", reason: " + event.type; + logger.warn(message); + let ex = new Error(message); + ex.status = status; + reject(ex); + }; + + let success = (event) => { + logger.info("Completed downloading document"); + let request = event.target; + + try { + checkCert(request.channel, allowNonBuiltIn, allowedCerts); + } catch (ex) { + logger.error("Request failed certificate checks: " + ex); + ex.status = getRequestStatus(request); + reject(ex); + return; + } + + resolve(request.responseXML); + }; + + request.addEventListener("error", fail, false); + request.addEventListener("abort", fail, false); + request.addEventListener("timeout", fail, false); + request.addEventListener("load", success, false); + + logger.info("sending request to: " + url); + request.send(null); + }); +} + +/** + * Parses a list of add-ons from a DOM document. + * + * @param document + * The DOM document to parse. + * @return null if there is no element otherwise an array of the addons + * listed. + */ +function parseXML(document) { + // Check that the root element is correct + if (document.documentElement.localName != "updates") { + throw new Error("got node name: " + document.documentElement.localName + + ", expected: updates"); + } + + // Check if there are any addons elements in the updates element + let addons = document.querySelector("updates:root > addons"); + if (!addons) { + return null; + } + + let results = []; + let addonList = document.querySelectorAll("updates:root > addons > addon"); + for (let addonElement of addonList) { + let addon = {}; + + for (let name of ["id", "URL", "hashFunction", "hashValue", "version", "size"]) { + if (addonElement.hasAttribute(name)) { + addon[name] = addonElement.getAttribute(name); + } + } + addon.size = Number(addon.size) || undefined; + + results.push(addon); + } + + return results; +} + +/** + * Downloads file from a URL using the incremental file downloader. + * + * @param url + * The url to download from. + * @return a promise that resolves to the path of a temporary file or rejects + * with a JS exception in case of error. + */ +function downloadFile(url) { + return new Promise((resolve, reject) => { + let observer = { + onStartRequest: function() {}, + + onStopRequest: function(request, context, status) { + if (!Components.isSuccessCode(status)) { + logger.warn("File download failed: 0x" + status.toString(16)); + tmpFile.remove(true); + reject(Components.Exception("File download failed", status)); + return; + } + + resolve(tmpFile.path); + } + }; + + let uri = NetUtil.newURI(url); + let request = Cc["@mozilla.org/network/incremental-download;1"]. + createInstance(Ci.nsIIncrementalDownload); + let tmpFile = FileUtils.getFile("TmpD", ["tmpaddon"]); + tmpFile.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); + + logger.info("Downloading from " + uri.spec + " to " + tmpFile.path); + request.init(uri, tmpFile, DOWNLOAD_CHUNK_BYTES_SIZE, DOWNLOAD_INTERVAL); + request.start(observer, null); + }); +} + +/** + * Convert a string containing binary values to hex. + */ +function binaryToHex(input) { + let result = ""; + for (let i = 0; i < input.length; ++i) { + let hex = input.charCodeAt(i).toString(16); + if (hex.length == 1) { + hex = "0" + hex; + } + result += hex; + } + return result; +} + +/** + * Calculates the hash of a file. + * + * @param hashFunction + * The type of hash function to use, must be supported by nsICryptoHash. + * @param path + * The path of the file to hash. + * @return a promise that resolves to hash of the file or rejects with a JS + * exception in case of error. + */ +let computeHash = Task.async(function*(hashFunction, path) { + let file = yield OS.File.open(path, { existing: true, read: true }); + try { + let hasher = Cc["@mozilla.org/security/hash;1"]. + createInstance(Ci.nsICryptoHash); + hasher.initWithString(hashFunction); + + let bytes; + do { + bytes = yield file.read(HASH_CHUNK_SIZE); + hasher.update(bytes, bytes.length); + } while (bytes.length == HASH_CHUNK_SIZE); + + return binaryToHex(hasher.finish(false)); + } + finally { + yield file.close(); + } +}); + +/** + * Verifies that a downloaded file matches what was expected. + * + * @param properties + * The properties to check, `size` and `hashFunction` with `hashValue` + * are supported. Any properties missing won't be checked. + * @param path + * The path of the file to check. + * @return a promise that resolves if the file matched or rejects with a JS + * exception in case of error. + */ +let verifyFile = Task.async(function*(properties, path) { + if (properties.size !== undefined) { + let stat = yield OS.File.stat(path); + if (stat.size != properties.size) { + throw new Error("Downloaded file was " + stat.size + " bytes but expected " + properties.size + " bytes."); + } + } + + if (properties.hashFunction !== undefined) { + let expectedDigest = properties.hashValue.toLowerCase(); + let digest = yield computeHash(properties.hashFunction, path); + if (digest != expectedDigest) { + throw new Error("Hash was `" + digest + "` but expected `" + expectedDigest + "`."); + } + } +}); + +const ProductAddonChecker = { + /** + * Downloads a list of add-ons from a URL optionally testing the SSL + * certificate for certain attributes. + * + * @param url + * The url to download from. + * @param allowNonBuiltIn + * Whether to trust SSL certificates without a built-in CA issuer. + * @param allowedCerts + * The list of certificate attributes to match the SSL certificate + * against or null to skip checks. + * @return a promise that resolves to the list of add-ons or rejects with a JS + * exception in case of error. + */ + getProductAddonList: function(url, allowNonBuiltIn = false, allowedCerts = null) { + return downloadXML(url, allowNonBuiltIn, allowedCerts).then(parseXML); + }, + + /** + * Downloads an add-on to a local file and checks that it matches the expected + * file. The caller is responsible for deleting the temporary file returned. + * + * @param addon + * The addon to download. + * @return a promise that resolves to the temporary file downloaded or rejects + * with a JS exception in case of error. + */ + downloadAddon: Task.async(function*(addon) { + let path = yield downloadFile(addon.URL); + try { + yield verifyFile(addon, path); + return path; + } + catch (e) { + yield OS.File.remove(path); + throw e; + } + }) +} diff --git a/toolkit/mozapps/extensions/internal/moz.build b/toolkit/mozapps/extensions/internal/moz.build index 7d1eaf296fbf..a12a18360130 100644 --- a/toolkit/mozapps/extensions/internal/moz.build +++ b/toolkit/mozapps/extensions/internal/moz.build @@ -12,6 +12,7 @@ EXTRA_JS_MODULES.addons += [ 'Content.js', 'GMPProvider.jsm', 'LightweightThemeImageOptimizer.jsm', + 'ProductAddonChecker.jsm', 'SpellCheckDictionaryBootstrap.js', 'WebExtensionBootstrap.js', ] diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt new file mode 100644 index 000000000000..f17f98b15be7 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.txt @@ -0,0 +1 @@ +Not an xml file! \ No newline at end of file diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml new file mode 100644 index 000000000000..0e3d415c4420 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml new file mode 100644 index 000000000000..55ad1c7d55b0 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/bad2.xml @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml new file mode 100644 index 000000000000..42cb20bd0192 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/empty.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml new file mode 100644 index 000000000000..e1da86fa5420 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/good.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml new file mode 100644 index 000000000000..8c9501478ee1 --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/missing.xml @@ -0,0 +1,3 @@ + + + diff --git a/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi new file mode 100644 index 000000000000..51b00475a964 Binary files /dev/null and b/toolkit/mozapps/extensions/test/xpcshell/data/productaddons/unsigned.xpi differ diff --git a/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js new file mode 100644 index 000000000000..88daa68f9aae --- /dev/null +++ b/toolkit/mozapps/extensions/test/xpcshell/test_ProductAddonChecker.js @@ -0,0 +1,264 @@ +"use strict"; + +Components.utils.import("resource://gre/modules/addons/ProductAddonChecker.jsm"); +Components.utils.import("resource://testing-common/httpd.js"); +Components.utils.import("resource://gre/modules/osfile.jsm"); + +const LocalFile = new Components.Constructor("@mozilla.org/file/local;1", AM_Ci.nsIFile, "initWithPath"); + +let testserver = new HttpServer(); +testserver.registerDirectory("/data/", do_get_file("data/productaddons")); +testserver.start(); +let root = testserver.identity.primaryScheme + "://" + + testserver.identity.primaryHost + ":" + + testserver.identity.primaryPort + "/data/" + +/** + * Compares binary data of 2 arrays and returns true if they are the same + * + * @param arr1 The first array to compare + * @param arr2 The second array to compare +*/ +function compareBinaryData(arr1, arr2) { + do_check_eq(arr1.length, arr2.length); + for (let i = 0; i < arr1.length; i++) { + if (arr1[i] != arr2[i]) { + do_print("Data differs at index " + i + + ", arr1: " + arr1[i] + ", arr2: " + arr2[i]); + return false; + } + } + return true; +} + +/** + * Reads a file's data and returns it + * + * @param file The file to read the data from + * @return array of bytes for the data in the file. +*/ +function getBinaryFileData(file) { + let fileStream = AM_Cc["@mozilla.org/network/file-input-stream;1"]. + createInstance(AM_Ci.nsIFileInputStream); + // Open as RD_ONLY with default permissions. + fileStream.init(file, FileUtils.MODE_RDONLY, FileUtils.PERMS_FILE, 0); + + let stream = AM_Cc["@mozilla.org/binaryinputstream;1"]. + createInstance(AM_Ci.nsIBinaryInputStream); + stream.setInputStream(fileStream); + let bytes = stream.readByteArray(stream.available()); + fileStream.close(); + return bytes; +} + +/** + * Compares binary data of 2 files and returns true if they are the same + * + * @param file1 The first file to compare + * @param file2 The second file to compare +*/ +function compareFiles(file1, file2) { + return compareBinaryData(getBinaryFileData(file1), getBinaryFileData(file2)); +} + +add_task(function* test_404() { + try { + let addons = yield ProductAddonChecker.getProductAddonList(root + "404.xml"); + do_throw("Should not have returned anything"); + } + catch (e) { + do_check_true(true, "Expected to throw for a missing update file"); + } +}); + +add_task(function* test_not_xml() { + try { + let addons = yield ProductAddonChecker.getProductAddonList(root + "bad.txt"); + do_throw("Should not have returned anything"); + } + catch (e) { + do_check_true(true, "Expected to throw for a non XML result"); + } +}); + +add_task(function* test_invalid_xml() { + try { + let addons = yield ProductAddonChecker.getProductAddonList(root + "bad.xml"); + do_throw("Should not have returned anything"); + } + catch (e) { + do_check_true(true, "Expected to throw for invalid XML"); + } +}); + +add_task(function* test_wrong_xml() { + try { + let addons = yield ProductAddonChecker.getProductAddonList(root + "bad2.xml"); + do_throw("Should not have returned anything"); + } + catch (e) { + do_check_true(true, "Expected to throw for a missing tag"); + } +}); + +add_task(function* test_missing() { + let addons = yield ProductAddonChecker.getProductAddonList(root + "missing.xml"); + do_check_eq(addons, null); +}); + +add_task(function* test_empty() { + let addons = yield ProductAddonChecker.getProductAddonList(root + "empty.xml"); + do_check_true(Array.isArray(addons)); + do_check_eq(addons.length, 0); +}); + +add_task(function* test_good_xml() { + let addons = yield ProductAddonChecker.getProductAddonList(root + "good.xml"); + do_check_true(Array.isArray(addons)); + + // There are three valid entries in the XML + do_check_eq(addons.length, 5); + + let addon = addons[0]; + do_check_eq(addon.id, "test1"); + do_check_eq(addon.URL, "http://example.com/test1.xpi"); + do_check_eq(addon.hashFunction, undefined); + do_check_eq(addon.hashValue, undefined); + do_check_eq(addon.version, undefined); + do_check_eq(addon.size, undefined); + + addon = addons[1]; + do_check_eq(addon.id, "test2"); + do_check_eq(addon.URL, "http://example.com/test2.xpi"); + do_check_eq(addon.hashFunction, "md5"); + do_check_eq(addon.hashValue, "djhfgsjdhf"); + do_check_eq(addon.version, undefined); + do_check_eq(addon.size, undefined); + + addon = addons[2]; + do_check_eq(addon.id, "test3"); + do_check_eq(addon.URL, "http://example.com/test3.xpi"); + do_check_eq(addon.hashFunction, undefined); + do_check_eq(addon.hashValue, undefined); + do_check_eq(addon.version, "1.0"); + do_check_eq(addon.size, 45); + + addon = addons[3]; + do_check_eq(addon.id, "test4"); + do_check_eq(addon.URL, undefined); + do_check_eq(addon.hashFunction, undefined); + do_check_eq(addon.hashValue, undefined); + do_check_eq(addon.version, undefined); + do_check_eq(addon.size, undefined); + + addon = addons[4]; + do_check_eq(addon.id, undefined); + do_check_eq(addon.URL, "http://example.com/test5.xpi"); + do_check_eq(addon.hashFunction, undefined); + do_check_eq(addon.hashValue, undefined); + do_check_eq(addon.version, undefined); + do_check_eq(addon.size, undefined); +}); + +add_task(function* test_download_nourl() { + try { + let path = yield ProductAddonChecker.downloadAddon({}); + + yield OS.File.remove(path); + do_throw("Should not have downloaded a file with a missing url"); + } + catch (e) { + do_check_true(true, "Should have thrown when downloading a file with a missing url."); + } +}); + +add_task(function* test_download_missing() { + try { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "nofile.xpi", + }); + + yield OS.File.remove(path); + do_throw("Should not have downloaded a missing file"); + } + catch (e) { + do_check_true(true, "Should have thrown when downloading a missing file."); + } +}); + +add_task(function* test_download_noverify() { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "unsigned.xpi", + }); + + let stat = yield OS.File.stat(path); + do_check_false(stat.isDir); + do_check_eq(stat.size, 452) + + do_check_true(compareFiles(do_get_file("data/productaddons/unsigned.xpi"), new LocalFile(path))); + + yield OS.File.remove(path); +}); + +add_task(function* test_download_badsize() { + try { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "unsigned.xpi", + size: 400, + }); + + yield OS.File.remove(path); + do_throw("Should not have downloaded a file with a bad size"); + } + catch (e) { + do_check_true(true, "Should have thrown when downloading a file with a bad size."); + } +}); + +add_task(function* test_download_badhashfn() { + try { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "unsigned.xpi", + hashFunction: "sha2567", + hashValue: "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3", + }); + + yield OS.File.remove(path); + do_throw("Should not have downloaded a file with a bad hash function"); + } + catch (e) { + do_check_true(true, "Should have thrown when downloading a file with a bad hash function."); + } +}); + +add_task(function* test_download_badhash() { + try { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "unsigned.xpi", + hashFunction: "sha256", + hashValue: "8b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3", + }); + + yield OS.File.remove(path); + do_throw("Should not have downloaded a file with a bad hash"); + } + catch (e) { + do_check_true(true, "Should have thrown when downloading a file with a bad hash."); + } +}); + +add_task(function* test_download_works() { + let path = yield ProductAddonChecker.downloadAddon({ + URL: root + "unsigned.xpi", + size: 452, + hashFunction: "sha256", + hashValue: "9b9abf7ddfc1a6d7ffc7e0247481dcc202363e4445ad3494fb22036f1698c7f3", + }); + + let stat = yield OS.File.stat(path); + do_check_false(stat.isDir); + + do_check_true(compareFiles(do_get_file("data/productaddons/unsigned.xpi"), new LocalFile(path))); + + yield OS.File.remove(path); +}); diff --git a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini index b3aa502de302..0cace3c207c8 100644 --- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini +++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell.ini @@ -23,6 +23,7 @@ skip-if = appname != "firefox" [test_provider_shutdown.js] [test_provider_unsafe_access_shutdown.js] [test_provider_unsafe_access_startup.js] +[test_ProductAddonChecker.js] [test_shutdown.js] [test_system_reset.js] [test_XPIcancel.js]