Bug 1192924: Split out add-on update.xml parsing code from GMP modules. r=spohl

The system add-on update checks will use the same update.xml format as GMP so
this splits out the code for parsing and downloading files into a standalone
module that both can reuse.

--HG--
extra : commitid : I89HsxRnP9T
extra : rebase_source : 1b38a03e202f73ba214604e083745e8c6b5984b5
This commit is contained in:
Dave Townsend 2015-09-08 15:00:28 -07:00
parent a22c2d1f9e
commit 77f3ee0b44
13 changed files with 652 additions and 410 deletions

View File

@ -8,10 +8,6 @@ this.EXPORTED_SYMBOLS = [];
const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} = const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu, manager: Cm} =
Components; Components;
// Chunk size for the incremental downloader
const DOWNLOAD_CHUNK_BYTES_SIZE = 300000;
// Incremental downloader interval
const DOWNLOAD_INTERVAL = 0;
// 1 day default // 1 day default
const DEFAULT_SECONDS_BETWEEN_CHECKS = 60 * 60 * 24; 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/osfile.jsm");
Cu.import("resource://gre/modules/Task.jsm"); Cu.import("resource://gre/modules/Task.jsm");
Cu.import("resource://gre/modules/GMPUtils.jsm"); Cu.import("resource://gre/modules/GMPUtils.jsm");
Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm");
this.EXPORTED_SYMBOLS = ["GMPInstallManager", "GMPExtractor", "GMPDownloader", this.EXPORTED_SYMBOLS = ["GMPInstallManager", "GMPExtractor", "GMPDownloader",
"GMPAddon"]; "GMPAddon"];
@ -45,16 +42,6 @@ XPCOMUtils.defineLazyGetter(this, "gCertUtils", function() {
XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils", XPCOMUtils.defineLazyModuleGetter(this, "UpdateUtils",
"resource://gre/modules/UpdateUtils.jsm"); "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) { function getScopedLogger(prefix) {
// `PARENT_LOGGER_ID.` being passed here effectively links this logger // `PARENT_LOGGER_ID.` being passed here effectively links this logger
// to the parentLogger. // to the parentLogger.
@ -108,38 +95,27 @@ GMPInstallManager.prototype = {
this._deferred = Promise.defer(); this._deferred = Promise.defer();
let url = this._getURL(); let url = this._getURL();
this._request = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]. let allowNonBuiltIn = true;
createInstance(Ci.nsISupports); let certs = null;
// This is here to let unit test code override XHR if (!Services.prefs.prefHasUserValue(GMPPrefs.KEY_URL_OVERRIDE)) {
if (this._request.wrappedJSObject) { allowNonBuiltIn = !GMPPrefs.get(GMPPrefs.KEY_CERT_REQUIREBUILTIN, true);
this._request = this._request.wrappedJSObject; 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"); ProductAddonChecker.getProductAddonList(url, allowNonBuiltIn, certs).then((addons) => {
// The Cache-Control header is only interpreted by proxies and the if (!addons) {
// final destination. It does not help if a resource is already this._deferred.resolve([]);
// cached locally. }
this._request.setRequestHeader("Cache-Control", "no-cache"); else {
// HTTP/1.0 servers might not implement Cache-Control and this._deferred.resolve([for (a of addons) new GMPAddon(a)]);
// might only implement Pragma: no-cache }
this._request.setRequestHeader("Pragma", "no-cache"); delete this._deferred;
}, (ex) => {
this._request.timeout = CHECK_FOR_ADDONS_TIMEOUT_DELAY_MS; this._deferred.reject(ex);
this._request.addEventListener("error", event => this.onFailXML("onErrorXML", event), false); delete this._deferred;
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);
return this._deferred.promise; return this._deferred.promise;
}, },
@ -341,132 +317,6 @@ GMPInstallManager.prototype = {
* This is useful for tests. * This is useful for tests.
*/ */
overrideLeaveDownloadedZip: false, 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 are returns from GMPInstallManager.checkForAddons
* GMPAddon objects can also be used in calls to GMPInstallManager.installAddon * 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"); let log = getScopedLogger("GMPAddon.constructor");
gmpAddon.QueryInterface(Ci.nsIDOMElement); for (let name of Object.keys(addon)) {
["id", "URL", "hashFunction", this[name] = addon[name];
"hashValue", "version", "size"].forEach(name => { }
if (gmpAddon.hasAttribute(name)) {
this[name] = gmpAddon.getAttribute(name);
}
});
this.size = Number(this.size) || undefined;
log.info ("Created new addon: " + this.toString()); 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 = { GMPAddon.prototype = {
/** /**
* Returns a string representation of the addon * Returns a string representation of the addon
@ -647,38 +464,7 @@ function GMPDownloader(gmpAddon)
{ {
this._gmpAddon = 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 = { GMPDownloader.prototype = {
/** /**
* Starts the download process for an addon. * Starts the download process for an addon.
@ -686,9 +472,10 @@ GMPDownloader.prototype = {
* See GMPInstallManager.installAddon for resolve/rejected info * See GMPInstallManager.installAddon for resolve/rejected info
*/ */
start: function() { start: function() {
let log = getScopedLogger("GMPDownloader.start"); let log = getScopedLogger("GMPDownloader");
this._deferred = Promise.defer(); let gmpAddon = this._gmpAddon;
if (!this._gmpAddon.isValid) {
if (!gmpAddon.isValid) {
log.info("gmpAddon is not valid, will not continue"); log.info("gmpAddon is not valid, will not continue");
return Promise.reject({ return Promise.reject({
target: this, target: this,
@ -697,55 +484,14 @@ GMPDownloader.prototype = {
}); });
} }
let uri = Services.io.newURI(this._gmpAddon.URL, null, null); return ProductAddonChecker.downloadAddon(gmpAddon).then((zipPath) => {
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);
let path = OS.Path.join(OS.Constants.Path.profileDir, let path = OS.Path.join(OS.Constants.Path.profileDir,
gmpAddon.id, gmpAddon.id,
gmpAddon.version); gmpAddon.version);
installToDirPath.initWithPath(path); log.info("install to directory path: " + path);
log.info("install to directory path: " + installToDirPath.path); let gmpInstaller = new GMPExtractor(zipPath, path);
let gmpInstaller = new GMPExtractor(zipPath, installToDirPath.path);
let installPromise = gmpInstaller.install(); let installPromise = gmpInstaller.install();
installPromise.then(extractedPaths => { return installPromise.then(extractedPaths => {
// Success, set the prefs // Success, set the prefs
let now = Math.round(Date.now() / 1000); let now = Math.round(Date.now() / 1000);
GMPPrefs.set(GMPPrefs.KEY_PLUGIN_LAST_UPDATE, now, gmpAddon.id); 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. // if you need to set other prefs etc. do it before this.
GMPPrefs.set(GMPPrefs.KEY_PLUGIN_VERSION, gmpAddon.version, GMPPrefs.set(GMPPrefs.KEY_PLUGIN_VERSION, gmpAddon.version,
gmpAddon.id); gmpAddon.id);
this._deferred.resolve(extractedPaths); return extractedPaths;
}, err => {
this._deferred.reject(err);
});
}, err => {
log.warn("verifyDownload check failed");
this._deferred.reject({
target: this,
status: 200,
type: "verifyerr"
}); });
}); });
}, },
/**
* 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;
}

View File

@ -13,6 +13,8 @@ Cu.import("resource://gre/modules/Promise.jsm");
Cu.import("resource://gre/modules/Preferences.jsm") Cu.import("resource://gre/modules/Preferences.jsm")
Cu.import("resource://gre/modules/UpdateUtils.jsm"); Cu.import("resource://gre/modules/UpdateUtils.jsm");
let { computeHash } = Cu.import("resource://gre/modules/addons/ProductAddonChecker.jsm");
do_get_profile(); do_get_profile();
function run_test() {Cu.import("resource://gre/modules/Preferences.jsm") 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 data = "e~=0.5772156649";
let zipFile = createNewZipFile(zipFileName, data); let zipFile = createNewZipFile(zipFileName, data);
let hashFunc = "sha256"; let hashFunc = "sha256";
let expectedDigest = yield GMPDownloader.computeHash(hashFunc, zipFile); let expectedDigest = yield computeHash(hashFunc, zipFile.path);
let fileSize = zipFile.fileSize; let fileSize = zipFile.fileSize;
if (wantInstallReject) { if (wantInstallReject) {
fileSize = 1; fileSize = 1;
@ -457,7 +459,6 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) {
let gmpAddon = gmpAddons[0]; let gmpAddon = gmpAddons[0];
do_check_false(gmpAddon.isInstalled); do_check_false(gmpAddon.isInstalled);
GMPInstallManager.overrideLeaveDownloadedZip = true;
try { try {
let extractedPaths = yield installManager.installAddon(gmpAddon); let extractedPaths = yield installManager.installAddon(gmpAddon);
if (wantInstallReject) { if (wantInstallReject) {
@ -475,14 +476,6 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) {
let readData = readStringFromFile(extractedFile); let readData = readStringFromFile(extractedFile);
do_check_eq(readData, data); 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 // Make sure the prefs are set correctly
do_check_true(!!GMPScope.GMPPrefs.get( do_check_true(!!GMPScope.GMPPrefs.get(
GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", gmpAddon.id)); GMPScope.GMPPrefs.KEY_PLUGIN_LAST_UPDATE, "", gmpAddon.id));
@ -499,16 +492,9 @@ function* test_checkForAddons_installAddon(id, includeSize, wantInstallReject) {
extractedFile.parent.remove(true); extractedFile.parent.remove(true);
zipFile.remove(false); zipFile.remove(false);
httpServer.stop(function() {}); httpServer.stop(function() {});
do_print("Removing downloaded GMP file: " + downloadedGMPFile.path);
downloadedGMPFile.remove(false);
installManager.uninit(); installManager.uninit();
} catch(ex) { } catch(ex) {
zipFile.remove(false); 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) { if (!wantInstallReject) {
do_throw("install update should not reject"); do_throw("install update should not reject");
} }
@ -799,45 +785,6 @@ function overrideXHR(status, response, options) {
return overrideXHR.myxhr; 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 * Creates a new zip file containing a file with the specified data
* @param zipName The name of the zip file * @param zipName The name of the zip file

View File

@ -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 <addons> 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;
}
})
}

View File

@ -12,6 +12,7 @@ EXTRA_JS_MODULES.addons += [
'Content.js', 'Content.js',
'GMPProvider.jsm', 'GMPProvider.jsm',
'LightweightThemeImageOptimizer.jsm', 'LightweightThemeImageOptimizer.jsm',
'ProductAddonChecker.jsm',
'SpellCheckDictionaryBootstrap.js', 'SpellCheckDictionaryBootstrap.js',
'WebExtensionBootstrap.js', 'WebExtensionBootstrap.js',
] ]

View File

@ -0,0 +1 @@
Not an xml file!

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<foobar></barfoo>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<test></test>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<updates>
<addons></addons>
</updates>

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<updates>
<addons>
<addon id="test1" URL="http://example.com/test1.xpi"/>
<addon id="test2" URL="http://example.com/test2.xpi" hashFunction="md5" hashValue="djhfgsjdhf"/>
<addon id="test3" URL="http://example.com/test3.xpi" version="1.0" size="45"/>
<addon id="test4"/>
<addon URL="http://example.com/test5.xpi"/>
</addons>
</updates>

View File

@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<updates></updates>

View File

@ -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 <updates> 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);
});

View File

@ -23,6 +23,7 @@ skip-if = appname != "firefox"
[test_provider_shutdown.js] [test_provider_shutdown.js]
[test_provider_unsafe_access_shutdown.js] [test_provider_unsafe_access_shutdown.js]
[test_provider_unsafe_access_startup.js] [test_provider_unsafe_access_startup.js]
[test_ProductAddonChecker.js]
[test_shutdown.js] [test_shutdown.js]
[test_system_reset.js] [test_system_reset.js]
[test_XPIcancel.js] [test_XPIcancel.js]