Bug 1068664 - Introduce API for downloads in a blocked state with file on disk. r=paolo

This new API allows consumers to call unblock() on the download if
they'd like to allow it. If they'd like to delete the blocked download's
partial data they may call confirmBlock(). Downloads also have a new
hasBlockedData property which will be true if the Download has blocked
data on disk.

DownloadIntegration now also allows for checking if handling downloads
in a blocked state is supported. As platforms support the new operations
on a blocked download they should update their implementation of this
check to keep the blocked download data on disk.

This also moves the reputation checking into the saver so it can happen
before moving the download to its final path - so we don't have possibly
dangerous files sitting around with their intended filename / extension.

If a Download did not use a part file and it fails the reputation check
we will always remove it to prevent dangerous files from existing with
their intended filename.

--HG--
extra : rebase_source : 519e3b4b98c8ccfc00039f35bd32e3143e39a5f8
This commit is contained in:
Steven MacLeod 2014-12-08 16:31:03 -05:00
Родитель ac9a1eb2a6
Коммит cc8a4df022
3 изменённых файлов: 463 добавлений и 66 удалений

Просмотреть файл

@ -254,6 +254,11 @@ this.Download.prototype = {
* Indicates whether, at this time, there is any partially downloaded data
* that can be used when restarting a failed or canceled download.
*
* Even if the download has partial data on disk, hasPartialData will be false
* if that data cannot be used to restart the download. In order to determine
* if a part file is being used which contains partial data the
* Download.target.partFilePath should be checked.
*
* This property is relevant while the download is in progress, and also if it
* failed or has been canceled. If the download has been completed
* successfully, this property is always false.
@ -263,6 +268,13 @@ this.Download.prototype = {
*/
hasPartialData: false,
/**
* Indicates whether, at this time, there is any data that has been blocked.
* Since reputation blocking takes place after the download has fully
* completed a value of true also indicates 100% of the data is present.
*/
hasBlockedData: false,
/**
* This can be set to a function that is called after other properties change.
*/
@ -353,11 +365,18 @@ this.Download.prototype = {
message: "Cannot start after finalization."}));
}
if (this.error && this.error.becauseBlockedByReputationCheck) {
return Promise.reject(new DownloadError({
message: "Cannot start after being blocked " +
"by a reputation check."}));
}
// Initialize all the status properties for a new or restarted download.
this.stopped = false;
this.canceled = false;
this.error = null;
this.hasProgress = false;
this.hasBlockedData = false;
this.progress = 0;
this.totalBytes = 0;
this.currentBytes = 0;
@ -448,20 +467,16 @@ this.Download.prototype = {
yield this.saver.execute(DS_setProgressBytes.bind(this),
DS_setProperties.bind(this));
// Check for application reputation, which requires the entire file to
// be downloaded. After that, check for the last time if the download
// has been canceled. Both cases require the target file to be deleted,
// thus we process both in the same block of code.
if ((yield DownloadIntegration.shouldBlockForReputationCheck(this)) ||
this._promiseCanceled) {
// Check for the last time if the download has been canceled.
if (this._promiseCanceled) {
try {
yield OS.File.remove(this.target.path);
} catch (ex) {
Cu.reportError(ex);
}
// If this is actually a cancellation, this exception will be changed
// in the catch block below.
throw new DownloadError({ becauseBlockedByReputationCheck: true });
// Cancellation exceptions will be changed in the catch block below.
throw new DownloadError();
}
// Update the status properties for a successful download.
@ -513,6 +528,25 @@ this.Download.prototype = {
this.speed = 0;
this._notifyChange();
if (this.succeeded) {
yield this._succeed();
}
}
}
}.bind(this)));
// Notify the new download state before returning.
this._notifyChange();
return currentAttempt;
},
/**
* Perform the actions necessary when a Download succeeds.
*
* @return {Promise}
* @resolves When the steps to take after success have completed.
* @rejects JavaScript exception if any of the operations failed.
*/
_succeed: Task.async(function* () {
yield DownloadIntegration.downloadDone(this);
this._deferSucceeded.resolve();
@ -531,14 +565,105 @@ this.Download.prototype = {
new FileUtils.File(this.target.path));
}
}
}
}
}
}.bind(this)));
}),
// Notify the new download state before returning.
/**
* When a request to unblock the download is received, contains a promise
* that will be resolved when the unblock request is completed. This property
* will then continue to hold the promise indefinitely.
*/
_promiseUnblock: null,
/**
* When a request to confirm the block of the download is received, contains
* a promise that will be resolved when cleaning up the download has
* completed. This property will then continue to hold the promise
* indefinitely.
*/
_promiseConfirmBlock: null,
/**
* Unblocks a download which had been blocked by reputation.
*
* The file will be moved out of quarantine and the download will be
* marked as succeeded.
*
* @return {Promise}
* @resolves When the Download has been unblocked and succeeded.
* @rejects JavaScript exception if any of the operations failed.
*/
unblock: function() {
if (this._promiseUnblock) {
return this._promiseUnblock;
}
if (this._promiseConfirmBlock) {
return Promise.reject(new Error(
"Download block has been confirmed, cannot unblock."));
}
if (!this.hasBlockedData) {
return Promise.reject(new Error(
"unblock may only be called on Downloads with blocked data."));
}
this._promiseUnblock = Task.spawn(function* () {
try {
yield OS.File.move(this.target.partFilePath, this.target.path);
} catch (ex) {
yield this.refresh();
this._promiseUnblock = null;
throw ex;
}
this.succeeded = true;
this.hasBlockedData = false;
this._notifyChange();
return currentAttempt;
yield this._succeed();
}.bind(this));
return this._promiseUnblock;
},
/**
* Confirms that a blocked download should be cleaned up.
*
* If a download was blocked but retained on disk this method can be used
* to remove the file.
*
* @return {Promise}
* @resolves When the Download's data has been removed.
* @rejects JavaScript exception if any of the operations failed.
*/
confirmBlock: function() {
if (this._promiseConfirmBlock) {
return this._promiseConfirmBlock;
}
if (this._promiseUnblock) {
return Promise.reject(new Error(
"Download is being unblocked, cannot confirmBlock."));
}
if (!this.hasBlockedData) {
return Promise.reject(new Error(
"confirmBlock may only be called on Downloads with blocked data."));
}
this._promiseConfirmBlock = Task.spawn(function* () {
try {
yield OS.File.remove(this.target.partFilePath);
} catch (ex) {
yield this.refresh();
this._promiseConfirmBlock = null;
throw ex;
}
this.hasBlockedData = false;
this._notifyChange();
}.bind(this));
return this._promiseConfirmBlock;
},
/*
@ -772,7 +897,10 @@ this.Download.prototype = {
}
// Update the current progress from disk if we retained partial data.
if (this.hasPartialData && this.target.partFilePath) {
if ((this.hasPartialData || this.hasBlockedData) &&
this.target.partFilePath) {
try {
let stat = yield OS.File.stat(this.target.partFilePath);
// Ignore the result if the state has changed meanwhile.
@ -787,6 +915,16 @@ this.Download.prototype = {
this.progress = Math.floor(this.currentBytes /
this.totalBytes * 100);
}
} catch (ex if ex instanceof OS.File.Error && ex.becauseNoSuchFile) {
// Ignore the result if the state has changed meanwhile.
if (!this.stopped || this._finalized) {
return;
}
this.hasBlockedData = false;
this.hasPartialData = false;
}
this._notifyChange();
}
}.bind(this)).then(null, Cu.reportError);
@ -984,6 +1122,7 @@ const kPlainSerializableDownloadProperties = [
"canceled",
"totalBytes",
"hasPartialData",
"hasBlockedData",
"tryToKeepPartialData",
"launcherPath",
"launchWhenSucceeded",
@ -1818,11 +1957,6 @@ this.DownloadCopySaver.prototype = {
// background file saver that the operation can finish. If the
// data transfer failed, the saver has been already stopped.
if (Components.isSuccessCode(aStatusCode)) {
if (partFilePath) {
// Move to the final target if we were using a part file.
backgroundFileSaver.setTarget(
new FileUtils.File(targetPath), false);
}
backgroundFileSaver.finish(Cr.NS_OK);
}
}
@ -1855,6 +1989,8 @@ this.DownloadCopySaver.prototype = {
// We will wait on this promise in case no error occurred while setting
// up the chain of objects for the download.
yield deferSaveComplete.promise;
yield this._checkReputationAndMove();
} catch (ex) {
// Ensure we always remove the placeholder for the final target file on
// failure, independently of which code path failed. In some cases, the
@ -1876,6 +2012,47 @@ this.DownloadCopySaver.prototype = {
}.bind(this));
},
/**
* Perform the reputation check and cleanup the downloaded data if required.
* If the download passes the reputation check and is using a part file we
* will move it to the target path since reputation checking is the final
* step in the saver.
*
* @return {Promise}
* @resolves When the reputation check and cleanup is complete.
* @rejects DownloadError if the download should be blocked.
*/
_checkReputationAndMove: Task.async(function* () {
let download = this.download;
let targetPath = this.download.target.path;
let partFilePath = this.download.target.partFilePath;
if (yield DownloadIntegration.shouldBlockForReputationCheck(download)) {
download.progress = 100;
download.hasPartialData = false;
// We will remove the potentially dangerous file if instructed by
// DownloadIntegration. We will always remove the file when the
// download did not use a partial file path, meaning it
// currently has its final filename.
if (!DownloadIntegration.shouldKeepBlockedData() || !partFilePath) {
try {
yield OS.File.remove(partFilePath || targetPath);
} catch (ex) {
Cu.reportError(ex);
}
} else {
download.hasBlockedData = true;
}
throw new DownloadError({ becauseBlockedByReputationCheck: true });
}
if (partFilePath) {
yield OS.File.move(partFilePath, targetPath);
}
}),
/**
* Implements "DownloadSaver.cancel".
*/
@ -2171,13 +2348,12 @@ this.DownloadLegacySaver.prototype = {
// to its final target path when the download succeeds. In this case,
// an empty ".part" file is created even if no data was received from
// the source.
if (this.download.target.partFilePath) {
yield OS.File.move(this.download.target.partFilePath,
this.download.target.path);
} else {
// The download implementation may not have created the target file if
// no data was received from the source. In this case, ensure that an
// empty file is created as expected.
//
// When no ".part" file path is provided the download implementation may
// not have created the target file (if no data was received from the
// source). In this case, ensure that an empty file is created as
// expected.
if (!this.download.target.partFilePath) {
try {
// This atomic operation is more efficient than an existence check.
let file = yield OS.File.open(this.download.target.path,
@ -2185,6 +2361,9 @@ this.DownloadLegacySaver.prototype = {
yield file.close();
} catch (ex if ex instanceof OS.File.Error && ex.becauseExists) { }
}
yield this._checkReputationAndMove();
} catch (ex) {
// Ensure we always remove the final target file on failure,
// independently of which code path failed. In some cases, the
@ -2217,6 +2396,10 @@ this.DownloadLegacySaver.prototype = {
}.bind(this));
},
_checkReputationAndMove: function () {
return DownloadCopySaver.prototype._checkReputationAndMove.call(this);
},
/**
* Implements "DownloadSaver.cancel".
*/

Просмотреть файл

@ -152,6 +152,7 @@ this.DownloadIntegration = {
dontCheckApplicationReputation: true,
#endif
shouldBlockInTestForApplicationReputation: false,
shouldKeepBlockedDataInTest: false,
dontOpenFileAndFolder: false,
downloadDoneCalled: false,
_deferTestOpenFile: null,
@ -174,6 +175,30 @@ this.DownloadIntegration = {
return (this._testMode = mode);
},
/**
* Returns whether data for blocked downloads should be kept on disk.
* Implementations which support unblocking downloads may return true to
* keep the blocked download on disk until its fate is decided.
*
* If a download is blocked and the partial data is kept the Download's
* 'hasBlockedData' property will be true. In this state Download.unblock()
* or Download.confirmBlock() may be used to either unblock the download or
* remove the downloaded data respectively.
*
* Even if shouldKeepBlockedData returns true, if the download did not use a
* partFile the blocked data will be removed - preventing the complete
* download from existing on disk with its final filename.
*
* @return boolean True if data should be kept.
*/
shouldKeepBlockedData: function() {
if (this.shouldBlockInTestForApplicationReputation) {
return this.shouldKeepBlockedDataInTest;
}
return false;
},
/**
* Performs initialization of the list of persistent downloads, before its
* first use by the host application. This function may be called only once

Просмотреть файл

@ -1547,42 +1547,231 @@ add_task(function test_getSha256Hash()
}
});
/**
* Checks that application reputation blocks the download and the target file
* does not exist.
* Create a download which will be reputation blocked.
*
* @param options
* {
* keepPartialData: bool,
* keepBlockedData: bool,
* }
* @return {Promise}
* @resolves The reputation blocked download.
* @rejects JavaScript exception.
*/
add_task(function test_blocked_applicationReputation()
{
let promiseBlockedDownload = Task.async(function* (options) {
function cleanup() {
DownloadIntegration.shouldBlockInTestForApplicationReputation = false;
DownloadIntegration.shouldKeepBlockedDataInTest = false;
}
do_register_cleanup(cleanup);
let {keepPartialData, keepBlockedData} = options;
DownloadIntegration.shouldBlockInTestForApplicationReputation = true;
DownloadIntegration.shouldKeepBlockedDataInTest = keepBlockedData;
let download;
try {
if (!gUseLegacySaver) {
// When testing DownloadCopySaver, we want to check that the promise
// returned by the "start" method is rejected.
if (keepPartialData) {
download = yield promiseStartDownload_tryToKeepPartialData();
continueResponses();
} else if (gUseLegacySaver) {
download = yield promiseStartLegacyDownload();
} else {
download = yield promiseNewDownload();
yield download.start();
} else {
// When testing DownloadLegacySaver, we cannot be sure whether we are
// testing the promise returned by the "start" method or we are testing
// the "error" property checked by promiseDownloadStopped. This happens
// because we don't have control over when the download is started.
download = yield promiseStartLegacyDownload();
yield promiseDownloadStopped(download);
do_throw("The download should have blocked.");
}
yield promiseDownloadStopped(download);
do_throw("The download should have blocked.");
} catch (ex if ex instanceof Downloads.Error && ex.becauseBlocked) {
do_check_true(ex.becauseBlockedByReputationCheck);
do_check_true(download.error.becauseBlockedByReputationCheck);
}
do_check_true(download.stopped);
do_check_false(download.succeeded);
do_check_false(yield OS.File.exists(download.target.path));
cleanup();
return download;
});
/**
* Checks that application reputation blocks the download and the target file
* does not exist.
*/
add_task(function test_blocked_applicationReputation()
{
let download = yield promiseBlockedDownload({
keepPartialData: false,
keepBlockedData: false,
});
// Now that the download is blocked, the target file should not exist.
do_check_false(yield OS.File.exists(download.target.path));
cleanup();
// There should also be no blocked data in this case
do_check_false(download.hasBlockedData);
});
/**
* Checks that application reputation blocks the download but maintains the
* blocked data, which will be deleted when the block is confirmed.
*/
add_task(function test_blocked_applicationReputation_confirmBlock()
{
let download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
do_check_true(download.hasBlockedData);
do_check_true(yield OS.File.exists(download.target.partFilePath));
yield download.confirmBlock();
// After confirming the block the download should be in a failed state and
// have no downloaded data left on disk.
do_check_true(download.stopped);
do_check_false(download.succeeded);
do_check_false(download.hasBlockedData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
do_check_false(yield OS.File.exists(download.target.path));
});
/**
* Checks that application reputation blocks the download but maintains the
* blocked data, which will be used to complete the download when unblocking.
*/
add_task(function test_blocked_applicationReputation_unblock()
{
let download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
do_check_true(download.hasBlockedData);
do_check_true(yield OS.File.exists(download.target.partFilePath));
yield download.unblock();
// After unblocking the download should have succeeded and be
// present at the final path.
do_check_true(download.stopped);
do_check_true(download.succeeded);
do_check_false(download.hasBlockedData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
do_check_true(yield OS.File.exists(download.target.path));
// The only indication the download was previously blocked is the
// existence of the error, so we make sure it's still set.
do_check_true(download.error instanceof Downloads.Error);
do_check_true(download.error.becauseBlocked);
do_check_true(download.error.becauseBlockedByReputationCheck);
});
/**
* Check that calling cancel on a blocked download will not cause errors
*/
add_task(function test_blocked_applicationReputation_cancel()
{
let download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
// This call should succeed on a blocked download.
yield download.cancel();
// Calling cancel should not have changed the current state, the download
// should still be blocked.
do_check_true(download.error.becauseBlockedByReputationCheck);
do_check_true(download.stopped);
do_check_false(download.succeeded);
do_check_true(download.hasBlockedData);
});
/**
* Checks that unblock and confirmBlock cannot race on a blocked download
*/
add_task(function test_blocked_applicationReputation_decisionRace()
{
let download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
let unblockPromise = download.unblock();
let confirmBlockPromise = download.confirmBlock();
yield confirmBlockPromise.then(() => {
do_throw("confirmBlock should have failed.");
}, () => {});
yield unblockPromise;
// After unblocking the download should have succeeded and be
// present at the final path.
do_check_true(download.stopped);
do_check_true(download.succeeded);
do_check_false(download.hasBlockedData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
do_check_true(yield OS.File.exists(download.target.path));
download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
confirmBlockPromise = download.confirmBlock();
unblockPromise = download.unblock();
yield unblockPromise.then(() => {
do_throw("unblock should have failed.");
}, () => {});
yield confirmBlockPromise;
// After confirming the block the download should be in a failed state and
// have no downloaded data left on disk.
do_check_true(download.stopped);
do_check_false(download.succeeded);
do_check_false(download.hasBlockedData);
do_check_false(yield OS.File.exists(download.target.partFilePath));
do_check_false(yield OS.File.exists(download.target.path));
});
/**
* Checks that unblocking a blocked download fails if the blocked data has been
* removed.
*/
add_task(function test_blocked_applicationReputation_unblock()
{
let download = yield promiseBlockedDownload({
keepPartialData: true,
keepBlockedData: true,
});
do_check_true(download.hasBlockedData);
do_check_true(yield OS.File.exists(download.target.partFilePath));
// Remove the blocked data without telling the download.
yield OS.File.remove(download.target.partFilePath);
let unblockPromise = download.unblock();
yield unblockPromise.then(() => {
do_throw("unblock should have failed.");
}, () => {});
// Even though unblocking failed the download state should have been updated
// to reflect the lack of blocked data.
do_check_false(download.hasBlockedData);
do_check_true(download.stopped);
do_check_false(download.succeeded);
});
/**